diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index a71f2ca696..99b539f7cb 100644 --- a/packages/plugin/src/server/acp-internal/connection.ts +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -358,6 +358,8 @@ class AcpRuntime { private emit: (event: ProviderEvent) => void; private readonly messages = new Map(); private readonly toolCalls = new Map(); + // Running tool rows published by transformers; the turn's terminal closes any left open. + private readonly vendorToolCalls = new Map(); private readonly pendingCompactions = new Set(); private readonly permissions = new Map< string, @@ -1026,9 +1028,17 @@ class AcpRuntime { this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, - item: toolTimelineItem(completed), + item: terminalToolItem(toolTimelineItem(snapshot), state), }); } + for (const item of this.vendorToolCalls.values()) { + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: terminalToolItem(item, state), + }); + } + this.vendorToolCalls.clear(); for (const id of this.pendingCompactions) { this.emit({ type: "timeline.item", @@ -1075,6 +1085,10 @@ class AcpRuntime { if (this.configTransaction) this.stagedTransformerConfig = update.config; else this.emitConfigSnapshot(update.config); } else if (update.type === "timeline") { + if (update.item.type === "tool_call") { + if (update.item.status === "running") this.vendorToolCalls.set(update.item.id, update.item); + else this.vendorToolCalls.delete(update.item.id); + } this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, @@ -1382,7 +1396,9 @@ function configValues( return Object.fromEntries(options.map((option) => [option.id, option.currentValue])); } -function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem { +type ToolCallTimelineItem = Extract; + +function toolTimelineItem(snapshot: AcpToolCallSnapshot): ToolCallTimelineItem { const input = jsonRecord(snapshot.input); const isEdit = snapshot.kind === "edit" || snapshot.name?.toLowerCase().includes("edit") === true; const detail = isEdit @@ -1410,6 +1426,18 @@ function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem { return { ...base, status, error: null }; } +// A row the turn left open ends with the turn. The wire contract requires a non-null error on a +// failed tool call, so a canceled turn cancels its rows instead of failing them. +function terminalToolItem( + item: ToolCallTimelineItem, + state: "completed" | "failed" | "canceled", +): ToolCallTimelineItem { + if (state === "failed") { + return { ...item, status: "failed", error: item.error ?? { message: "Turn failed" } }; + } + return { ...item, status: state, error: null }; +} + function jsonValue(value: unknown) { return z.json().parse(value); } diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts index a62b2f2c48..13d69416bb 100644 --- a/packages/plugin/src/server/acp.test.ts +++ b/packages/plugin/src/server/acp.test.ts @@ -619,6 +619,146 @@ lines.on("line", (line) => { await connection.close(); }); + it.each([ + ["end_turn", "completed", "completed"], + ["cancelled", "canceled", "canceled"], + ] as const)( + "closes a transformer's running tool row when the turn ends with %s", + async (stopReason, turnState, rowStatus) => { + const harness = connectorHarness({ + handleMessage(instance, message) { + if (!("method" in message) || !("id" in message)) return false; + if (message.method !== "session/prompt") return false; + instance.notify("vendor/phase", { id: "phase-1", label: "Preparing" }); + instance.respond(message as AcpRequestMessage, { stopReason }); + return true; + }, + }); + const registration = runAcpProvider({ + id: "vendor-row-acp", + label: "Vendor row ACP", + connector: harness.connector, + transformers: [ + { + notification(notification) { + if (notification.method !== "vendor/phase") return null; + return { + type: "timeline", + item: { + type: "tool_call", + id: "phase-1", + callId: "phase-1", + name: "Vendor phase", + detail: { type: "plain_text", label: "Preparing" }, + status: "running", + error: null, + }, + }; + }, + }, + ], + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "first", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "first" }] }, + }, + }); + await waitForEvent( + events, + (event) => event.type === "session.turn" && event.state === turnState, + ); + + const rows = events.flatMap((event) => + event.type === "timeline.item" && event.item.id === "phase-1" ? [event.item] : [], + ); + expect(rows.map((row) => row.type === "tool_call" && row.status)).toEqual([ + "running", + rowStatus, + ]); + const closed = events.findIndex( + (event) => + event.type === "timeline.item" && + event.item.id === "phase-1" && + event.item.type === "tool_call" && + event.item.status === rowStatus, + ); + const terminal = events.findIndex( + (event) => event.type === "session.turn" && event.state === turnState, + ); + expect(closed).toBeLessThan(terminal); + // The wire contract only accepts a failed tool call with a non-null error. + expect(rows.at(-1)).toMatchObject({ error: null }); + await connection.close(); + }, + ); + + it("cancels an in-progress ACP tool call with the canceled turn instead of failing it without an error", async () => { + const harness = connectorHarness({ + handleMessage(instance, message) { + if (!("method" in message) || !("id" in message)) return false; + if (message.method !== "session/prompt") return false; + instance.notify("session/update", { + sessionId: "connector-session", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Run tests", + kind: "execute", + status: "in_progress", + }, + }); + instance.respond(message as AcpRequestMessage, { stopReason: "cancelled" }); + return true; + }, + }); + const registration = runAcpProvider({ + id: "cancel-tool-acp", + label: "Cancel tool ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "first", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "first" }] }, + }, + }); + await waitForEvent( + events, + (event) => event.type === "session.turn" && event.state === "canceled", + ); + + const rows = events.flatMap((event) => + event.type === "timeline.item" && event.item.id === "tool-1" ? [event.item] : [], + ); + expect(rows.at(-1)).toMatchObject({ type: "tool_call", status: "canceled", error: null }); + await connection.close(); + }); + it("serializes configuration mutations", async () => { let releaseMutation: (() => void) | null = null; const configOptions = [ diff --git a/packages/protocol/src/provider-icon-names.ts b/packages/protocol/src/provider-icon-names.ts index 6265b631c3..4ca8ea44e3 100644 --- a/packages/protocol/src/provider-icon-names.ts +++ b/packages/protocol/src/provider-icon-names.ts @@ -31,6 +31,7 @@ export const ACP_PROVIDER_ICON_NAMES = [ "glm-acp-agent", "goose", "grok", + "hoplite", "junie", "kilo", "kimi", diff --git a/public-docs/providers.md b/public-docs/providers.md index 9e80ebecde..0bc892574d 100644 --- a/public-docs/providers.md +++ b/public-docs/providers.md @@ -8,7 +8,7 @@ category: Providers # Providers -Paseo doesn't ship its own coding agent. It launches and supervises **existing CLIs you've already installed and authenticated**, Claude Code, Codex, OpenCode, Antigravity, Muse Code, Hoplite, Cursor, Gemini, and the rest. Your subscriptions, your config, your skills, your MCP servers all stay intact. Paseo just gives you a UI, a CLI, a relay, and orchestration on top. +Paseo doesn't ship its own coding agent. It launches and supervises **existing CLIs you've already installed and authenticated**, Claude Code, Codex, OpenCode, Antigravity, Muse Code, Cursor, Gemini, and the rest. Your subscriptions, your config, your skills, your MCP servers all stay intact. Paseo just gives you a UI, a CLI, a relay, and orchestration on top. ## Mental model @@ -16,7 +16,7 @@ A provider is the contract between Paseo and one external agent CLI: how to laun ## Two tiers -- **Native support**, Paseo ships a bundled adapter for the major agents (Claude Code, Codex, OpenCode, Pi, [Antigravity](/docs/supported-providers#antigravity), [Muse Code](/docs/muse-code), [Hoplite](/docs/supported-providers#hoplite)). Auto-discovered when the underlying CLI is installed, with mode metadata and voice support where applicable. +- **Native support**, Paseo ships a bundled adapter for the major agents (Claude Code, Codex, OpenCode, Pi, [Antigravity](/docs/supported-providers#antigravity), [Muse Code](/docs/muse-code), [Hoplite](/docs/supported-providers#hoplite)). Auto-discovered when the underlying CLI is installed, with mode metadata and voice support where applicable. Hoplite's CLI runs on your machine, but its agent works in a Hoplite cloud workspace, so your local MCP servers and Paseo tools are not available there. - **ACP catalog**, any agent speaking the [Agent Client Protocol](https://agentclientprotocol.com) is supported through a generic adapter. Paseo ships a curated catalog of one-click installs (Cursor, Gemini, GitHub Copilot, Hermes, Kimi, Qwen Code, and 25+ more), and you can add any other ACP agent yourself. Either way, **you install the underlying CLI**. Paseo runs it.