Skip to content

Commit 46e1f3c

Browse files
秦奇qwencoder
andcommitted
feat(daemon): ACP Streamable HTTP transport at /acp (RFD QwenLM#721)
Adds an official ACP Streamable HTTP transport as a second northbound surface on `qwen serve`, mounted at /acp alongside the existing REST API and sharing the same HttpAcpBridge + EventBus. Implements the RFD QwenLM#721 wire shape: single endpoint; POST {initialize}→200+Acp-Connection-Id, other POST→202; long-lived connection-scoped and session-scoped GET SSE streams carrying JSON-RPC frames; DELETE teardown. Bridges session/new, session/ prompt (streamed session/update + final result), session/cancel, session/ load|resume|close, and the session/request_permission agent→client round trip. qwen-specific features ride _qwen/* extension methods (set_model, heartbeat). Toggle via QWEN_SERVE_ACP_HTTP=0. Verified by an over-the-wire vitest suite (real server + fake bridge, 15 tests) and live against a running daemon with a real model via scripts/acp-http-smoke.mjs. See docs/design/daemon-acp-http/README.md. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1 parent e4ea2dd commit 46e1f3c

10 files changed

Lines changed: 1652 additions & 0 deletions

File tree

docs/design/daemon-acp-http/README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,3 +313,50 @@ thin compat shim over `/acp` (separate, later PR).
313313
| HTTP/1.1 vs required HTTP/2 | Localhost/CLI clients unaffected; documented; h2 is a transport swap later. |
314314
| Two transports on one bridge race | Bridge already supports multi-client; reuse its locking. |
315315
| `fs/*` forwarding vs daemon-local FS | Capability-gated: forward when client declares `fs`, else local. |
316+
317+
---
318+
319+
## 10. Implementation & verification log (v1)
320+
321+
Implemented in `packages/cli/src/serve/acpHttp/` (`jsonRpc.ts`, `sseStream.ts`,
322+
`connectionRegistry.ts`, `dispatch.ts`, `index.ts`), mounted from `server.ts`
323+
via `mountAcpHttp(app, bridge, { boundWorkspace })`.
324+
325+
### Automated (`packages/cli/src/serve/acpHttp/*.test.ts`)
326+
327+
`transport.test.ts` boots a real Express server + the real `mountAcpHttp` over
328+
a controllable fake bridge and drives it with `fetch` + manual SSE parsing.
329+
15 tests green, covering: `initialize` 200 + `Acp-Connection-Id`; unknown-conn
330+
400; `session/new` reply on the connection stream; prompt → `session/update`
331+
stream + final result correlation; `session/request_permission` agent→client→
332+
agent round-trip; `_qwen/session/set_model`; method-not-found; `DELETE` teardown.
333+
334+
### Live daemon (real model)
335+
336+
Booted `qwen serve --port 8767 --token … --workspace …` (bundle entry so the
337+
spawned `qwen --acp` child is self-contained) and ran `scripts/acp-http-smoke.mjs`:
338+
339+
```
340+
✓ initialize: connectionId=… protocolVersion=1
341+
✓ session/new: sessionId=…
342+
→ prompt: "Reply with the single word: pong"
343+
pong
344+
✓ prompt complete: 10 session/update frames, stopReason=end_turn
345+
✓ DELETE /acp — connection closed
346+
ALL CHECKS PASSED ✅
347+
```
348+
349+
Error-path was also confirmed live: when the child failed to start, the bridge
350+
timeout surfaced to the client as a JSON-RPC error frame on the connection
351+
stream (`{"id":2,"error":{"code":-32603,…}}`), proving id-correlation + the
352+
202/SSE split under failure.
353+
354+
### Review fold-in — bridge-issued clientId (found in live verify)
355+
356+
First live run failed `session/prompt` with *"client id … is not registered for
357+
session"*. Root cause: `spawnOrAttach`/`loadSession` **ignore** a caller-supplied
358+
clientId the bridge has never issued and stamp a fresh one (returned in
359+
`BridgeSession.clientId`); the dispatcher was echoing the connection's own
360+
(unregistered) id on `sendPrompt`. Fix: persist the bridge-stamped id on the
361+
`SessionBinding` and echo it on every per-session call (`sessionCtx`). Re-verified
362+
green above.
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Qwen Team
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { randomUUID } from 'node:crypto';
8+
import type { SseStream } from './sseStream.js';
9+
10+
/**
11+
* Tracks one logical ACP-over-HTTP connection (RFD #721). A connection is
12+
* minted at `initialize`, keyed by `Acp-Connection-Id`, and may host many
13+
* sessions — each with its own session-scoped SSE stream.
14+
*/
15+
export interface SessionBinding {
16+
sessionId: string;
17+
/**
18+
* The clientId the bridge STAMPED for this session at create/attach.
19+
* The bridge ignores caller-supplied ids it has never issued and mints
20+
* a fresh one (returned on `spawnOrAttach`/`loadSession`), so every
21+
* later per-session call (`sendPrompt`, permission votes, …) must echo
22+
* THIS id, not the connection's own — otherwise the bridge rejects it
23+
* with "client id is not registered for session".
24+
*/
25+
clientId?: string;
26+
/** Session-scoped SSE stream (the client's `GET /acp` with both headers). */
27+
stream?: SseStream;
28+
/** Frames emitted before the session stream attached, flushed on attach. */
29+
buffer: unknown[];
30+
/** Aborts the bridge event subscription when the stream/connection closes. */
31+
abort: AbortController;
32+
}
33+
34+
/** An agent→client request awaiting the client's JSON-RPC response. */
35+
export interface PendingClientRequest {
36+
sessionId: string;
37+
/** Maps the JSON-RPC id we issued back to the bridge's permission id. */
38+
bridgeRequestId: string;
39+
kind: 'permission';
40+
}
41+
42+
export class AcpConnection {
43+
readonly connectionId: string;
44+
/** Connection-scoped SSE stream (the client's `GET /acp` with only the conn header). */
45+
connStream?: SseStream;
46+
/** Frames emitted before the connection stream attached, flushed on attach. */
47+
private readonly connBuffer: unknown[] = [];
48+
readonly sessions = new Map<string, SessionBinding>();
49+
readonly pending = new Map<number, PendingClientRequest>();
50+
/** Daemon-issued client id reused across this connection's bridge calls. */
51+
readonly clientId: string;
52+
lastActiveMs: number = Date.now();
53+
private idCounter = 0;
54+
55+
constructor(connectionId?: string) {
56+
this.connectionId = connectionId ?? randomUUID();
57+
this.clientId = randomUUID();
58+
}
59+
60+
/** Allocate a fresh JSON-RPC id for an agent→client request. */
61+
nextId(): number {
62+
// Negative ids keep our outbound request ids disjoint from the
63+
// client's (clients conventionally use positive ids), so a client
64+
// that echoes ids can't collide with our permission requests.
65+
this.idCounter -= 1;
66+
return this.idCounter;
67+
}
68+
69+
touch(): void {
70+
this.lastActiveMs = Date.now();
71+
}
72+
73+
getOrCreateSession(sessionId: string): SessionBinding {
74+
let binding = this.sessions.get(sessionId);
75+
if (!binding) {
76+
binding = { sessionId, abort: new AbortController(), buffer: [] };
77+
this.sessions.set(sessionId, binding);
78+
}
79+
return binding;
80+
}
81+
82+
/** Send a frame on the connection-scoped stream (buffer until it attaches). */
83+
sendConn(frame: unknown): void {
84+
if (this.connStream && !this.connStream.isClosed) {
85+
void this.connStream.send(frame);
86+
} else {
87+
this.connBuffer.push(frame);
88+
}
89+
}
90+
91+
/** Attach the connection-scoped stream and flush any buffered frames. */
92+
attachConnStream(stream: SseStream): void {
93+
this.connStream = stream;
94+
for (const frame of this.connBuffer.splice(0)) void stream.send(frame);
95+
}
96+
97+
/** Send a frame on a session-scoped stream (buffer until it attaches). */
98+
sendSession(sessionId: string, frame: unknown): void {
99+
const binding = this.getOrCreateSession(sessionId);
100+
if (binding.stream && !binding.stream.isClosed) {
101+
void binding.stream.send(frame);
102+
} else {
103+
binding.buffer.push(frame);
104+
}
105+
}
106+
107+
/** Attach a session-scoped stream and flush any buffered frames. */
108+
attachSessionStream(sessionId: string, stream: SseStream): SessionBinding {
109+
const binding = this.getOrCreateSession(sessionId);
110+
binding.stream = stream;
111+
for (const frame of binding.buffer.splice(0)) void stream.send(frame);
112+
return binding;
113+
}
114+
115+
closeSessionStream(sessionId: string): void {
116+
const binding = this.sessions.get(sessionId);
117+
if (!binding) return;
118+
binding.abort.abort();
119+
binding.stream?.close();
120+
this.sessions.delete(sessionId);
121+
}
122+
123+
destroy(): void {
124+
for (const binding of this.sessions.values()) {
125+
binding.abort.abort();
126+
binding.stream?.close();
127+
}
128+
this.sessions.clear();
129+
this.pending.clear();
130+
this.connStream?.close();
131+
}
132+
}
133+
134+
/**
135+
* Registry of live ACP connections with an idle-TTL sweep. The sweep is
136+
* defensive: a well-behaved client `DELETE /acp`s, but a crashed client
137+
* that never closes its streams would otherwise leak connection state.
138+
*/
139+
export class ConnectionRegistry {
140+
private readonly byId = new Map<string, AcpConnection>();
141+
private readonly sweepTimer: ReturnType<typeof setInterval>;
142+
143+
constructor(private readonly idleTtlMs = 30 * 60_000) {
144+
this.sweepTimer = setInterval(() => this.sweep(), 60_000);
145+
this.sweepTimer.unref();
146+
}
147+
148+
create(): AcpConnection {
149+
const conn = new AcpConnection();
150+
this.byId.set(conn.connectionId, conn);
151+
return conn;
152+
}
153+
154+
get(connectionId: string | undefined): AcpConnection | undefined {
155+
if (!connectionId) return undefined;
156+
const conn = this.byId.get(connectionId);
157+
conn?.touch();
158+
return conn;
159+
}
160+
161+
delete(connectionId: string): boolean {
162+
const conn = this.byId.get(connectionId);
163+
if (!conn) return false;
164+
conn.destroy();
165+
return this.byId.delete(connectionId);
166+
}
167+
168+
get size(): number {
169+
return this.byId.size;
170+
}
171+
172+
dispose(): void {
173+
clearInterval(this.sweepTimer);
174+
for (const id of [...this.byId.keys()]) this.delete(id);
175+
}
176+
177+
private sweep(): void {
178+
const cutoff = Date.now() - this.idleTtlMs;
179+
for (const [id, conn] of this.byId) {
180+
if (conn.lastActiveMs < cutoff) this.delete(id);
181+
}
182+
}
183+
}

0 commit comments

Comments
 (0)