Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions agent-chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion agent-chat/adapters/acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ function effectiveSpawnModel(def: ProviderDef, options: Record<string, OptionVal
: def.defaultModel ?? def.models?.[0]?.value ?? "";
}

function commandForSession(def: ProviderDef, options: Record<string, OptionValue>): string[] {
export function commandForSession(def: ProviderDef, options: Record<string, OptionValue>): string[] {
const cmd = [...(def.cmd ?? [])];
if (def.models?.length) {
cmd.push("--model", effectiveSpawnModel(def, options));
Expand Down
4 changes: 3 additions & 1 deletion agent-chat/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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;
}
Expand Down
3 changes: 3 additions & 0 deletions agent-chat/test/provider-registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
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",
adapter: "acp",
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"]);
});
27 changes: 23 additions & 4 deletions agent-chat/test/providers.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,39 @@
import { expect, test } from "bun:test";
import { PROVIDERS } from "../server";
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", () => {
expect(commandForSession({ id: "fake", label: "Fake", adapter: "acp", cmd: ["fake-agent"], models: [{ value: "model-a", label: "Model A" }] }, {})).toEqual([
"fake-agent",
"--model",
"model-a",
]);
});
Loading