From 91505db5eb31a129831d648c2de6d7f00c8a5ced Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Mon, 9 Mar 2026 14:51:00 +0100 Subject: [PATCH] acp: fail honestly in bridge mode --- docs.acp.md | 34 +++++++++ docs/cli/acp.md | 32 ++++++++ src/acp/translator.session-rate-limit.test.ts | 74 +++++++++++++++++++ src/acp/translator.ts | 19 +++-- 4 files changed, 152 insertions(+), 7 deletions(-) diff --git a/docs.acp.md b/docs.acp.md index cfe7349c341..48613138141 100644 --- a/docs.acp.md +++ b/docs.acp.md @@ -17,6 +17,40 @@ Key goals: - Works with existing Gateway session store (list/resolve/reset). - Safe defaults (isolated ACP session keys by default). +## Bridge Scope + +`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor +runtime. It is designed to route IDE prompts into an existing OpenClaw Gateway +session with predictable session mapping and basic streaming updates. + +## Compatibility Matrix + +| ACP area | Status | Notes | +| --------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | +| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | +| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | +| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key. Stored history is not replayed yet. | +| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | +| Session modes | Partial | `session/set_mode` is supported, but this bridge does not yet expose broader ACP-native mode or config surfaces. | +| Tool streaming | Partial | Tool start and result updates are forwarded, but without ACP-native terminal or richer editor metadata. | +| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | +| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | +| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | +| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | + +## Known Limitations + +- `loadSession` rebinds to an existing Gateway session, but it does not replay + prior user or assistant history yet. +- If multiple ACP clients share the same Gateway session key, event and cancel + routing are best-effort rather than strictly isolated per client. Prefer the + default isolated `acp:` sessions when you need clean editor-local + turns. +- Gateway stop states are translated into ACP stop reasons, but that mapping is + less expressive than a fully ACP-native runtime. +- Tool follow-along data is intentionally narrow in bridge mode. The bridge + does not yet emit ACP terminals, file locations, or structured diffs. + ## How can I use this Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 7650390ed55..fbd46e428d3 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -13,6 +13,38 @@ Run the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) bridge t This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway over WebSocket. It keeps ACP sessions mapped to Gateway session keys. +`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor +runtime. It focuses on session routing, prompt delivery, and basic streaming +updates. + +## Compatibility Matrix + +| ACP area | Status | Notes | +| --------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | +| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | +| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | +| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key. Stored history is not replayed yet. | +| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | +| Session modes | Partial | `session/set_mode` is supported, but this bridge does not yet expose broader ACP-native mode or config surfaces. | +| Tool streaming | Partial | Tool start and result updates are forwarded, but without ACP-native terminal or richer editor metadata. | +| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | +| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | +| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | +| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | + +## Known Limitations + +- `loadSession` rebinds to an existing Gateway session, but it does not replay + prior user or assistant history yet. +- If multiple ACP clients share the same Gateway session key, event and cancel + routing are best-effort rather than strictly isolated per client. Prefer the + default isolated `acp:` sessions when you need clean editor-local + turns. +- Gateway stop states are translated into ACP stop reasons, but that mapping is + less expressive than a fully ACP-native runtime. +- Tool follow-along data is intentionally narrow in bridge mode. The bridge + does not yet emit ACP terminals, file locations, or structured diffs. + ## Usage ```bash diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 2e7d03b0f7b..51d6cc1f8e2 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -2,6 +2,7 @@ import type { LoadSessionRequest, NewSessionRequest, PromptRequest, + SetSessionModeRequest, } from "@agentclientprotocol/sdk"; import { describe, expect, it, vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; @@ -38,6 +39,14 @@ function createPromptRequest( } as unknown as PromptRequest; } +function createSetSessionModeRequest(sessionId: string, modeId: string): SetSessionModeRequest { + return { + sessionId, + modeId, + _meta: {}, + } as unknown as SetSessionModeRequest; +} + async function expectOversizedPromptRejected(params: { sessionId: string; text: string }) { const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"]; const sessionStore = createInMemorySessionStore(); @@ -97,6 +106,71 @@ describe("acp session creation rate limit", () => { }); }); +describe("acp unsupported bridge session setup", () => { + it("rejects per-session MCP servers on newSession", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = vi.spyOn(connection, "sessionUpdate"); + const agent = new AcpGatewayAgent(connection, createAcpGateway(), { + sessionStore, + }); + + await expect( + agent.newSession({ + ...createNewSessionRequest(), + mcpServers: [{ name: "docs", command: "mcp-docs" }] as never[], + }), + ).rejects.toThrow(/does not support per-session MCP servers/i); + + expect(sessionStore.hasSession("docs-session")).toBe(false); + expect(sessionUpdate).not.toHaveBeenCalled(); + sessionStore.clearAllSessionsForTest(); + }); + + it("rejects per-session MCP servers on loadSession", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = vi.spyOn(connection, "sessionUpdate"); + const agent = new AcpGatewayAgent(connection, createAcpGateway(), { + sessionStore, + }); + + await expect( + agent.loadSession({ + ...createLoadSessionRequest("docs-session"), + mcpServers: [{ name: "docs", command: "mcp-docs" }] as never[], + }), + ).rejects.toThrow(/does not support per-session MCP servers/i); + + expect(sessionStore.hasSession("docs-session")).toBe(false); + expect(sessionUpdate).not.toHaveBeenCalled(); + sessionStore.clearAllSessionsForTest(); + }); +}); + +describe("acp setSessionMode bridge behavior", () => { + it("surfaces gateway mode patch failures instead of succeeding silently", async () => { + const sessionStore = createInMemorySessionStore(); + const request = vi.fn(async (method: string) => { + if (method === "sessions.patch") { + throw new Error("gateway rejected mode"); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("mode-session")); + + await expect( + agent.setSessionMode(createSetSessionModeRequest("mode-session", "high")), + ).rejects.toThrow(/gateway rejected mode/i); + + sessionStore.clearAllSessionsForTest(); + }); +}); + describe("acp prompt size hardening", () => { it("rejects oversized prompt blocks without leaking active runs", async () => { await expectOversizedPromptRejected({ diff --git a/src/acp/translator.ts b/src/acp/translator.ts index d399228afa6..6be5f72510f 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -170,9 +170,7 @@ export class AcpGatewayAgent implements Agent { } async newSession(params: NewSessionRequest): Promise { - if (params.mcpServers.length > 0) { - this.log(`ignoring ${params.mcpServers.length} MCP servers`); - } + this.assertSupportedSessionSetup(params.mcpServers); this.enforceSessionCreateRateLimit("newSession"); const sessionId = randomUUID(); @@ -193,9 +191,7 @@ export class AcpGatewayAgent implements Agent { } async loadSession(params: LoadSessionRequest): Promise { - if (params.mcpServers.length > 0) { - this.log(`ignoring ${params.mcpServers.length} MCP servers`); - } + this.assertSupportedSessionSetup(params.mcpServers); if (!this.sessionStore.hasSession(params.sessionId)) { this.enforceSessionCreateRateLimit("loadSession"); } @@ -256,7 +252,7 @@ export class AcpGatewayAgent implements Agent { this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`); } catch (err) { this.log(`setSessionMode error: ${String(err)}`); - throw err; + throw err instanceof Error ? err : new Error(String(err)); } return {}; } @@ -536,6 +532,15 @@ export class AcpGatewayAgent implements Agent { }); } + private assertSupportedSessionSetup(mcpServers: ReadonlyArray): void { + if (mcpServers.length === 0) { + return; + } + throw new Error( + "ACP bridge mode does not support per-session MCP servers. Configure MCP on the OpenClaw gateway or agent instead.", + ); + } + private enforceSessionCreateRateLimit(method: "newSession" | "loadSession"): void { const budget = this.sessionCreateRateLimiter.consume(); if (budget.allowed) {