From 4a307204df0328c49b0e20ac7d74ae13d03c8487 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 30 Sep 2026 02:11:57 -0700 Subject: [PATCH 1/2] test(agent-chat): pin the ACP provider argv instead of the registry literal --- agent-chat/test/providers.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/agent-chat/test/providers.test.ts b/agent-chat/test/providers.test.ts index 0a16278b66f2..fcc4d1ba19ca 100644 --- a/agent-chat/test/providers.test.ts +++ b/agent-chat/test/providers.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { PROVIDERS } from "../server"; +import * as acp from "../adapters/acp"; test("Gemini ACP command uses the documented experimental flag", () => { const gemini = PROVIDERS.find((provider) => provider.id === "gemini"); @@ -18,3 +19,13 @@ test("registers Cursor Agent as an ACP provider", () => { installCommand: "curl https://cursor.com/install -fsS | bash", }); }); + +test("appends the selected model to ACP provider commands", () => { + const commandForSession = (acp as any).commandForSession as ((def: any, options: Record) => string[]); + expect(commandForSession).toBeDefined(); + expect(commandForSession({ cmd: ["fake-agent"], models: [{ value: "model-a", label: "Model A" }] }, {})).toEqual([ + "fake-agent", + "--model", + "model-a", + ]); +}); From 38648dcd00987054a195b53635fa707516c06d66 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 30 Sep 2026 02:17:07 -0700 Subject: [PATCH 2/2] docs(agent-chat): correct the Amp claim and note Cursor's ACP gaps --- agent-chat/README.md | 7 +++++-- agent-chat/adapters/acp.ts | 2 +- agent-chat/server.ts | 4 +++- agent-chat/test/provider-registry.test.ts | 3 +++ agent-chat/test/providers.test.ts | 24 +++++++++++++++-------- 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/agent-chat/README.md b/agent-chat/README.md index 3f698e4b2882..a10e5ef0aa79 100644 --- a/agent-chat/README.md +++ b/agent-chat/README.md @@ -81,9 +81,10 @@ The UI only knows `AgentEvent` (types.ts): `user`, `delta`, `assistant`, `thinki Two adapter families are enough, and family 2 is a single implementation: -1. **Native stream-JSON/JSON-RPC CLIs.** Claude Code (`--output-format stream-json`), Codex (`app-server`, the JSON-RPC server its IDE extension uses), and pi (`--mode rpc`) have the same shape. Each needs a ~100-line adapter because event names differ, but they all reduce to the same event set: text deltas, tool start/end, turn done. Use a native adapter when the native protocol carries things ACP doesn't yet (Claude permission modes/hooks, Codex thread/turn model and approvals). +1. **Native stream-JSON/JSON-RPC CLIs.** Claude Code (`--output-format stream-json`), Codex (`app-server`, the JSON-RPC server its IDE extension uses), pi (`--mode rpc`), and Amp ([`-x`/`--execute`, `--stream-json`, and `--stream-json-input`](https://ampcode.com/docs/cli/streaming-json)) have the same shape. Each needs a ~100-line adapter because event names differ, but they all reduce to the same event set: text deltas, tool start/end, turn done. Use a native adapter when the native protocol carries things ACP doesn't yet (Claude permission modes/hooks, Codex thread/turn model and approvals). 2. **ACP (Agent Client Protocol, agentclientprotocol.com).** One generic client (`adapters/acp.ts`) speaks initialize → session/new → session/prompt, renders `session/update` notifications, and answers reverse requests (`session/request_permission`). That single file already runs opencode (`opencode acp`), gemini (`gemini --experimental-acp`), goose (`goose acp`), and Cursor Agent (`cursor-agent acp`), and gets claude (`@zed-industries/claude-code-acp`), marimo, and future agents for free. ACP is the long-term contract: it's the protocol Zed drove, adapters keep appearing, and it standardizes exactly the hard parts (permissions, fs proxying, tool call lifecycle, plans). -Amp is not listed because it has no documented native ACP command. Supporting it would require a deliberate choice of a third-party bridge or a native adapter. + +Amp has no first-party ACP subcommand, only third-party bridges such as [amp-acp](https://github.com/tao12345666333/amp-acp). Capability differences are absorbed by the schema, not the UI: @@ -97,6 +98,8 @@ Capability differences are absorbed by the schema, not the UI: | cursor-agent | ACP persistent stdio | deltas | yes | ACP session | auto-approve toggle for request_permission | | pi | persistent stdio | deltas | yes | persistent proc | none (always executes) | +Cursor Agent has two ACP capability gaps: it advertises blocking `cursor/ask_question` and `cursor/create_plan` requests, which this adapter answers with JSON-RPC `-32601` instead of prompting; it also advertises `cursor_login`, but the adapter does not call `authenticate`, so an unauthenticated `session/new` failure has no login hint. + Runtime options are declared by adapters as `SessionOption[]` and replayed as `options` events. React renders the schema generically; provider-specific logic stays in adapters. diff --git a/agent-chat/adapters/acp.ts b/agent-chat/adapters/acp.ts index 59cd139d6034..fb552abe1a1f 100644 --- a/agent-chat/adapters/acp.ts +++ b/agent-chat/adapters/acp.ts @@ -163,7 +163,7 @@ function effectiveSpawnModel(def: ProviderDef, options: Record): string[] { +export function commandForSession(def: ProviderDef, options: Record): string[] { const cmd = [...(def.cmd ?? [])]; if (def.models?.length) { cmd.push("--model", effectiveSpawnModel(def, options)); diff --git a/agent-chat/server.ts b/agent-chat/server.ts index ba7b2fe6ca3d..a923b16efbb1 100644 --- a/agent-chat/server.ts +++ b/agent-chat/server.ts @@ -144,10 +144,11 @@ function geminiDefaultModel(): string | undefined { return agentModelCatalog.provider("gemini")?.defaultModel ?? (agentModelCatalog.hasPayload ? undefined : "gemini-3.1-pro-preview"); } -export const PROVIDERS: ProviderDef[] = [ +const PROVIDERS: ProviderDef[] = [ { id: "claude", label: "Claude Code", adapter: "claude", cmd: ["claude"], installCommand: "npm i -g @anthropic-ai/claude-code" }, { id: "codex", label: "Codex", adapter: "codex", cmd: ["codex"], installCommand: "npm i -g @openai/codex" }, { id: "opencode", label: "OpenCode", adapter: "acp", cmd: ["opencode", "acp"], installCommand: "npm i -g opencode-ai" }, + // Cursor's installer makes `agent` primary and `cursor-agent` a legacy symlink; keep the repo-standard spelling here. { id: "cursor-agent", label: "Cursor Agent", adapter: "acp", cmd: ["cursor-agent", "acp"], installCommand: "curl https://cursor.com/install -fsS | bash" }, { id: "goose", label: "Goose", adapter: "acp", cmd: ["goose", "acp"], installCommand: "curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | bash" }, { id: "pi", label: "pi", adapter: "pi", cmd: ["pi"], installCommand: "npm i -g @mariozechner/pi" }, @@ -163,6 +164,7 @@ export const PROVIDERS: ProviderDef[] = [ }, ]; +/** Read-only registry access for tests; production code keeps the mutable array private. */ export function providerDefinitionsForTest(): readonly ProviderDef[] { return PROVIDERS; } diff --git a/agent-chat/test/provider-registry.test.ts b/agent-chat/test/provider-registry.test.ts index bab46500e2a2..5dfb7d867548 100644 --- a/agent-chat/test/provider-registry.test.ts +++ b/agent-chat/test/provider-registry.test.ts @@ -1,8 +1,10 @@ import { expect, test } from "bun:test"; +import { commandForSession } from "../adapters/acp"; import { providerDefinitionsForTest } from "../server"; test("registers Goose as an ACP provider", () => { const goose = providerDefinitionsForTest().find((provider) => provider.id === "goose"); + expect(goose).toBeDefined(); expect(goose).toMatchObject({ id: "goose", label: "Goose", @@ -10,4 +12,5 @@ test("registers Goose as an ACP provider", () => { cmd: ["goose", "acp"], installCommand: "curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | bash", }); + expect(commandForSession(goose!, {})).toEqual(["goose", "acp"]); }); diff --git a/agent-chat/test/providers.test.ts b/agent-chat/test/providers.test.ts index fcc4d1ba19ca..cd1d3a34986d 100644 --- a/agent-chat/test/providers.test.ts +++ b/agent-chat/test/providers.test.ts @@ -1,29 +1,37 @@ import { expect, test } from "bun:test"; -import { PROVIDERS } from "../server"; -import * as acp from "../adapters/acp"; +import { commandForSession } from "../adapters/acp"; +import { providerDefinitionsForTest } from "../server"; + +const providers = providerDefinitionsForTest(); test("Gemini ACP command uses the documented experimental flag", () => { - const gemini = PROVIDERS.find((provider) => provider.id === "gemini"); + const gemini = providers.find((provider) => provider.id === "gemini"); expect(gemini).toBeDefined(); expect(gemini?.adapter).toBe("acp"); expect(gemini?.cmd).toEqual(["gemini", "--experimental-acp"]); + + const expectedCommand = [...(gemini?.cmd ?? [])]; + if (gemini?.models?.length) { + expectedCommand.push("--model", gemini.defaultModel ?? gemini.models[0]!.value); + } + expect(commandForSession(gemini!, {})).toEqual(expectedCommand); }); test("registers Cursor Agent as an ACP provider", () => { - const cursor = PROVIDERS.find((provider) => provider.id === "cursor-agent"); - expect(cursor).toEqual({ + const cursor = providers.find((provider) => provider.id === "cursor-agent"); + expect(cursor).toBeDefined(); + expect(cursor).toMatchObject({ id: "cursor-agent", label: "Cursor Agent", adapter: "acp", cmd: ["cursor-agent", "acp"], installCommand: "curl https://cursor.com/install -fsS | bash", }); + expect(commandForSession(cursor!, {})).toEqual(["cursor-agent", "acp"]); }); test("appends the selected model to ACP provider commands", () => { - const commandForSession = (acp as any).commandForSession as ((def: any, options: Record) => string[]); - expect(commandForSession).toBeDefined(); - expect(commandForSession({ cmd: ["fake-agent"], models: [{ value: "model-a", label: "Model A" }] }, {})).toEqual([ + expect(commandForSession({ id: "fake", label: "Fake", adapter: "acp", cmd: ["fake-agent"], models: [{ value: "model-a", label: "Model A" }] }, {})).toEqual([ "fake-agent", "--model", "model-a",