diff --git a/FORK_CHANGELOG.md b/FORK_CHANGELOG.md index 12b42b0efa..508169238b 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`. @@ -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` @@ -329,10 +331,10 @@ 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 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..406c042474 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 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. - `/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..0d5a24dada 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 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..7508c9d8c4 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 { @@ -772,6 +1035,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..068c9ae2fd 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 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/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index b9e592fc96..b8ca6f8329 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..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,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..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,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..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,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..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,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..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,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..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,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..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,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..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,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..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 Agent Builder 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/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: { 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", () => {