From c7c720d887aacb3036f6b576385318e7e3cce5cf Mon Sep 17 00:00:00 2001 From: Nick Bobrowski <39348559+nicko-ai@users.noreply.github.com> Date: Fri, 12 Jun 2026 00:41:43 +0100 Subject: [PATCH 1/2] feat(opencode): restore build and plan modes - add /mode as the Build, Plan, and Run product switch - route Build and Plan through native OpenCode instructions with Agent Swarm guidance - keep Run server-backed and enable native Plan handoff to Build - add terminal E2E coverage for mode visibility, routing, and plan approval --- AGENTS.md | 1 + FORK_CHANGELOG.md | 30 +- README.md | 10 +- USER_FLOWS.md | 27 +- e2e/agent-swarm-tui/QA_COVERAGE.md | 2 + e2e/agent-swarm-tui/harness.ts | 283 +++++++++++++++- e2e/agent-swarm-tui/terminal-tui.test.ts | 306 +++++++++++++++++- packages/opencode/src/agent/display.ts | 1 - packages/opencode/src/cli/cmd/agency.ts | 6 +- packages/opencode/src/cli/cmd/tui/app.tsx | 12 + .../cli/cmd/tui/component/dialog-agent.tsx | 1 + .../src/cli/cmd/tui/component/dialog-mode.tsx | 40 +++ .../cli/cmd/tui/component/dialog-model.tsx | 1 + .../cli/cmd/tui/component/dialog-provider.tsx | 5 + .../cmd/tui/component/prompt/autocomplete.tsx | 3 +- .../cli/cmd/tui/component/prompt/index.tsx | 32 +- .../tui/context/agency-swarm-connection.tsx | 1 + .../src/cli/cmd/tui/context/local.tsx | 78 ++++- .../cmd/tui/routes/session/dialog-message.tsx | 1 + .../src/cli/cmd/tui/routes/session/index.tsx | 6 +- .../opencode/src/cli/cmd/tui/session-error.ts | 5 +- packages/opencode/src/cli/cmd/tui/thread.ts | 1 + packages/opencode/src/session/prompt.ts | 36 ++- packages/opencode/src/tool/plan-exit.txt | 2 +- packages/opencode/test/agent/display.test.ts | 5 +- .../cli/tui/prompt-framework-mode.test.tsx | 3 + 26 files changed, 834 insertions(+), 64 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-mode.tsx diff --git a/AGENTS.md b/AGENTS.md index 33754f0a08..d99da6a476 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,7 @@ Policy summaries live in AGENTS.md files. Do not check in duplicate copies of gl - Drive any smoke automation yourself when it should finish in under 3 minutes. - When relevant full testing should finish in under 5 minutes, run it yourself before handoff. - When relevant full testing is likely to take more than 5 minutes, give the user a ready-to-run script and exact manual QA steps instead of calling the work done. +- For integration-heavy Agent Swarm, OpenCode TUI, or CLI behavior, prefer the smallest meaningful integration or end-to-end tests that drive real user flows and prove boundaries. Unit tests are fine for isolated pure or local logic, but mocked unit tests are not enough proof for integration behavior. - Test every user-facing function touched by the change, and every nearby function that the change could affect. - If a check needs secrets, live services, global installs, or long human QA, say that clearly and give the safest exact command or script for the user to run. diff --git a/FORK_CHANGELOG.md b/FORK_CHANGELOG.md index 12b42b0efa..8a4367cd82 100644 --- a/FORK_CHANGELOG.md +++ b/FORK_CHANGELOG.md @@ -13,7 +13,8 @@ When a change is suspicious, unproven, not clearly fork-specific, or not clearly - `/auth` is the credentials flow, not a product mode switch. - `/models` chooses the LLM config passed to Agency Swarm, not a product mode switch. - Upstream OpenCode provider/model state may still exist internally for auth and LLM choice, but it must not pull the user out of Run mode by accident. -- Agent Builder and Plan still exist conceptually, but they are currently hidden or disabled in Run mode and continue to rely on the native OpenCode backbone plus fork-specific instructions. +- `/mode` is the product mode switch. It exposes Build, Plan, and Run without adding `/build` or `/plan` commands. +- Build and Plan rely on native OpenCode behavior plus fork-specific Agent Swarm instructions. Run is the Agency Swarm server-backed mode. - Bug-like changes are not product features. Compare them against upstream, find the root cause, reduce divergence, and avoid fork-only workarounds. - Install, launcher, and package behavior count as user experience and belong in this file when they are intentional fork behavior. - `USER_FLOWS.md` is the single source of truth for full QA before every release; implementation-level file paths and symbols stay in this changelog. @@ -27,7 +28,7 @@ Use this index with `USER_FLOWS.md` when a QA row needs the owning fork implemen - Downstream product profile: `packages/opencode/src/agency-swarm/product.ts`, `packages/opencode/src/agency-swarm/npx.ts`, `packages/opencode/src/cli/cmd/tui/util/env-file.ts`, `packages/opencode/src/agency-swarm/server-launcher.ts`, `packages/opencode/src/installation/distribution.ts`, `packages/opencode/script/build.ts`. - Local project setup, starter creation, and onboarding auto-launch: `packages/opencode/src/agency-swarm/npx.ts`, `packages/opencode/src/cli/cmd/tui/thread.ts`, `packages/opencode/src/cli/cmd/tui/app.tsx`, `packages/opencode/src/cli/cmd/tui/routes/home.tsx`. - Agency session resume and bridge recovery: `packages/opencode/src/agency-swarm/run-session.ts`, `packages/opencode/src/agency-swarm/npx.ts`, `packages/opencode/src/session/agency-swarm.ts`, `packages/opencode/src/cli/cmd/tui/session-error.ts`, `packages/opencode/src/cli/cmd/tui/context/agency-swarm-connection.tsx`. -- Connection, auth, and provider dialogs: `packages/opencode/src/cli/cmd/tui/app.tsx`, `packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx`, `packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx`, `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`, `packages/opencode/src/cli/cmd/tui/session-error.ts`. +- Connection, auth, mode, and provider dialogs: `packages/opencode/src/cli/cmd/tui/app.tsx`, `packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx`, `packages/opencode/src/cli/cmd/tui/component/dialog-mode.tsx`, `packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx`, `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`, `packages/opencode/src/cli/cmd/tui/session-error.ts`. - Run-mode routing, add-ons, models, and attachments: `packages/opencode/src/agency-swarm/adapter.ts`, `packages/opencode/src/session/agency-swarm.ts`, `packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx`, `packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx`, `packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx`, `packages/opencode/src/cli/cmd/tui/context/local.tsx`, `packages/opencode/src/cli/cmd/tui/util/agency-target.ts`. - Run-mode local models: `packages/opencode/src/agency-swarm/ollama.ts`, `packages/opencode/src/agency-swarm/litellm-provider.ts`, `packages/opencode/src/agency-swarm/client-config.ts`, `packages/opencode/src/provider/provider.ts`, `packages/opencode/src/cli/cmd/tui/component/download-ollama-model.tsx`, `packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx`, `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`, `packages/opencode/src/cli/cmd/tui/session-error.ts`. - Builder and Plan preservation: `packages/opencode/src/session/agent-builder.ts`, `packages/opencode/src/session/agent-planner.ts`, `packages/opencode/src/session/prompt/agent-builder.txt`, `packages/opencode/src/session/prompt/agent-planner.txt`. @@ -212,7 +213,7 @@ Use this index with `USER_FLOWS.md` when a QA row needs the owning fork implemen - **Agency backend management commands are debugging and development tools** - Intent: keep backend lifecycle commands available for debugging and development without treating them as the main end-user path. - - Behavior: the fork exposes backend install and maintenance commands, but they are debugging and development tools rather than core product surface. + - Behavior: the fork exposes backend install and maintenance commands, but they are debugging and development tools rather than core product surface. `agentswarm agency agent new` remains hidden from help instead of being expanded as an Agent Builder path. - Implementation: `AgencyCommand` in `packages/opencode/src/cli/cmd/agency.ts`. - Added by: `14abd070` @@ -228,15 +229,16 @@ Use this index with `USER_FLOWS.md` when a QA row needs the owning fork implemen - Implementation: `agentPlannerInstructions` in `packages/opencode/src/session/agent-planner.ts` with `packages/opencode/src/session/prompt/agent-planner.txt`. - Added by: `7643fcde` -- **Builder and Plan switching are hidden in Run mode** - - Intent: keep Run mode focused on connected Agency Swarm execution while Builder and Plan stay conceptually preserved but currently hidden. - - Behavior: in Run mode, the picker becomes a run-target picker instead of a Builder or Plan mode switcher. - - Implementation: `frameworkMode` and `cycleAgencyRunTarget` in `packages/opencode/src/cli/cmd/tui/app.tsx` plus `DialogAgent` in `packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx`. +- **`/mode` exposes Build, Plan, and Run** + - Intent: let users move between native Build, native Plan, and server-backed Run inside one project without adding parallel Build or Plan behavior. + - Behavior: `/mode` switches product mode. Build selects the native `build` agent, Plan selects the native `plan` agent, and Run keeps prompts server-backed through Agency Swarm. `/build` and `/plan` are not slash commands. + - Behavior: leaving Run stops prompt routing through the Agency Swarm backend; returning to Run reconnects to or keeps using the configured Agency Swarm server. + - Implementation: product mode state in `packages/opencode/src/cli/cmd/tui/context/local.tsx`, `DialogMode` in `packages/opencode/src/cli/cmd/tui/component/dialog-mode.tsx`, and framework-mode gates in `packages/opencode/src/cli/cmd/tui/session-error.ts`. - Added by: `d6b9ed38` -- **Tab switches agents in Run mode** - - Intent: speed up agent switching during run sessions. - - Behavior: pressing Tab in Run mode cycles through available agents. +- **Tab switches native agents outside Run and swarm agents in Run** + - Intent: preserve upstream native agent cycling while keeping fast target switching during run sessions. + - Behavior: pressing Tab in Run mode cycles through available Agency Swarm targets. In Build and Plan, Tab keeps the native OpenCode local-agent cycle behavior. - Implementation: `cycleAgencyRunTarget` in `packages/opencode/src/cli/cmd/tui/app.tsx` and `cycleAgencyTargetSelection` in `packages/opencode/src/cli/cmd/tui/util/agency-target.ts`. - Added by: `d6b9ed38` @@ -253,8 +255,8 @@ Use this index with `USER_FLOWS.md` when a QA row needs the owning fork implemen - Added by: `PR #51` - **Run mode hides native OpenCode menus and limits model selection** - - Intent: keep Run mode on the connected Agency Swarm surface while preserving native OpenCode menus for Builder and Plan when those modes are available again. - - Behavior: Run mode hides native `/editor`, `/variants`, `/init`, and `/review`; model-selection and provider-auth surfaces remain as support flows for LLM choice and credentials, and are limited to intended Agent Swarm / Agency Swarm providers. + - Intent: keep Run mode on the connected Agency Swarm surface while preserving native OpenCode menus for Build and Plan. + - Behavior: Run mode hides native `/editor`, `/variants`, `/init`, and `/review`; those commands return in Build and Plan. Model-selection and provider-auth surfaces remain as Run support flows for LLM choice and credentials, and are limited to intended Agent Swarm / Agency Swarm providers. - Implementation: framework-mode command gating in `packages/opencode/src/cli/cmd/tui/app.tsx`, `packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx`, `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`, and `packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx`. - Added by: `PR #81` @@ -331,8 +333,8 @@ Use this index with `USER_FLOWS.md` when a QA row needs the owning fork implemen - **README mode overview explains Builder, Plan, and Run** - Intent: document the fork's mode model clearly at the top level. - - Behavior: the README explains Agent Builder, Plan, and Run, with Run as the connected Agency Swarm path and Builder or Plan preserved conceptually even if hidden in current Run mode. - - Implementation: the `### Agents` section in `README.md`. + - Behavior: the README explains `/mode`, Build, Plan, and Run, with Run as the connected Agency Swarm path and Build or Plan as native OpenCode modes under the Agent Swarm umbrella. + - Implementation: the `Main TUI Flows` section in `README.md`. - Added by: `1df2f455` - **Canonical flow map and QA source of truth** diff --git a/README.md b/README.md index ccb2148186..b70b18cc9e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Agent Swarm CLI is a terminal app for running and testing Agency Swarm projects. It is built on the OpenCode codebase, with Agent Swarm-specific packaging, branding, auth, and TUI flows. -The main user path is **Run mode**: start the TUI from an Agency Swarm project, authenticate a model provider, connect to the local Agency Swarm server, and send prompts to your agency. +The main user path is **Run mode**: start the TUI from an Agency Swarm project, authenticate a model provider, connect to the local Agency Swarm server, and send prompts to your swarm. ## Install @@ -36,13 +36,15 @@ On startup, the CLI can detect the project, prepare the project Python environme ## Main TUI Flows +- `/mode` switches between Build, Plan, and Run. +- Build is Agent Builder: native OpenCode build behavior with Agent Swarm guidance. +- Plan is native OpenCode Plan mode for preparing work before Build. +- Run connects to or starts an Agency Swarm FastAPI server and sends prompts to the active swarm. - `/auth` manages OpenAI and Anthropic credentials used by Agency Swarm runs. - `/connect` chooses a local or external Agency Swarm server. -- `/agents` switches the active swarm or agent from live Agency Swarm metadata. +- `/agents` switches native agents in Build and Plan. In Run, it switches the active swarm or agent from live Agency Swarm metadata. - `/models` is limited in Run mode to providers that the Agency Swarm path supports. -Agent Builder and Plan are preserved from the OpenCode backbone, but they are currently hidden from the normal Run mode surface. - ## Sharing `/share` is still the upstream OpenCode share flow and currently posts to `https://opncd.ai`. diff --git a/USER_FLOWS.md b/USER_FLOWS.md index c950787e48..7d77daa947 100644 --- a/USER_FLOWS.md +++ b/USER_FLOWS.md @@ -12,7 +12,7 @@ Keep implementation-level file paths and symbol details in `FORK_CHANGELOG.md`; - Launcher and install behavior for `npx @vrsen/agentswarm`, installed `agentswarm`, the direct fork binary, and `agentswarm pr`. - Downstream package builds that reuse this TUI foundation through generic product profile inputs. - Local Agency Swarm project detection, starter creation, Python environment repair, uv setup, bridge startup, resume recovery, and external Agency server connection. -- TUI Run mode routing, `/auth`, `/connect`, run-target selection, attachments, history, handoffs, dead-server recovery, and hidden upstream-native commands. +- TUI `/mode`, native Build and Plan exposure, Run mode routing, `/auth`, `/connect`, run-target selection, attachments, history, handoffs, dead-server recovery, and hidden upstream-native commands in Run. - Fork branding, tips, theme, config precedence, upgrade channel limits, share carry-forward, and developer/debug `agentswarm agency` commands. - Trust-safe telemetry metrics, event-list docs, derived dashboard metrics, opt-out behavior, and privacy proof for fork-owned Agent Swarm flows. - Out of scope: Python-side `agency.tui()` invocation before control reaches this repo. @@ -182,6 +182,7 @@ For each failure scenario, capture the visible user result and cite the matching - **Happy-path proof:** In-flight Agency runs cancel through the bridge. - **Happy-path proof:** Codex OAuth is stripped from non-OpenAI LiteLLM agency runs. - **Happy-path proof:** Run mode hides Builder, Plan, `/editor`, `/variants`, `/init`, `/review`, and other disabled upstream-native surfaces. +- **Happy-path proof:** `/mode` remains available in Run so the user can switch to Build or Plan without leaving the project. - **Happy-path proof:** `/models` and `/auth` are limited to Agency-supported providers. - **Happy-path proof:** Upstream provider/model state used for auth or LLM choice does not pull the user out of Run mode by accident. - **Happy-path proof:** `agency-swarm/default` stays active over stale stored model state until the user explicitly chooses another model. @@ -193,13 +194,20 @@ For each failure scenario, capture the visible user result and cite the matching - **Failure scenarios to test:** Non-OpenAI LiteLLM agency runs do not receive Codex OAuth credentials while OpenAI-based LiteLLM runs still keep them. - **Failure scenarios to test:** Missing or unreachable Ollama fails visibly without switching out of Run mode. -#### Builder and Plan instruction preservation - -- **Trigger:** Builder or Plan flows are exercised outside Run-mode hiding. -- **Happy-path proof:** Builder still uses fork-specific Agency Swarm repo instructions. -- **Happy-path proof:** Plan still writes Agency Swarm handoff plans instead of upstream OpenCode defaults. -- **Failure scenarios to test:** Run mode keeps Builder and Plan switching hidden. -- **Failure scenarios to test:** Any re-enabled Builder or Plan path keeps the fork-specific prompt instructions. +#### `/mode`, Build, and Plan + +- **Trigger:** The user runs `/mode` and chooses Build, Plan, or Run. +- **Happy-path proof:** `/mode` is the product mode switch; `/build` and `/plan` slash commands do not exist. +- **Happy-path proof:** Build uses native OpenCode build behavior with fork-specific Agent Swarm repo instructions and works without an Agency Swarm server. +- **Happy-path proof:** Plan uses native OpenCode Plan mode with fork-specific Agent Swarm handoff instructions and works without an Agency Swarm server. +- **Happy-path proof:** Build and Plan prompts use native local agent state, not the Agency Swarm backend target state. +- **Happy-path proof:** Native OpenCode commands hidden in Run return in Build and Plan. +- **Happy-path proof:** `/agents` uses the native OpenCode agent picker in Build and Plan. +- **Happy-path proof:** Tab cycles native local agents in Build and Plan. +- **Happy-path proof:** Switching from Run to Build or Plan stops treating prompts as server-backed Run prompts. +- **Happy-path proof:** Switching back to Run in the same project reconnects to or keeps using the Agency Swarm server and returns `/agents` to the swarm/agent picker. +- **Failure scenarios to test:** Server failure in Run still lets the user switch to Build or Plan in the same project, make a plan or build a fix, then return to Run. +- **Failure scenarios to test:** Run mode stays server-backed even when visible provider/model state is native. #### Attachments, history, and handoff @@ -241,9 +249,8 @@ For each failure scenario, capture the visible user result and cite the matching - **Happy-path proof:** `connect` stores normalized backend config and optional bearer token. - **Happy-path proof:** `agencies` discovers available agencies. - **Happy-path proof:** `use` pins a default agency id. -- **Happy-path proof:** `agent` provides Agent Builder scaffold helpers. +- **Happy-path proof:** `agent` scaffold helpers are not promoted in CLI help. - **Failure scenarios to test:** URL normalization and discovery failures surface in the CLI command. -- **Failure scenarios to test:** `agentswarm agency agent new` fails visibly when `agency-swarm create-agent-template` fails. ### Trust-Safe Telemetry diff --git a/e2e/agent-swarm-tui/QA_COVERAGE.md b/e2e/agent-swarm-tui/QA_COVERAGE.md index f43b0d4f0c..21ad7faa91 100644 --- a/e2e/agent-swarm-tui/QA_COVERAGE.md +++ b/e2e/agent-swarm-tui/QA_COVERAGE.md @@ -7,6 +7,8 @@ - `USER_FLOWS.md` Detected Local Project: launcher mode shows the detected-project choice before `.venv` work begins. - `USER_FLOWS.md` Startup `/auth` and In-TUI `/connect`: `/auth` and `/connect` stay separate in the real terminal UI. - `USER_FLOWS.md` Run Mode: native `/editor`, `/variants`, `/init`, and `/review` slash commands stay hidden. +- `USER_FLOWS.md` `/mode`, Build, and Plan: `/mode` appears as the product switch, `/build` and `/plan` do not exist, Build and Plan restore native `/review` and `/init`, and switching back to Run hides native commands again. +- `USER_FLOWS.md` `/mode`, Build, and Plan: Tab cycles native local agents outside Run and Agency Swarm targets in Run. - `USER_FLOWS.md` Run Mode: `/agents` uses Swarm and agent wording, live agency labels, swarm-row routing, and specific-agent routing against an Agency Swarm TUI-demo-shaped swarm. - `USER_FLOWS.md` Run Mode: prompt submit reaches a local Agency Swarm protocol server with the configured agent. - `USER_FLOWS.md` Run Mode: simulated visible OpenAI model state does not pull prompts, slash-command `/new`, run-session local-project marking, `/connect`, or runtime auth recovery out of Agency Swarm routing. diff --git a/e2e/agent-swarm-tui/harness.ts b/e2e/agent-swarm-tui/harness.ts index 92193d4d42..12ef67a36b 100644 --- a/e2e/agent-swarm-tui/harness.ts +++ b/e2e/agent-swarm-tui/harness.ts @@ -44,8 +44,271 @@ export type AgencyProtocolServer = { stop(): void } +export type NativeLLMServer = { + baseURL: string + requests: Array<{ + path: string + body: Record + }> + planExitNext(): void + stop(): void +} + type AgencyServerScenario = "qa" | "tui-demo" +export async function startNativeLLMServer(): Promise { + const requests: NativeLLMServer["requests"] = [] + let planExitUsed = false + let planExitNext = false + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: async (request) => { + const url = new URL(request.url) + if (request.method !== "POST") return new Response("not found", { status: 404 }) + if (url.pathname !== "/v1/responses" && url.pathname !== "/v1/chat/completions") { + return new Response("not found", { status: 404 }) + } + + const body = (await request.json().catch(() => ({}))) as Record + requests.push({ path: url.pathname, body }) + const bodyText = JSON.stringify(body) + const shouldPlanExit = !planExitUsed && planExitNext && !bodyText.includes("title generator") + + if (url.pathname === "/v1/responses") { + if (shouldPlanExit) { + planExitUsed = true + planExitNext = false + return new Response( + eventStream([ + { + type: "response.created", + sequence_number: 1, + response: { + id: `resp_native_${requests.length}`, + created_at: Math.floor(Date.now() / 1000), + model: typeof body.model === "string" ? body.model : "gpt-5.4-mini", + service_tier: null, + }, + }, + { + type: "response.output_item.added", + sequence_number: 2, + output_index: 0, + item: { + type: "function_call", + id: "fc_plan_exit", + call_id: "call_plan_exit", + name: "plan_exit", + arguments: "", + status: "in_progress", + }, + }, + { + type: "response.function_call_arguments.done", + sequence_number: 3, + output_index: 0, + item_id: "fc_plan_exit", + arguments: "{}", + }, + { + type: "response.output_item.done", + sequence_number: 4, + output_index: 0, + item: { + type: "function_call", + id: "fc_plan_exit", + call_id: "call_plan_exit", + name: "plan_exit", + arguments: "{}", + status: "completed", + }, + }, + { + type: "response.completed", + sequence_number: 5, + response: { + incomplete_details: null, + service_tier: null, + usage: { + input_tokens: 1, + input_tokens_details: { cached_tokens: null }, + output_tokens: 1, + output_tokens_details: { reasoning_tokens: null }, + }, + }, + }, + ]), + { + headers: { "Content-Type": "text/event-stream" }, + }, + ) + } + + return new Response( + eventStream([ + { + type: "response.created", + sequence_number: 1, + response: { + id: `resp_native_${requests.length}`, + created_at: Math.floor(Date.now() / 1000), + model: typeof body.model === "string" ? body.model : "gpt-5.4-mini", + service_tier: null, + }, + }, + { + type: "response.output_item.added", + sequence_number: 2, + output_index: 0, + item: { type: "message", id: "msg_native" }, + }, + { + type: "response.output_text.delta", + sequence_number: 3, + item_id: "msg_native", + delta: "native-mode-ok", + logprobs: null, + }, + { + type: "response.completed", + sequence_number: 4, + response: { + incomplete_details: null, + service_tier: null, + usage: { + input_tokens: 1, + input_tokens_details: { cached_tokens: null }, + output_tokens: 1, + output_tokens_details: { reasoning_tokens: null }, + }, + }, + }, + ]), + { + headers: { "Content-Type": "text/event-stream" }, + }, + ) + } + + if (shouldPlanExit) { + planExitUsed = true + planExitNext = false + return new Response( + eventStream([ + { + id: `chatcmpl_native_${requests.length}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: typeof body.model === "string" ? body.model : "gpt-5.4-mini", + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }, + { + id: `chatcmpl_native_${requests.length}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: typeof body.model === "string" ? body.model : "gpt-5.4-mini", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_plan_exit", + type: "function", + function: { + name: "plan_exit", + arguments: "", + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: `chatcmpl_native_${requests.length}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: typeof body.model === "string" ? body.model : "gpt-5.4-mini", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + function: { + arguments: "{}", + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: `chatcmpl_native_${requests.length}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: typeof body.model === "string" ? body.model : "gpt-5.4-mini", + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }, + ]), + { + headers: { "Content-Type": "text/event-stream" }, + }, + ) + } + + return new Response( + eventStream([ + { + id: `chatcmpl_native_${requests.length}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: typeof body.model === "string" ? body.model : "gpt-5.4-mini", + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }, + { + id: `chatcmpl_native_${requests.length}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: typeof body.model === "string" ? body.model : "gpt-5.4-mini", + choices: [{ index: 0, delta: { content: "native-mode-ok" }, finish_reason: null }], + }, + { + id: `chatcmpl_native_${requests.length}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: typeof body.model === "string" ? body.model : "gpt-5.4-mini", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }, + ]), + { + headers: { "Content-Type": "text/event-stream" }, + }, + ) + }, + }) + + return { + baseURL: `http://${server.hostname}:${server.port}/v1`, + requests, + planExitNext() { + planExitNext = true + }, + stop() { + server.stop(true) + }, + } +} + export async function startAgencyProtocolServer( input: { scenario?: AgencyServerScenario } = {}, ): Promise { @@ -192,6 +455,7 @@ function mergeSingleProviderConfig(base: ProviderConfig | undefined, override: P export async function startTui(input: { args?: string[] + binaryPath?: string cwd?: string env?: Record baseURL?: string @@ -238,7 +502,7 @@ export async function startTui(input: { ...(configContent && input.configSource !== "file" ? { OPENCODE_CONFIG_CONTENT: configContent } : {}), ...(input.env ?? {}), }) - let proc = spawnTuiProcess({ args, cwd: input.cwd, env }) + let proc = spawnTuiProcess({ args, binaryPath: input.binaryPath, cwd: input.cwd, env }) let dataReceived = false let activeProc = proc @@ -268,7 +532,7 @@ export async function startTui(input: { onRetry: async () => { await closeProcess(proc) await Bun.sleep(initialOutputRetryDelayMs) - proc = spawnTuiProcess({ args, cwd: input.cwd, env }) + proc = spawnTuiProcess({ args, binaryPath: input.binaryPath, cwd: input.cwd, env }) dataReceived = false exitCode = undefined attachProcess(proc) @@ -325,8 +589,15 @@ export async function startTui(input: { } } -function spawnTuiProcess(input: { args: string[]; cwd?: string; env: Record }) { - return spawn(process.execPath, ["--conditions=browser", "./src/index.ts", ...input.args], { +function spawnTuiProcess(input: { + args: string[] + binaryPath?: string + cwd?: string + env: Record +}) { + const command = input.binaryPath ?? process.execPath + const args = input.binaryPath ? input.args : ["--conditions=browser", "./src/index.ts", ...input.args] + return spawn(command, args, { cwd: input.cwd ?? packageRoot, cols: 100, rows: 30, @@ -772,6 +1043,10 @@ function sse(events: Array<[event: string, data: Record]>) { return events.map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`).join("") } +function eventStream(chunks: unknown[]) { + return chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + "data: [DONE]\n\n" +} + function controlledSse( events: Array<[event: string, data: Record]>, streamReleased: Promise, diff --git a/e2e/agent-swarm-tui/terminal-tui.test.ts b/e2e/agent-swarm-tui/terminal-tui.test.ts index 0fd54c0e34..54cb11c7b7 100644 --- a/e2e/agent-swarm-tui/terminal-tui.test.ts +++ b/e2e/agent-swarm-tui/terminal-tui.test.ts @@ -8,15 +8,18 @@ import { latestOpenAITestModelLabel, openAIProviderTestConfig, startAgencyProtocolServer, + startNativeLLMServer, startTui, startTuiDemoAgencyServer, writeAgencyProject, + type NativeLLMServer, type TuiProcess, type AgencyProtocolServer, } from "./harness" let currentTui: TuiProcess | undefined let currentServer: AgencyProtocolServer | undefined +let currentNativeServer: NativeLLMServer | undefined let currentTelemetryServer: ReturnType | undefined const tempDirs: string[] = [] const tuiReadyTimeoutMs = process.env.CI ? 120_000 : 30_000 @@ -36,11 +39,39 @@ function hasCommand(screen: string, command: string) { return screen.split("\n").some((line) => new RegExp(`┃\\s+${command}\\b`).test(line)) } +async function waitForCommand(tui: TuiProcess, command: string) { + await tui.waitFor(() => hasCommand(tui.screen(), command), `${command} command`, tuiInteractionTimeoutMs) + return tui.screen() +} + +function clearPrompt(tui: TuiProcess) { + tui.write("\b".repeat(80)) +} + +function hasModeDialog(screen: string) { + return ( + screen.includes("Select mode") && + screen.includes("Build") && + screen.includes("Plan") && + screen.includes("Run") && + !screen.includes("Select model") + ) +} + +function footerHasMode(screen: string, mode: string) { + return screen + .split("\n") + .slice(-16) + .some((line) => line.includes(`${mode} ·`) && line.includes("OpenAI")) +} + afterEach(async () => { await currentTui?.close() currentTui = undefined currentServer?.stop() currentServer = undefined + currentNativeServer?.stop() + currentNativeServer = undefined currentTelemetryServer?.stop() currentTelemetryServer = undefined await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) @@ -123,7 +154,7 @@ describe("Agent Swarm terminal TUI e2e", () => { ) const appEvent = currentTelemetryServer.events.find((event) => event.event === "app_started") expect(appEvent?.properties).toMatchObject({ - "$process_person_profile": false, + $process_person_profile: false, app: "Agent Swarm", entrypoint: "tui", framework_mode: true, @@ -136,12 +167,17 @@ describe("Agent Swarm terminal TUI e2e", () => { tuiInteractionTimeoutMs, ) const commandEvent = currentTelemetryServer.events.find((event) => event.event === "ui_command_executed") - expect(commandEvent?.properties).toMatchObject({ "$process_person_profile": false, app: "Agent Swarm", command: "auth", source: "slash" }) + expect(commandEvent?.properties).toMatchObject({ + $process_person_profile: false, + app: "Agent Swarm", + command: "auth", + source: "slash", + }) expect(JSON.stringify(commandEvent)).not.toContain("sk-test-telemetry") expect(JSON.stringify(commandEvent)).not.toContain("refresh") const requested = currentTelemetryServer.events.find((event) => event.event === "provider_requested") expect(requested?.properties).toMatchObject({ - "$process_person_profile": false, + $process_person_profile: false, app: "Agent Swarm", connected_before: false, framework_mode: true, @@ -150,7 +186,7 @@ describe("Agent Swarm terminal TUI e2e", () => { }) const started = currentTelemetryServer.events.find((event) => event.event === "provider_auth_started") expect(started?.properties).toMatchObject({ - "$process_person_profile": false, + $process_person_profile: false, app: "Agent Swarm", auth_method: "api", framework_mode: true, @@ -160,7 +196,7 @@ describe("Agent Swarm terminal TUI e2e", () => { const authEvent = currentTelemetryServer.events.find((event) => event.event === "provider_auth_configured") expect(authEvent?.api_key).toBe("ph_test") expect(authEvent?.properties).toMatchObject({ - "$process_person_profile": false, + $process_person_profile: false, app: "Agent Swarm", auth_method: "api", framework_mode: true, @@ -300,6 +336,237 @@ describe("Agent Swarm terminal TUI e2e", () => { } }) + test("/mode is the product switch and does not add /build or /plan commands", async () => { + currentServer = await startAgencyProtocolServer() + currentTui = await startTui({ baseURL: currentServer.baseURL }) + + await currentTui.waitForText("Swarm Default", tuiReadyTimeoutMs) + currentTui.write("/") + const screen = await waitForCommand(currentTui, "/mode") + + expect(hasCommand(screen, "/mode")).toBe(true) + expect(hasCommand(screen, "/agents")).toBe(true) + expect(hasCommand(screen, "/build")).toBe(false) + expect(hasCommand(screen, "/plan")).toBe(false) + expect(hasCommand(screen, "/review")).toBe(false) + + currentTui.write("mode\r") + await currentTui.waitForText("Select mode", tuiInteractionTimeoutMs) + }) + + test("/mode Build and Plan restore native command visibility", async () => { + for (const mode of ["Build", "Plan"] as const) { + currentServer = await startAgencyProtocolServer() + currentTui = await startTui({ baseURL: currentServer.baseURL }) + + await currentTui.waitForText("Swarm Default", tuiReadyTimeoutMs) + await selectProductMode(currentTui, mode) + currentTui.write("/rev") + const screen = await waitForCommand(currentTui, "/review") + + expect(hasCommand(screen, "/review")).toBe(true) + expect(hasCommand(screen, "/build")).toBe(false) + expect(hasCommand(screen, "/plan")).toBe(false) + + await currentTui.close() + currentTui = undefined + currentServer.stop() + currentServer = undefined + } + }) + + test("/mode Build and Plan stay native after a Run-mode Agency response", async () => { + for (const mode of ["Build", "Plan"] as const) { + currentServer = await startTuiDemoAgencyServer() + currentNativeServer = await startNativeLLMServer() + currentTui = await startTui({ + baseURL: currentServer.baseURL, + agency: "tui-demo-agency", + recipientAgent: "UserSupportAgent", + configSource: "file", + config: { + enabled_providers: openAIProviderTestConfig.enabled_providers, + provider: { + openai: { + options: { + apiKey: "test-openai-key", + baseURL: currentNativeServer.baseURL, + }, + }, + }, + }, + }) + + await currentTui.waitForText("Swarm Default", tuiReadyTimeoutMs) + currentTui.write(`run before ${mode.toLowerCase()} mode switch\r`) + await currentTui.waitForText("TUI demo response complete.", tuiInteractionTimeoutMs) + await currentTui.waitFor( + () => currentServer!.requests.length === 1, + `Run-mode Agency request before ${mode}`, + tuiInteractionTimeoutMs, + ) + const agencyRequests = currentServer.requests.length + + await selectProductMode(currentTui, mode) + currentTui.write("who are you\r") + await currentTui.waitForText("native-mode-ok", tuiInteractionTimeoutMs) + + const request = await waitForNativeLLMRequest(currentTui, currentNativeServer, "who are you") + const body = JSON.stringify(request.body) + expect(body).toContain("You are OpenCode") + expect(body).toContain( + mode === "Build" ? "Agent Swarm Agent Builder Instructions" : "Agent Swarm Planner Instructions", + ) + if (mode === "Plan") expect(body).toContain("plan_exit") + expect(currentServer.requests).toHaveLength(agencyRequests) + + await currentTui.close() + currentTui = undefined + currentServer.stop() + currentServer = undefined + currentNativeServer.stop() + currentNativeServer = undefined + } + }) + + test("/mode Build and Plan stay native when launcher-style env config defaults to Run", async () => { + for (const mode of ["Build", "Plan"] as const) { + currentServer = await startTuiDemoAgencyServer() + currentNativeServer = await startNativeLLMServer() + currentTui = await startTui({ + baseURL: currentServer.baseURL, + agency: "tui-demo-agency", + recipientAgent: "UserSupportAgent", + config: { + enabled_providers: openAIProviderTestConfig.enabled_providers, + provider: { + openai: { + options: { + apiKey: "test-openai-key", + baseURL: currentNativeServer.baseURL, + }, + }, + }, + }, + }) + + await currentTui.waitForText("Swarm Default", tuiReadyTimeoutMs) + await selectProductMode(currentTui, mode) + currentTui.write(`who are you from ${mode.toLowerCase()} env config\r`) + await currentTui.waitForText("native-mode-ok", tuiInteractionTimeoutMs) + + await waitForNativeLLMRequest( + currentTui, + currentNativeServer, + `who are you from ${mode.toLowerCase()} env config`, + ) + const request = currentNativeServer.requests.find((item) => + JSON.stringify(item.body).includes( + mode === "Build" ? "Agent Swarm Agent Builder Instructions" : "Agent Swarm Planner Instructions", + ), + ) + expect(request).toBeDefined() + const body = JSON.stringify(request.body) + expect(body).toContain( + mode === "Build" ? "Agent Swarm Agent Builder Instructions" : "Agent Swarm Planner Instructions", + ) + if (mode === "Plan") expect(body).toContain("plan_exit") + expect(currentServer.requests).toHaveLength(0) + + await currentTui.close() + currentTui = undefined + currentServer.stop() + currentServer = undefined + currentNativeServer.stop() + currentNativeServer = undefined + } + }) + + test("approving native Plan handoff switches the TUI to Build", async () => { + const planPrompt = "finish test plan with native plan_exit" + currentServer = await startTuiDemoAgencyServer() + currentNativeServer = await startNativeLLMServer() + currentTui = await startTui({ + baseURL: currentServer.baseURL, + agency: "tui-demo-agency", + recipientAgent: "UserSupportAgent", + configSource: "file", + config: { + enabled_providers: openAIProviderTestConfig.enabled_providers, + provider: { + openai: { + options: { + apiKey: "test-openai-key", + baseURL: currentNativeServer.baseURL, + }, + }, + }, + }, + }) + + await currentTui.waitForText("Swarm Default", tuiReadyTimeoutMs) + await selectProductMode(currentTui, "Plan") + await currentTui.waitFor(() => footerHasMode(currentTui!.screen(), "Plan"), "Plan footer", tuiInteractionTimeoutMs) + + currentNativeServer.planExitNext() + currentTui.write(`${planPrompt}\r`) + await currentTui.waitFor( + () => currentTui!.screen().includes("Would you like to") && currentTui!.screen().includes("switch to the build"), + "Plan approval question", + tuiInteractionTimeoutMs, + ) + currentTui.write("\r") + await currentTui.waitFor( + () => footerHasMode(currentTui!.screen(), "Build"), + "Build footer after plan approval", + tuiInteractionTimeoutMs, + ) + expect(currentServer.requests).toHaveLength(0) + }) + + test("/mode can return to server-backed Run after Build", async () => { + currentServer = await startTuiDemoAgencyServer() + currentTui = await startTui({ + baseURL: currentServer.baseURL, + agency: "tui-demo-agency", + recipientAgent: "UserSupportAgent", + configSource: "file", + }) + + await currentTui.waitForText("Swarm Default", tuiReadyTimeoutMs) + await selectProductMode(currentTui, "Build") + await selectProductMode(currentTui, "Run") + await currentTui.waitForText("Swarm Default", tuiInteractionTimeoutMs) + + currentTui.write("/") + const screen = await waitForCommand(currentTui, "/agents") + expect(hasCommand(screen, "/agents")).toBe(true) + expect(hasCommand(screen, "/review")).toBe(false) + }) + + test("Tab cycles native agents outside Run and swarm targets in Run", async () => { + currentServer = await startTuiDemoAgencyServer() + currentTui = await startTui({ + baseURL: currentServer.baseURL, + agency: "tui-demo-agency", + recipientAgent: "UserSupportAgent", + configSource: "file", + }) + + await currentTui.waitForText("UserSupportAgent", tuiReadyTimeoutMs) + await selectProductMode(currentTui, "Build") + currentTui.write("\t") + await currentTui.waitFor(() => currentTui!.screen().includes("Plan"), "native Plan agent", tuiInteractionTimeoutMs) + + await selectProductMode(currentTui, "Run") + currentTui.write("\t") + await currentTui.waitFor( + () => currentTui!.screen().includes("MathAgent · Swarm Default"), + "Run agent target", + tuiInteractionTimeoutMs, + ) + }) + test("run-target picker uses live agency labels instead of local-agency ids", async () => { currentServer = await startAgencyProtocolServer() currentTui = await startTui({ baseURL: currentServer.baseURL }) @@ -878,6 +1145,35 @@ async function selectRunTarget(tui: TuiProcess, query: string, successMessage: s await tui.waitForText(successMessage, tuiInteractionTimeoutMs) } +async function selectProductMode(tui: TuiProcess, mode: "Build" | "Plan" | "Run") { + clearPrompt(tui) + tui.write("/mode\r") + await tui.waitFor(() => hasModeDialog(tui.screen()), "current mode dialog", tuiInteractionTimeoutMs) + tui.write(mode) + await tui.waitFor( + () => hasModeDialog(tui.screen()) && tui.screen().includes(mode), + `${mode} mode option`, + tuiInteractionTimeoutMs, + ) + tui.write("\x1b[B") + tui.write("\r") + await tui.waitFor(() => !tui.screen().includes("Select mode"), `${mode} mode selected`, tuiInteractionTimeoutMs) + clearPrompt(tui) +} + +async function waitForNativeLLMRequest(tui: TuiProcess, server: NativeLLMServer, prompt: string) { + let request: NativeLLMServer["requests"][number] | undefined + await tui.waitFor( + () => { + request = server.requests.find((item) => JSON.stringify(item.body).includes(prompt)) + return request !== undefined + }, + `native LLM request containing ${prompt}`, + tuiInteractionTimeoutMs, + ) + return request! +} + async function selectCurrentSwarm(tui: TuiProcess) { tui.write("/agents\r") await tui.waitForText("TuiDemoAgency") diff --git a/packages/opencode/src/agent/display.ts b/packages/opencode/src/agent/display.ts index 1fa844e2bc..9b150d43a7 100644 --- a/packages/opencode/src/agent/display.ts +++ b/packages/opencode/src/agent/display.ts @@ -1,6 +1,5 @@ import * as Locale from "@/util/locale" export function displayAgentName(name: string) { - if (name === "build") return "Agent Builder" return Locale.titlecase(name) } diff --git a/packages/opencode/src/cli/cmd/agency.ts b/packages/opencode/src/cli/cmd/agency.ts index 6cd4f5dd5d..874e6769b5 100644 --- a/packages/opencode/src/cli/cmd/agency.ts +++ b/packages/opencode/src/cli/cmd/agency.ts @@ -10,7 +10,7 @@ import path from "path" export const AgencyCommand = cmd({ command: "agency", - describe: "Agency Swarm backend management: connect, discover agencies, set default agency, Agent Builder helpers", + describe: "Agency Swarm backend management: connect, discover swarms, set default swarm", builder: (yargs: Argv) => yargs .command(AgencyConnectCommand) @@ -165,14 +165,14 @@ const AgencyUseCommand = cmd({ const AgencyAgentCommand = cmd({ command: "agent", - describe: "Agency Swarm Agent Builder helpers", + describe: false, builder: (yargs: Argv) => yargs.command(AgencyAgentNewCommand).demandCommand(), async handler() {}, }) const AgencyAgentNewCommand = cmd({ command: "new ", - describe: "create a new Agency Swarm agent scaffold via `agency-swarm create-agent-template`", + describe: false, builder: (yargs: Argv) => yargs .positional("name", { diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index b9e592fc96..5bee28ffd3 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -39,6 +39,7 @@ import { SyncProvider, useSync } from "@tui/context/sync" import { SyncProviderV2 } from "@tui/context/sync-v2" import { LocalProvider, useLocal } from "@tui/context/local" import { DialogModel } from "@tui/component/dialog-model" +import { DialogMode } from "@tui/component/dialog-mode" import { useConnected } from "@tui/component/use-connected" import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogStatus } from "@tui/component/dialog-status" @@ -109,6 +110,7 @@ const appBindingCommands = [ "model.cycle_recent_reverse", "model.cycle_favorite", "model.cycle_favorite_reverse", + "product.mode", "agent.list", "mcp.list", "agent.cycle", @@ -457,6 +459,7 @@ function App(props: { onSnapshot?: () => Promise }) { const connected = useConnected() const frameworkMode = createMemo(() => isAgencySwarmFrameworkMode({ + productMode: local.product.current(), currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, @@ -644,6 +647,15 @@ function App(props: { onSnapshot?: () => Promise }) { })), ] : []), + { + name: "product.mode", + title: "Switch mode", + category: "Agent", + slashName: "mode", + run: () => { + dialog.replace(() => ) + }, + }, { name: "model.list", title: "Switch model", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx index 199e992da0..1d9a393e6c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx @@ -47,6 +47,7 @@ export function DialogAgent() { currentProviderID: currentModel()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, + productMode: local.product.current(), }), ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mode.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mode.tsx new file mode 100644 index 0000000000..c5d4d1367f --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mode.tsx @@ -0,0 +1,40 @@ +import { useLocal, type ProductMode } from "@tui/context/local" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" + +export function DialogMode() { + const local = useLocal() + const dialog = useDialog() + + const options: DialogSelectOption[] = [ + { + value: "build", + title: "Build", + description: "Build or fix swarms and agents", + }, + { + value: "plan", + title: "Plan", + description: "Plan work before building", + }, + { + value: "run", + title: "Run", + description: "Run the connected swarm", + }, + ] + + return ( + { + void local.product.set(option.value).then( + () => dialog.clear(), + () => dialog.clear(), + ) + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index a8eba0d011..96f476de07 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -30,6 +30,7 @@ export function DialogModel(props: { providerID?: string }) { currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, + productMode: local.product.current(), }) // In Agent Swarm framework mode, restrict `/models` to the agency-supported set // so users cannot pick a provider the send guard (`shouldBlockAgencyPromptSubmit`) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 043ba5470b..1daa848740 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -197,6 +197,7 @@ export function createDialogProviderOptionsWithFilter(props: DialogProviderProps currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, + productMode: local.product.current(), }), ) @@ -453,6 +454,7 @@ export function DialogAuth() { currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, + productMode: local.product.current(), }), ) const providerIDs = frameworkMode() @@ -985,6 +987,7 @@ function AutoMethod(props: AutoMethodProps) { currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, + productMode: local.product.current(), }), ) @@ -1128,6 +1131,7 @@ function CodeMethod(props: CodeMethodProps) { currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, + productMode: local.product.current(), }), ) @@ -1227,6 +1231,7 @@ function ApiMethod(props: ApiMethodProps) { currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, + productMode: local.product.current(), }), ) const description = () => { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 38dbc866f6..212fb153e7 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -106,6 +106,7 @@ export function Autocomplete(props: { currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, + productMode: local.product.current(), }), ) @@ -663,7 +664,7 @@ export function Autocomplete(props: { function select() { if (store.visible === "/" && store.selected === 0) { const typed = "/" + props.input().getTextRange(store.index + 1, props.input().cursorOffset) - const exact = command.slashes().find((item) => [item.display, ...(item.aliases ?? [])].includes(typed)) + const exact = command.slashes().find((item) => [item.display.trimEnd(), ...(item.aliases ?? [])].includes(typed)) if (exact) { hide() exact.onSelect() diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 87ae2377bf..4e54cb6b81 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -248,11 +248,37 @@ export function Prompt(props: PromptProps) { const canSwitchOrgs = createMemo(() => sync.data.console_state.switchableOrgCount > 1) const frameworkMode = createMemo(() => isAgencySwarmFrameworkMode({ + productMode: local.product.current(), currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, }), ) + function firstNativeModel() { + const hasModel = (model: { providerID: string; modelID: string }) => + Boolean(sync.data.provider.find((provider) => provider.id === model.providerID)?.models[model.modelID]) + + const recent = local.model + .recent() + .find((item) => item.providerID !== AgencySwarmAdapter.PROVIDER_ID && hasModel(item)) + if (recent) return recent + + for (const provider of sync.data.provider) { + if (provider.id === AgencySwarmAdapter.PROVIDER_ID) continue + const defaultModel = sync.data.provider_default[provider.id] + const firstModel = Object.values(provider.models)[0] as { id?: string } | undefined + const modelID = defaultModel ?? firstModel?.id + if (modelID) return { providerID: provider.id, modelID } + } + } + function promptModel() { + const model = local.model.current() + const mode = local.product.current() + if ((mode === "build" || mode === "plan") && model?.providerID === AgencySwarmAdapter.PROVIDER_ID) { + return firstNativeModel() ?? model + } + return model + } const effectiveAgentName = createMemo(() => (frameworkMode() ? "build" : (local.agent.current()?.name ?? "build"))) const agencyProviderOptions = createMemo(() => readAgencyProviderOptions({ @@ -1457,7 +1483,7 @@ export function Prompt(props: PromptProps) { } const currentMode = store.mode - const selectedModel = local.model.current() + const selectedModel = promptModel() if (!selectedModel) { void promptModelWarning() return false @@ -1559,7 +1585,7 @@ export function Prompt(props: PromptProps) { return false } - if (currentMode !== "shell" && agencyConnection.requiresReconnect()) { + if (currentMode !== "shell" && frameworkMode() && agencyConnection.requiresReconnect()) { toast.show({ variant: "warning", message: "Reconnect to a local agency-swarm server before sending a message", @@ -1711,6 +1737,7 @@ export function Prompt(props: PromptProps) { : undefined const promptPayload: Parameters[0] & { $body_agencyRecipientAgent?: string + $body_agencySwarmBridge?: boolean } = { sessionID, ...selectedModel, @@ -1719,6 +1746,7 @@ export function Prompt(props: PromptProps) { model: selectedModel, variant, $body_agencyRecipientAgent: agencyRecipientAgent, + $body_agencySwarmBridge: frameworkMode(), parts: [ ...editorParts, { diff --git a/packages/opencode/src/cli/cmd/tui/context/agency-swarm-connection.tsx b/packages/opencode/src/cli/cmd/tui/context/agency-swarm-connection.tsx index 7d7bea7c62..01e9121243 100644 --- a/packages/opencode/src/cli/cmd/tui/context/agency-swarm-connection.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/agency-swarm-connection.tsx @@ -245,6 +245,7 @@ export const { use: useAgencySwarmConnection, provider: AgencySwarmConnectionPro currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, + productMode: local.product.current(), }), ) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 888164fe82..ef7a39616b 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -8,7 +8,7 @@ import { useEvent } from "@tui/context/event" import { uniqueBy } from "remeda" import path from "path" import { AgencySwarmAdapter } from "@/agency-swarm/adapter" -import { isAgencySwarmModel } from "@/agency-swarm/run-mode" +import { isAgencySwarmModel, isAgencySwarmRunMode } from "@/agency-swarm/run-mode" import { Global } from "@opencode-ai/core/global" import { Flag } from "@opencode-ai/core/flag/flag" import { iife } from "@/util/iife" @@ -36,6 +36,8 @@ type StoredModelSelection = ModelSelection & { explicit?: boolean } +export type ProductMode = "build" | "plan" | "run" + export function isUsableModel(input: { model: ModelSelection providers: { @@ -47,6 +49,7 @@ export function isUsableModel(input: { configuredProviders?: Record enabledProviders?: string[] disabledProviders?: string[] + productMode?: ProductMode }) { const provider = input.providers.find((x) => x.id === input.model.providerID) if (provider?.models[input.model.modelID]) return true @@ -75,6 +78,7 @@ export function selectCurrentModel(input: { configuredProviders?: Record enabledProviders?: string[] disabledProviders?: string[] + productMode?: ProductMode }) { function isModelValid(model: ModelSelection) { return isUsableModel({ @@ -88,10 +92,16 @@ export function selectCurrentModel(input: { }) } + function isAllowedProductModel(model: ModelSelection) { + if (input.productMode !== "build" && input.productMode !== "plan") return true + return model.providerID !== AgencySwarmAdapter.PROVIDER_ID + } + function getFirstValidModel(...modelFns: (() => ModelSelection | undefined)[]) { for (const modelFn of modelFns) { const model = modelFn() if (!model) continue + if (!isAllowedProductModel(model)) continue if (isModelValid(model)) return { providerID: model.providerID, modelID: model.modelID } } } @@ -99,7 +109,7 @@ export function selectCurrentModel(input: { const fallbackModel = () => { if (input.argModel) { const { providerID, modelID } = Provider.parseModel(input.argModel) - if (isModelValid({ providerID, modelID })) { + if (isAllowedProductModel({ providerID, modelID }) && isModelValid({ providerID, modelID })) { return { providerID, modelID, @@ -109,7 +119,7 @@ export function selectCurrentModel(input: { if (input.configModel) { const { providerID, modelID } = Provider.parseModel(input.configModel) - if (isModelValid({ providerID, modelID })) { + if (isAllowedProductModel({ providerID, modelID }) && isModelValid({ providerID, modelID })) { return { providerID, modelID, @@ -118,12 +128,15 @@ export function selectCurrentModel(input: { } for (const item of input.recentModels ?? []) { - if (isModelValid(item)) { + if (isAllowedProductModel(item) && isModelValid(item)) { return item } } - const provider = input.providers[0] + const provider = input.providers.find((item) => { + if (input.productMode !== "build" && input.productMode !== "plan") return true + return item.id !== AgencySwarmAdapter.PROVIDER_ID + }) if (!provider) return undefined const defaultModel = input.providerDefaults?.[provider.id] const firstModel = Object.values(provider.models)[0] as { id?: string } | undefined @@ -172,6 +185,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const sdk = useSDK() const toast = useToast() const args = useArgs() + const [productStore, setProductStore] = createStore<{ + mode: ProductMode | undefined + }>({ + mode: undefined, + }) function isModelValid(model: ModelSelection) { return isUsableModel({ @@ -309,6 +327,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ configuredProviders: sync.data.config.provider, enabledProviders: sync.data.config.enabled_providers, disabledProviders: sync.data.config.disabled_providers, + productMode: productStore.mode, }) }) @@ -713,11 +732,60 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } }) + const product = { + current(): ProductMode { + if (productStore.mode) return productStore.mode + if ( + isAgencySwarmRunMode({ + currentProviderID: model.current()?.providerID, + configuredModel: sync.data.config.model, + agentModel: agent.current()?.model, + }) + ) { + return "run" + } + return agent.current()?.name === "plan" ? "plan" : "build" + }, + async set(mode: ProductMode) { + setProductStore("mode", mode) + if (mode === "build" || mode === "plan") { + agent.set(mode) + const value = model.current() + if (!value || value.providerID === AgencySwarmAdapter.PROVIDER_ID) return + model.set(value, { explicit: true }) + await updateConfigModel(value) + return + } + const runModel = { + providerID: AgencySwarmAdapter.PROVIDER_ID, + modelID: AgencySwarmAdapter.DEFAULT_MODEL_ID, + } + await updateConfigModel(runModel) + model.set(runModel, { explicit: true }) + }, + } + + async function updateConfigModel(value: ModelSelection) { + await sdk.client.global.config.update( + { + config: { + model: `${value.providerID}/${value.modelID}`, + }, + }, + { + throwOnError: true, + }, + ) + await sdk.client.instance.dispose() + await sync.bootstrap() + } + const result = { model, agent, mcp, session, + product, } return result }, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx index f1f7a87585..194dd6ec3c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx @@ -23,6 +23,7 @@ export function DialogMessage(props: { currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, + productMode: local.product.current(), }) const revertOption: DialogSelectOption = { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 6cc98cef44..2b227cde7f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -242,6 +242,7 @@ export function Session() { currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, + productMode: local.product.current(), }), ) @@ -299,10 +300,11 @@ export function Session() { if (part.id === lastSwitch) return if (part.tool === "plan_exit") { - local.agent.set("build") + void local.product.set("build") lastSwitch = part.id } else if (part.tool === "plan_enter") { - local.agent.set(frameworkMode() ? "build" : "plan") + if (frameworkMode()) local.agent.set("build") + else void local.product.set("plan") lastSwitch = part.id } }) diff --git a/packages/opencode/src/cli/cmd/tui/session-error.ts b/packages/opencode/src/cli/cmd/tui/session-error.ts index fba0aad637..a54deec584 100644 --- a/packages/opencode/src/cli/cmd/tui/session-error.ts +++ b/packages/opencode/src/cli/cmd/tui/session-error.ts @@ -7,6 +7,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { Log } from "@opencode-ai/core/util/log" import { hasStoredProviderCredential } from "@tui/util/provider-auth" import type { Provider, ProviderAuthMethod } from "@opencode-ai/sdk/v2" +import type { ProductMode } from "./context/local" export const AGENCY_SWARM_PRIMARY_AUTH_PROVIDER_IDS = [ "openai", @@ -167,7 +168,9 @@ function hasExplicitAgencyClientConfig(provider: Provider | undefined) { * but the configured or agent model still points at `agency-swarm/...`, framework mode stays on so auth and * provider UI stay aligned with Agent Swarm. */ -export function isAgencySwarmFrameworkMode(input: AgencySwarmRunModeInput) { +export function isAgencySwarmFrameworkMode(input: AgencySwarmRunModeInput & { productMode?: ProductMode }) { + if (input.productMode === "run") return true + if (input.productMode === "build" || input.productMode === "plan") return false return isAgencySwarmRunMode(input) } diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index b40ca1c32c..333a4704f1 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -192,6 +192,7 @@ export const TuiThreadCommand = cmd({ const env = sanitizedProcessEnv({ [OPENCODE_PROCESS_ROLE]: "worker", [OPENCODE_RUN_ID]: ensureRunID(), + OPENCODE_EXPERIMENTAL_PLAN_MODE: "1", }) const worker = new Worker(file, { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7dcc4413d4..d72a1ca9b6 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -64,7 +64,9 @@ import * as Database from "@/storage/db" import { SessionTable } from "./session.sql" import { SessionAgencySwarm } from "./agency-swarm" import { AgencySwarmAdapter } from "@/agency-swarm/adapter" -import { isAgencySwarmRunMode } from "@/agency-swarm/run-mode" +import { isAgencySwarmModel, isAgencySwarmRunMode } from "@/agency-swarm/run-mode" +import { agentBuilderInstructions } from "./agent-builder" +import { agentPlannerInstructions } from "./agent-planner" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -94,7 +96,18 @@ export function shouldUseAgencySwarmBridge(input: { configuredModel?: string agentProviderID?: string lastAssistantProviderID?: string + agentName?: string + agencySwarmBridge?: boolean }) { + if (input.agencySwarmBridge === false) return false + const nativePrimary = + input.resolvedProviderID !== SessionAgencySwarm.PROVIDER_ID && + (input.agentName === "build" || input.agentName === "plan") + const configuredRunMode = + isAgencySwarmModel(input.configuredModel) || input.agentProviderID === SessionAgencySwarm.PROVIDER_ID + if (nativePrimary && !configuredRunMode) { + return false + } return isAgencySwarmRunMode({ currentProviderID: input.resolvedProviderID === SessionAgencySwarm.PROVIDER_ID || @@ -1664,6 +1677,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return yield* loop({ sessionID: input.sessionID, agencyRecipientAgent: input.agencyRecipientAgent, + agencySwarmBridge: input.agencySwarmBridge, promptMessageID: message.info.id, }) }) @@ -1768,6 +1782,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the configuredModel: cfg.model, agentProviderID: agent.model?.providerID, lastAssistantProviderID: lastAssistant?.providerID, + agentName: agent.name, + agencySwarmBridge: input.agencySwarmBridge, }) const processorModel = useAgencySwarmBridge ? yield* getModel( @@ -1918,7 +1934,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the instruction.system().pipe(Effect.orDie), MessageV2.toModelMessagesEffect(msgs, model), ]) - const system = [...env, ...instructions, ...(skills ? [skills] : [])] + const modeInstructions = [ + ...agentBuilderInstructions(agent.name, model.providerID), + ...agentPlannerInstructions(agent.name, model.providerID), + ] + const system = [...env, ...instructions, ...modeInstructions, ...(skills ? [skills] : [])] const format = lastUser.format ?? { type: "text" as const } if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) const result = yield* handle.process({ @@ -1977,11 +1997,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, ) - const loop: (input: RunLoopInput) => Effect.Effect = Effect.fn("SessionPrompt.loop")(function* ( - input: RunLoopInput, - ) { - return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input)) - }) + const loop: (input: RunLoopInput) => Effect.Effect = Effect.fn("SessionPrompt.loop")( + function* (input: RunLoopInput) { + return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input)) + }, + ) const shell: (input: ShellInput) => Effect.Effect = Effect.fn( "SessionPrompt.shell", @@ -2172,6 +2192,7 @@ export const PromptInput = Schema.Struct({ system: Schema.optional(Schema.String), variant: Schema.optional(Schema.String), agencyRecipientAgent: Schema.optional(Schema.String), + agencySwarmBridge: Schema.optional(Schema.Boolean), parts: Schema.Array( Schema.Union([ MessageV2.TextPartInput, @@ -2186,6 +2207,7 @@ export type PromptInput = Schema.Schema.Type export class LoopInput extends Schema.Class("SessionPrompt.LoopInput")({ sessionID: SessionID, agencyRecipientAgent: Schema.optional(Schema.String), + agencySwarmBridge: Schema.optional(Schema.Boolean), }) {} export const ShellInput = Schema.Struct({ diff --git a/packages/opencode/src/tool/plan-exit.txt b/packages/opencode/src/tool/plan-exit.txt index f24c1fb920..988821de3b 100644 --- a/packages/opencode/src/tool/plan-exit.txt +++ b/packages/opencode/src/tool/plan-exit.txt @@ -1,6 +1,6 @@ Use this tool when you have completed the planning phase and are ready to exit plan agent. -This tool will ask the user if they want to switch to Agent Builder to start implementing the plan. +This tool will ask the user if they want to switch to build agent to start implementing the plan. Call this tool: - After you have written a complete plan to the plan file diff --git a/packages/opencode/test/agent/display.test.ts b/packages/opencode/test/agent/display.test.ts index ccc593bca9..ac7f6aa615 100644 --- a/packages/opencode/test/agent/display.test.ts +++ b/packages/opencode/test/agent/display.test.ts @@ -1,11 +1,8 @@ import { expect, test } from "bun:test" import { displayAgentName } from "../../src/agent/display" -test("displayAgentName brands build as Agent Builder", () => { - expect(displayAgentName("build")).toBe("Agent Builder") -}) - test("displayAgentName titlecases other agent names", () => { + expect(displayAgentName("build")).toBe("Build") expect(displayAgentName("plan")).toBe("Plan") expect(displayAgentName("general")).toBe("General") }) diff --git a/packages/opencode/test/cli/tui/prompt-framework-mode.test.tsx b/packages/opencode/test/cli/tui/prompt-framework-mode.test.tsx index 3d53678039..ab7fe848d5 100644 --- a/packages/opencode/test/cli/tui/prompt-framework-mode.test.tsx +++ b/packages/opencode/test/cli/tui/prompt-framework-mode.test.tsx @@ -168,6 +168,9 @@ describe("prompt framework-mode footer", () => { set: () => {}, }, }, + product: { + current: () => "run", + }, } as any) spyOn(SDKContext, "useSDK").mockReturnValue({ client: { From 18a25d3ae4291e1f2dafa6abe9b20356d8ce5eb8 Mon Sep 17 00:00:00 2001 From: Nick Bobrowski <39348559+nicko-ai@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:14:26 +0100 Subject: [PATCH 2/2] feat(opencode): restore build and plan modes - Add /mode as the Build, Plan, and Run product switch - Keep Run server-backed while Build and Plan use native OpenCode mode routing - Add terminal E2E coverage for switching, native prompts, and Run return flow --- AGENTS.md | 1 - FORK_CHANGELOG.md | 4 ++-- README.md | 2 +- USER_FLOWS.md | 3 ++- e2e/agent-swarm-tui/harness.ts | 16 ++++------------ e2e/agent-swarm-tui/terminal-tui.test.ts | 2 +- packages/opencode/src/cli/cmd/agency.ts | 6 +++--- packages/opencode/src/cli/cmd/tui/app.tsx | 2 +- .../src/cli/cmd/tui/component/dialog-agent.tsx | 2 +- .../src/cli/cmd/tui/component/dialog-model.tsx | 2 +- .../cli/cmd/tui/component/dialog-provider.tsx | 10 +++++----- .../cmd/tui/component/prompt/autocomplete.tsx | 2 +- .../src/cli/cmd/tui/component/prompt/index.tsx | 4 ++-- .../cmd/tui/context/agency-swarm-connection.tsx | 2 +- .../cmd/tui/routes/session/dialog-message.tsx | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 2 +- packages/opencode/src/tool/plan-exit.txt | 2 +- packages/opencode/src/tool/plan.ts | 9 ++++++--- .../opencode/test/cli/tui/transcript.test.ts | 12 ++++++------ 19 files changed, 40 insertions(+), 45 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d99da6a476..33754f0a08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,6 @@ Policy summaries live in AGENTS.md files. Do not check in duplicate copies of gl - Drive any smoke automation yourself when it should finish in under 3 minutes. - When relevant full testing should finish in under 5 minutes, run it yourself before handoff. - When relevant full testing is likely to take more than 5 minutes, give the user a ready-to-run script and exact manual QA steps instead of calling the work done. -- For integration-heavy Agent Swarm, OpenCode TUI, or CLI behavior, prefer the smallest meaningful integration or end-to-end tests that drive real user flows and prove boundaries. Unit tests are fine for isolated pure or local logic, but mocked unit tests are not enough proof for integration behavior. - Test every user-facing function touched by the change, and every nearby function that the change could affect. - If a check needs secrets, live services, global installs, or long human QA, say that clearly and give the safest exact command or script for the user to run. diff --git a/FORK_CHANGELOG.md b/FORK_CHANGELOG.md index 8a4367cd82..508169238b 100644 --- a/FORK_CHANGELOG.md +++ b/FORK_CHANGELOG.md @@ -213,7 +213,7 @@ Use this index with `USER_FLOWS.md` when a QA row needs the owning fork implemen - **Agency backend management commands are debugging and development tools** - Intent: keep backend lifecycle commands available for debugging and development without treating them as the main end-user path. - - Behavior: the fork exposes backend install and maintenance commands, but they are debugging and development tools rather than core product surface. `agentswarm agency agent new` remains hidden from help instead of being expanded as an Agent Builder path. + - Behavior: the fork exposes backend install and maintenance commands, but they are debugging and development tools rather than core product surface. - Implementation: `AgencyCommand` in `packages/opencode/src/cli/cmd/agency.ts`. - Added by: `14abd070` @@ -331,7 +331,7 @@ Use this index with `USER_FLOWS.md` when a QA row needs the owning fork implemen ## Web/App Surface -- **README mode overview explains Builder, Plan, and Run** +- **README mode overview explains Build, Plan, and Run** - Intent: document the fork's mode model clearly at the top level. - Behavior: the README explains `/mode`, Build, Plan, and Run, with Run as the connected Agency Swarm path and Build or Plan as native OpenCode modes under the Agent Swarm umbrella. - Implementation: the `Main TUI Flows` section in `README.md`. diff --git a/README.md b/README.md index b70b18cc9e..406c042474 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ On startup, the CLI can detect the project, prepare the project Python environme ## Main TUI Flows - `/mode` switches between Build, Plan, and Run. -- Build is Agent Builder: native OpenCode build behavior with Agent Swarm guidance. +- Build uses native OpenCode build behavior with Agent Swarm guidance. - Plan is native OpenCode Plan mode for preparing work before Build. - Run connects to or starts an Agency Swarm FastAPI server and sends prompts to the active swarm. - `/auth` manages OpenAI and Anthropic credentials used by Agency Swarm runs. diff --git a/USER_FLOWS.md b/USER_FLOWS.md index 7d77daa947..0d5a24dada 100644 --- a/USER_FLOWS.md +++ b/USER_FLOWS.md @@ -249,8 +249,9 @@ For each failure scenario, capture the visible user result and cite the matching - **Happy-path proof:** `connect` stores normalized backend config and optional bearer token. - **Happy-path proof:** `agencies` discovers available agencies. - **Happy-path proof:** `use` pins a default agency id. -- **Happy-path proof:** `agent` scaffold helpers are not promoted in CLI help. +- **Happy-path proof:** `agent` provides Agent Builder scaffold helpers. - **Failure scenarios to test:** URL normalization and discovery failures surface in the CLI command. +- **Failure scenarios to test:** `agentswarm agency agent new` fails visibly when `agency-swarm create-agent-template` fails. ### Trust-Safe Telemetry diff --git a/e2e/agent-swarm-tui/harness.ts b/e2e/agent-swarm-tui/harness.ts index 12ef67a36b..7508c9d8c4 100644 --- a/e2e/agent-swarm-tui/harness.ts +++ b/e2e/agent-swarm-tui/harness.ts @@ -455,7 +455,6 @@ function mergeSingleProviderConfig(base: ProviderConfig | undefined, override: P export async function startTui(input: { args?: string[] - binaryPath?: string cwd?: string env?: Record baseURL?: string @@ -502,7 +501,7 @@ export async function startTui(input: { ...(configContent && input.configSource !== "file" ? { OPENCODE_CONFIG_CONTENT: configContent } : {}), ...(input.env ?? {}), }) - let proc = spawnTuiProcess({ args, binaryPath: input.binaryPath, cwd: input.cwd, env }) + let proc = spawnTuiProcess({ args, cwd: input.cwd, env }) let dataReceived = false let activeProc = proc @@ -532,7 +531,7 @@ export async function startTui(input: { onRetry: async () => { await closeProcess(proc) await Bun.sleep(initialOutputRetryDelayMs) - proc = spawnTuiProcess({ args, binaryPath: input.binaryPath, cwd: input.cwd, env }) + proc = spawnTuiProcess({ args, cwd: input.cwd, env }) dataReceived = false exitCode = undefined attachProcess(proc) @@ -589,15 +588,8 @@ export async function startTui(input: { } } -function spawnTuiProcess(input: { - args: string[] - binaryPath?: string - cwd?: string - env: Record -}) { - const command = input.binaryPath ?? process.execPath - const args = input.binaryPath ? input.args : ["--conditions=browser", "./src/index.ts", ...input.args] - return spawn(command, args, { +function spawnTuiProcess(input: { args: string[]; cwd?: string; env: Record }) { + return spawn(process.execPath, ["--conditions=browser", "./src/index.ts", ...input.args], { cwd: input.cwd ?? packageRoot, cols: 100, rows: 30, diff --git a/e2e/agent-swarm-tui/terminal-tui.test.ts b/e2e/agent-swarm-tui/terminal-tui.test.ts index 54cb11c7b7..068c9ae2fd 100644 --- a/e2e/agent-swarm-tui/terminal-tui.test.ts +++ b/e2e/agent-swarm-tui/terminal-tui.test.ts @@ -511,7 +511,7 @@ describe("Agent Swarm terminal TUI e2e", () => { currentNativeServer.planExitNext() currentTui.write(`${planPrompt}\r`) await currentTui.waitFor( - () => currentTui!.screen().includes("Would you like to") && currentTui!.screen().includes("switch to the build"), + () => currentTui!.screen().includes("Would you like to") && currentTui!.screen().includes("switch to Build"), "Plan approval question", tuiInteractionTimeoutMs, ) diff --git a/packages/opencode/src/cli/cmd/agency.ts b/packages/opencode/src/cli/cmd/agency.ts index 874e6769b5..6cd4f5dd5d 100644 --- a/packages/opencode/src/cli/cmd/agency.ts +++ b/packages/opencode/src/cli/cmd/agency.ts @@ -10,7 +10,7 @@ import path from "path" export const AgencyCommand = cmd({ command: "agency", - describe: "Agency Swarm backend management: connect, discover swarms, set default swarm", + describe: "Agency Swarm backend management: connect, discover agencies, set default agency, Agent Builder helpers", builder: (yargs: Argv) => yargs .command(AgencyConnectCommand) @@ -165,14 +165,14 @@ const AgencyUseCommand = cmd({ const AgencyAgentCommand = cmd({ command: "agent", - describe: false, + describe: "Agency Swarm Agent Builder helpers", builder: (yargs: Argv) => yargs.command(AgencyAgentNewCommand).demandCommand(), async handler() {}, }) const AgencyAgentNewCommand = cmd({ command: "new ", - describe: false, + describe: "create a new Agency Swarm agent scaffold via `agency-swarm create-agent-template`", builder: (yargs: Argv) => yargs .positional("name", { diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 5bee28ffd3..b8ca6f8329 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -459,7 +459,7 @@ function App(props: { onSnapshot?: () => Promise }) { const connected = useConnected() const frameworkMode = createMemo(() => isAgencySwarmFrameworkMode({ - productMode: local.product.current(), + productMode: local.product?.current(), currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx index 1d9a393e6c..2d85ad3aae 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx @@ -47,7 +47,7 @@ export function DialogAgent() { currentProviderID: currentModel()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, - productMode: local.product.current(), + productMode: local.product?.current(), }), ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 96f476de07..b47053ac1a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -30,7 +30,7 @@ export function DialogModel(props: { providerID?: string }) { currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, - productMode: local.product.current(), + productMode: local.product?.current(), }) // In Agent Swarm framework mode, restrict `/models` to the agency-supported set // so users cannot pick a provider the send guard (`shouldBlockAgencyPromptSubmit`) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 1daa848740..738ae41895 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -197,7 +197,7 @@ export function createDialogProviderOptionsWithFilter(props: DialogProviderProps currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, - productMode: local.product.current(), + productMode: local.product?.current(), }), ) @@ -454,7 +454,7 @@ export function DialogAuth() { currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, - productMode: local.product.current(), + productMode: local.product?.current(), }), ) const providerIDs = frameworkMode() @@ -987,7 +987,7 @@ function AutoMethod(props: AutoMethodProps) { currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, - productMode: local.product.current(), + productMode: local.product?.current(), }), ) @@ -1131,7 +1131,7 @@ function CodeMethod(props: CodeMethodProps) { currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, - productMode: local.product.current(), + productMode: local.product?.current(), }), ) @@ -1231,7 +1231,7 @@ function ApiMethod(props: ApiMethodProps) { currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, - productMode: local.product.current(), + productMode: local.product?.current(), }), ) const description = () => { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 212fb153e7..942e5fcab5 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -106,7 +106,7 @@ export function Autocomplete(props: { currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, - productMode: local.product.current(), + productMode: local.product?.current(), }), ) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 4e54cb6b81..40ad357aa3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -248,7 +248,7 @@ export function Prompt(props: PromptProps) { const canSwitchOrgs = createMemo(() => sync.data.console_state.switchableOrgCount > 1) const frameworkMode = createMemo(() => isAgencySwarmFrameworkMode({ - productMode: local.product.current(), + productMode: local.product?.current(), currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, @@ -273,7 +273,7 @@ export function Prompt(props: PromptProps) { } function promptModel() { const model = local.model.current() - const mode = local.product.current() + const mode = local.product?.current() if ((mode === "build" || mode === "plan") && model?.providerID === AgencySwarmAdapter.PROVIDER_ID) { return firstNativeModel() ?? model } diff --git a/packages/opencode/src/cli/cmd/tui/context/agency-swarm-connection.tsx b/packages/opencode/src/cli/cmd/tui/context/agency-swarm-connection.tsx index 01e9121243..8936c4a7ac 100644 --- a/packages/opencode/src/cli/cmd/tui/context/agency-swarm-connection.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/agency-swarm-connection.tsx @@ -245,7 +245,7 @@ export const { use: useAgencySwarmConnection, provider: AgencySwarmConnectionPro currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, - productMode: local.product.current(), + productMode: local.product?.current(), }), ) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx index 194dd6ec3c..0acf875995 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx @@ -23,7 +23,7 @@ export function DialogMessage(props: { currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, - productMode: local.product.current(), + productMode: local.product?.current(), }) const revertOption: DialogSelectOption = { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 2b227cde7f..56810edb70 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -242,7 +242,7 @@ export function Session() { currentProviderID: local.model.current()?.providerID, configuredModel: sync.data.config.model, agentModel: local.agent.current()?.model, - productMode: local.product.current(), + productMode: local.product?.current(), }), ) diff --git a/packages/opencode/src/tool/plan-exit.txt b/packages/opencode/src/tool/plan-exit.txt index 988821de3b..9cf85241f4 100644 --- a/packages/opencode/src/tool/plan-exit.txt +++ b/packages/opencode/src/tool/plan-exit.txt @@ -1,6 +1,6 @@ Use this tool when you have completed the planning phase and are ready to exit plan agent. -This tool will ask the user if they want to switch to build agent to start implementing the plan. +This tool will ask the user if they want to switch to Build mode to start implementing the plan. Call this tool: - After you have written a complete plan to the plan file diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index aca964e7ba..053ce32f15 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -31,12 +31,15 @@ export const PlanExitTool = Tool.define( sessionID: ctx.sessionID, questions: [ { - question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`, + question: `Plan at ${plan} is complete. Would you like to switch to Build and start implementing?`, header: displayAgentName("build"), custom: false, options: [ - { label: "Yes", description: `Switch to ${displayAgentName("build")} and start implementing the plan` }, - { label: "No", description: "Stay with plan agent to continue refining the plan" }, + { + label: "Yes", + description: `Switch to ${displayAgentName("build")} and start implementing the plan`, + }, + { label: "No", description: "Stay with Plan to continue refining the plan" }, ], }, ], diff --git a/packages/opencode/test/cli/tui/transcript.test.ts b/packages/opencode/test/cli/tui/transcript.test.ts index df591a9055..7fe2101cde 100644 --- a/packages/opencode/test/cli/tui/transcript.test.ts +++ b/packages/opencode/test/cli/tui/transcript.test.ts @@ -85,12 +85,12 @@ describe("transcript", () => { test("includes metadata when enabled", () => { const result = formatAssistantHeader(baseMsg, true) - expect(result).toBe("## Assistant (Agent Builder · claude-sonnet-4-20250514 · 5.4s)\n\n") + expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514 · 5.4s)\n\n") }) test("uses model display name when available", () => { const result = formatAssistantHeader(baseMsg, true, providers) - expect(result).toBe("## Assistant (Agent Builder · Claude Sonnet 4 · 5.4s)\n\n") + expect(result).toBe("## Assistant (Build · Claude Sonnet 4 · 5.4s)\n\n") }) test("excludes metadata when disabled", () => { @@ -101,7 +101,7 @@ describe("transcript", () => { test("handles missing completed time", () => { const msg = { ...baseMsg, time: { created: 1000000 } } const result = formatAssistantHeader(msg as AssistantMessage, true) - expect(result).toBe("## Assistant (Agent Builder · claude-sonnet-4-20250514)\n\n") + expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514)\n\n") }) test("titlecases agent name", () => { @@ -294,7 +294,7 @@ describe("transcript", () => { } const parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hi there" }] const result = formatMessage(msg, parts, options) - expect(result).toContain("## Assistant (Agent Builder · Claude Sonnet 4 · 5.4s)") + expect(result).toContain("## Assistant (Build · Claude Sonnet 4 · 5.4s)") expect(result).toContain("Hi there") }) }) @@ -349,7 +349,7 @@ describe("transcript", () => { expect(result).toContain("**Session ID:** ses_abc123") expect(result).toContain("## User") expect(result).toContain("Hello") - expect(result).toContain("## Assistant (Agent Builder · Claude Sonnet 4 · 0.5s)") + expect(result).toContain("## Assistant (Build · Claude Sonnet 4 · 0.5s)") expect(result).toContain("Hi!") expect(result).toContain("---") }) @@ -386,7 +386,7 @@ describe("transcript", () => { assistantMetadata: true, }) - expect(result).toContain("## Assistant (Agent Builder · claude-sonnet-4-20250514 · 0.5s)") + expect(result).toContain("## Assistant (Build · claude-sonnet-4-20250514 · 0.5s)") }) test("formats transcript without assistant metadata", () => {