|
| 1 | +# ADR: ACP Server over WebSocket — Phase 1 (as-built) |
| 2 | + |
| 3 | +- **Status:** Accepted |
| 4 | +- **Date:** 2026-07-17 |
| 5 | +- **Author:** @brettchien |
| 6 | +- **Related:** [ADR: ACP Server with WebSocket Transport](./acp-server-websocket.md) (original proposal, @pahud) |
| 7 | +- **Conformance:** official ACP Schema **v1.19.0** — see [acp-official-methods.md](../acp-official-methods.md) |
| 8 | +- **Implementation:** this PR (revives and completes #1260) |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | +## 1. Context |
| 13 | + |
| 14 | +The original proposal ([acp-server-websocket.md](./acp-server-websocket.md)) defines |
| 15 | +the full ACP-server vision across five phases. This ADR is the **as-built record of |
| 16 | +Phase 1** — the concrete, **wire-conformant** primitive surface the implementation |
| 17 | +ships and that future work should follow. |
| 18 | + |
| 19 | +Scope: a standard-ACP **1:1 streaming chat** endpoint for real ACP clients (browser, |
| 20 | +desktop, IDE, CLI) over WebSocket. Not in Phase 1: tool calls / permissions, client |
| 21 | +fs/terminal methods, multi-agent fan-out, Streamable HTTP. |
| 22 | + |
| 23 | +Design goal (per decision on 2026-07-17): **follow the official ACP guide** so |
| 24 | +third-party ACP clients (Zed, JetBrains, …) interoperate — no custom method names. |
| 25 | + |
| 26 | +## 2. Decision — Phase 1 primitive surface (ACP-conformant) |
| 27 | + |
| 28 | +Transport: `GET /acp`, feature-gated `acp` + runtime `OPENAB_ACP_ENABLED`. Token auth |
| 29 | +on the WS upgrade via timing-safe compare (`subtle::ConstantTimeEq`, |
| 30 | +`OPENAB_ACP_AUTH_KEY`). JSON-RPC 2.0; non-`"2.0"` rejected with `-32600`. |
| 31 | + |
| 32 | +### Client → Agent (requests) |
| 33 | + |
| 34 | +| Method | Params | Result | |
| 35 | +|---|---|---| |
| 36 | +| `initialize` | `{ protocolVersion: 1, clientCapabilities?, clientInfo? }` | `{ protocolVersion: 1, agentCapabilities, agentInfo, authMethods: [] }` | |
| 37 | +| `session/new` | `{ cwd, mcpServers }` | `{ sessionId }` | |
| 38 | +| `session/resume` | `{ sessionId, cwd, mcpServers? }` | `{}` (no history replay) | |
| 39 | +| `session/prompt` | `{ sessionId, prompt: [ContentBlock] }` | `{ stopReason }` | |
| 40 | + |
| 41 | +`agentCapabilities` advertises `sessionCapabilities.resume` (we support resume) and |
| 42 | +`loadSession: false` (we cannot replay history — see §3). `promptCapabilities` are |
| 43 | +all `false` in Phase 1 (text only). `protocolVersion` is the integer `1`. |
| 44 | + |
| 45 | +### Client → Agent (notification) |
| 46 | + |
| 47 | +| Method | Params | Effect | |
| 48 | +|---|---|---| |
| 49 | +| `session/cancel` | `{ sessionId }` | one-way; in-flight prompt ends with `stopReason:"cancelled"`. No response. | |
| 50 | + |
| 51 | +### Agent → Client (notification) |
| 52 | + |
| 53 | +- `session/update` with `update.sessionUpdate = "agent_message_chunk"` and |
| 54 | + `update.content = { type:"text", text: <delta> }` — streamed reply text. Delta is |
| 55 | + sliced char-boundary-safe (`str::get`, never byte-index) so CJK / 顏文字 / emoji |
| 56 | + cannot panic the stream. |
| 57 | +- Turn completion is the `session/prompt` **response** (`{ stopReason }`, correlated |
| 58 | + to the request id), not a separate notification. `stopReason` ∈ `end_turn` / |
| 59 | + `cancelled`. A backend timeout has no ACP stopReason, so it returns a JSON-RPC |
| 60 | + error (`-32603`) instead. |
| 61 | + |
| 62 | +### Session ↔ core mapping |
| 63 | + |
| 64 | +- `sessionId = sess_<uuid>` and `channel_id = acp_<uuid>` share one uuid, so |
| 65 | + `channel_id` is always re-derivable from a persisted `sessionId`. |
| 66 | +- Prompts become a `GatewayEvent` (`platform:"acp"`, `channel:acp_<uuid>`); core |
| 67 | + keys continuity by `session_key = acp:<channel_id>`. |
| 68 | + |
| 69 | +## 3. Resume — why `session/resume`, not `session/load` |
| 70 | + |
| 71 | +ACP distinguishes `session/load` (agent **replays** history via `session/update`, |
| 72 | +then responds) from `session/resume` (restores context, **MUST NOT** replay). We |
| 73 | +implement **`session/resume`**, decided against `crates/openab-core/src/acp/pool.rs`: |
| 74 | + |
| 75 | +- The conversation history lives inside the **downstream** coding-agent CLI's session |
| 76 | + (claude / codex / kiro). The core only persists a `thread_key → agent sessionId` |
| 77 | + mapping — it does **not** hold a replayable upstream transcript. So the gateway |
| 78 | + cannot satisfy `session/load`'s replay contract; `loadSession: false`. |
| 79 | +- Continuation still works: on the next prompt, core recovers the underlying agent |
| 80 | + session via its persisted mapping + downstream `session/load` (this survives a |
| 81 | + process restart, within the agent's retention / `session_ttl_hours`, default 4h). |
| 82 | +- `resume` therefore restores context without replay; the **client** keeps its own |
| 83 | + transcript for display. `session/resume` returns `{}` immediately. |
| 84 | + |
| 85 | +Whether the core session is still alive is **not observable** at the gateway — an |
| 86 | +expired session silently starts fresh, and the core prefixes its first reply with a |
| 87 | +"Session expired" notice the client can surface. |
| 88 | + |
| 89 | +Security: `sessionId` is a server-minted, high-entropy capability; `session/resume` |
| 90 | +requires a well-formed `sess_<uuid>`, keeping the channel inside the `acp_` namespace |
| 91 | +and rejecting forged ids. |
| 92 | + |
| 93 | +## 4. Divergences from the original proposal |
| 94 | + |
| 95 | +| Proposal (Phase 1) | As-built | Why | |
| 96 | +|---|---|---| |
| 97 | +| Add `agent-client-protocol` crate dep | removed — hand-rolled JSON-RPC | fewer deps; small surface | |
| 98 | +| "Bearer token auth" | `subtle::ConstantTimeEq` + `OPENAB_ACP_AUTH_KEY` | timing-safe, no new dep | |
| 99 | +| Resume in **Phase 3** | `session/resume` in **Phase 1** | core continuity is already channel-keyed + persisted, so a gateway-only change buys reconnect resume cheaply | |
| 100 | + |
| 101 | +## 5. Consequences & limits |
| 102 | + |
| 103 | +- **1:1 only** — reply registry is `channel_id → single reply_tx`; the delta stream |
| 104 | + assumes one monotonic text. Multi-agent fan-out is Phase 4 (would corrupt this). |
| 105 | +- **cwd / mcpServers** — accepted on `session/new` / `session/resume` for wire |
| 106 | + conformance but not yet propagated into the agent (follow-up). |
| 107 | +- **Emoji** — inline 顏文字 flow through as text; reaction emoji stay no-op in Phase 1. |
| 108 | +- **Reconnect** — on WS disconnect the per-connection session map is dropped; the |
| 109 | + client reconnects with `session/resume` + its persisted `sessionId`. |
| 110 | + |
| 111 | +## 6. Roadmap (from proposal; resume pulled into Phase 1) |
| 112 | + |
| 113 | +- **Phase 2** — tool calls: `session/update` variants `tool_call` / `tool_call_update`, |
| 114 | + and `session/request_permission`; reaction emoji → updates |
| 115 | +- **Phase 3** — `session/load` with history replay (needs an upstream transcript store) |
| 116 | +- **Phase 4** — multi-agent fan-out |
| 117 | +- **Phase 5** — Streamable HTTP (POST + SSE) on the same `/acp` |
| 118 | + |
| 119 | +## 7. References |
| 120 | + |
| 121 | +- Original proposal: [acp-server-websocket.md](./acp-server-websocket.md) |
| 122 | +- Official method surface + coverage: [acp-official-methods.md](../acp-official-methods.md) |
0 commit comments