acp: fail honestly in bridge mode (#41424)

Merged via squash.

Prepared head SHA: b5e6e13afe
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-03-09 22:01:30 +01:00
committed by GitHub
parent 1bc59cc09d
commit e6e4169e82
5 changed files with 153 additions and 7 deletions

View File

@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
- Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927. - Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927.
- Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x. - Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x.
- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky. - Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky.
- ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky.
## 2026.3.8 ## 2026.3.8

View File

@@ -17,6 +17,40 @@ Key goals:
- Works with existing Gateway session store (list/resolve/reset). - Works with existing Gateway session store (list/resolve/reset).
- Safe defaults (isolated ACP session keys by default). - 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:<uuid>` 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 ## How can I use this
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to

View File

@@ -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 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. 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:<uuid>` 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 ## Usage
```bash ```bash

View File

@@ -2,6 +2,7 @@ import type {
LoadSessionRequest, LoadSessionRequest,
NewSessionRequest, NewSessionRequest,
PromptRequest, PromptRequest,
SetSessionModeRequest,
} from "@agentclientprotocol/sdk"; } from "@agentclientprotocol/sdk";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { GatewayClient } from "../gateway/client.js"; import type { GatewayClient } from "../gateway/client.js";
@@ -38,6 +39,14 @@ function createPromptRequest(
} as unknown as PromptRequest; } 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 }) { async function expectOversizedPromptRejected(params: { sessionId: string; text: string }) {
const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"]; const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"];
const sessionStore = createInMemorySessionStore(); 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", () => { describe("acp prompt size hardening", () => {
it("rejects oversized prompt blocks without leaking active runs", async () => { it("rejects oversized prompt blocks without leaking active runs", async () => {
await expectOversizedPromptRejected({ await expectOversizedPromptRejected({

View File

@@ -170,9 +170,7 @@ export class AcpGatewayAgent implements Agent {
} }
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> { async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
if (params.mcpServers.length > 0) { this.assertSupportedSessionSetup(params.mcpServers);
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
}
this.enforceSessionCreateRateLimit("newSession"); this.enforceSessionCreateRateLimit("newSession");
const sessionId = randomUUID(); const sessionId = randomUUID();
@@ -193,9 +191,7 @@ export class AcpGatewayAgent implements Agent {
} }
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> { async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
if (params.mcpServers.length > 0) { this.assertSupportedSessionSetup(params.mcpServers);
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
}
if (!this.sessionStore.hasSession(params.sessionId)) { if (!this.sessionStore.hasSession(params.sessionId)) {
this.enforceSessionCreateRateLimit("loadSession"); this.enforceSessionCreateRateLimit("loadSession");
} }
@@ -256,7 +252,7 @@ export class AcpGatewayAgent implements Agent {
this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`); this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`);
} catch (err) { } catch (err) {
this.log(`setSessionMode error: ${String(err)}`); this.log(`setSessionMode error: ${String(err)}`);
throw err; throw err instanceof Error ? err : new Error(String(err));
} }
return {}; return {};
} }
@@ -536,6 +532,15 @@ export class AcpGatewayAgent implements Agent {
}); });
} }
private assertSupportedSessionSetup(mcpServers: ReadonlyArray<unknown>): 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 { private enforceSessionCreateRateLimit(method: "newSession" | "loadSession"): void {
const budget = this.sessionCreateRateLimiter.consume(); const budget = this.sessionCreateRateLimiter.consume();
if (budget.allowed) { if (budget.allowed) {