* fix(daemon): resume /acp session stream via Last-Event-ID (recover mid-turn content)
The `/acp` Streamable-HTTP session event stream was live-only: it emitted no
SSE `id:` sequence and ignored a `Last-Event-ID` reconnect header. When a
control-plane proxy idle-closed the long-lived SSE mid-turn, every content
frame the daemon produced during the gap (`session/update` carrying
agent_thought_chunk / agent_message_chunk) was lost — the turn still settled,
so the UI showed "done" with an empty/truncated body, and only a re-send
recovered it (tracked as §1.8 in the integration notes).
The replay engine already exists and is battle-tested on the REST surface:
EventBus assigns a monotonic per-session `id`, keeps a bounded ring, and
`subscribeEvents({ lastEventId })` replays `id > lastEventId` before live
events flow. This wires the `/acp` transport to it — no eventBus/bridge change.
- transport-stream / sse-stream / ws-stream: `send(message, id?)`. SSE emits an
`id:` line when `id` is present (mirrors REST `formatSseFrame`); WS ignores it
(stateful, no replay).
- connection-registry: `sendSession(…, id?)` threads the cursor; the pre-attach
session buffer stores `{ frame, id? }` so a buffered frame keeps its `id:`.
- dispatch: `translateEvent` passes `event.id` for bus events; `pumpSessionEvents`
forwards `lastEventId` to `subscribeEvents`.
- index: the `GET /acp` session branch reads `Last-Event-ID` (strict
decimal-only parse, same rule as REST) and passes it to the pump.
Bus-originated frames (session/update, request_permission, daemon notifies)
carry an `id:`; JSON-RPC responses and synthetic terminal frames do not, so
they don't burn a slot in the resume sequence. Backward compatible: clients
that send no `Last-Event-ID` get live-only behaviour as before, and `id:`
lines are inert for clients that ignore them.
Design: docs/design/daemon-acp-http/sse-resumable-stream.md
* fix(daemon): make /acp resume actually engage — session-stream grace/reclaim + replay guards
Addresses three review Criticals on the §1.8 plumbing: on its own the
`id:`/`Last-Event-ID` wiring never fired in the real close-then-reconnect flow,
and once it does fire two replay-correctness gaps become reachable.
1. Session-stream grace/reclaim (the core fix). A transport-level session-stream
close used to run the FULL `closeSessionStream` teardown — removing
ownership, aborting the in-flight prompt, detaching the bridge client. In the
real EventSource/proxy order (old socket closes first, then reconnect) that
meant the reconnect carrying `Last-Event-ID` was rejected 403 before the
cursor was read, and the prompt was already aborted — so replay had nothing
to resume. Now a transport close DETACHES (`detachSessionStream`): it stops
only the stream + subscription and keeps the binding, ownership, prompt, and
bridge-client alive for a grace window (`SESSION_GRACE_MS`, mirrors
`CONN_GRACE_MS`). A reconnect within the window reclaims (clears the timer);
otherwise the grace timer runs the full teardown, bounding runaway cost. Full
teardown stays immediate for explicit `session/close` and connection destroy.
The GET handler branches on `stream.isClosed` (transport close → grace;
pump-ended-while-open → full close).
2. No double-delivery (buffer ↔ ring overlap). `attachSessionStream` records the
max bus id flushed from the pre-attach buffer; the GET handler advances the
replay cursor to `max(Last-Event-ID, lastFlushedEventId)` so the ring replay
doesn't re-emit an already-flushed frame.
3. Idempotent `permission_request` under replay. `translateEvent` reuses the
existing `conn.pending` entry for a `bridgeRequestId` (re-sends the same
outbound id) instead of minting a second id+entry — no orphan pending, no
duplicate prompt on a ring-replayed permission.
Also: extract `parseLastEventId` to a shared `serve/sse-last-event-id.ts` used
by both REST and `/acp` (no drift; logs the rejected value); log `lastEventId`
in the pump error.
Tests: real close-then-reconnect order (200 not 403 + prompt not aborted);
overflow Last-Event-ID; replayed permission reuses pending id; registry
grace/reclaim + buffer-flush-preserves-id. Full acp-http suite green (216).
* feat(sdk): expose ACP transports via opt-in ./daemon/transports subpath
The resumable ACP-over-HTTP transport (AcpHttpTransport, native
supportsReplay + Last-Event-ID) and the negotiateTransport factory were
reachable only from source paths inside the monorepo — the published
`@qwen-code/sdk/daemon` barrel intentionally omits them to keep its
budget-checked browser bundle lean, so external consumers (agent-web)
had no import path short of forking.
Add a separate opt-in subpath `@qwen-code/sdk/daemon/transports` that
ships AcpHttpTransport / AcpWsTransport / AutoReconnectTransport /
RestSseTransport / negotiateTransport as their own browser+node bundle.
The default `./daemon` barrel and its byte budget are unchanged, so
REST-only consumers stay tree-shaken and pay nothing for the transports.
Also add a `fetchFn` option to NegotiateTransportOptions so callers can
inject auth/proxy/test fetch instead of the hardcoded global.
- build.js: emit dist/daemon/transports.{js,cjs}; reuse the node-builtin
guard for the new browser bundle (no size budget — it legitimately
ships the transports) while keeping the default barrel's budget check.
- daemon/index.ts: update the rationale comment to point at the subpath.
- daemon-transports-surface.test.ts: lock the runtime + type surface and
the package.json exports entry.
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(daemon): resume cursor must not skip in-flight-lost frames
Round-3 review (qwen-code-ci-bot) flagged a silent-frame-loss Critical in
the §1.8 resume path I added: `resumeCursor = max(Last-Event-ID,
lastFlushedEventId)` advances the ring-replay cursor past the buffer, but a
frame sent to the now-dead socket yet never received by the client has a bus
id BELOW the buffer's ids and ABOVE the client's cursor — so the max() skips
it and the ring replay never re-emits it. Exactly the proxy idle-close
mid-turn frame §1.8 is meant to recover.
Fix without trading loss for duplicates: a buffered bus event is ALSO in the
EventBus ring (it was published there to get its id), so the ring replay
started at the client's cursor is the single delivery path for every bus
event after the cursor. `attachSessionStream` now takes the resume cursor and,
when resuming, does NOT flush id-bearing buffered frames — the ring owns them,
delivering each exactly once including the in-flight-lost frame. Id-less
frames (JSON-RPC replies via `replySession`, not ring events) are still
flushed — their only delivery path. The GET handler sets
`resumeCursor = lastEventId` verbatim; `lastFlushedEventId` is removed.
Also from the same review:
- sse-last-event-id `safeLogValue`: strip ALL C0 control chars + DEL (not just
CR/LF) so a crafted `Last-Event-ID` can't smuggle ANSI ESC / null bytes onto
an operator's terminal via stderr.
- ws-stream: regression test asserting `send(msg, id)` keeps the WS wire frame
bare JSON (no SSE `id:` framing leak).
- connection-registry: resume-path test (id-bearing frames skipped, id-less
reply still flushed); design doc updated.
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* refactor(daemon): inline resumeCursor alias to lastEventId
Review nit (yiliang114): after the prior commit dropped the `max()` logic,
`resumeCursor` is a pure alias for `lastEventId`. Use `lastEventId` directly in
the `pumpSessionEvents` call and the error log; drop the alias.
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* docs(daemon): refresh stale resume comments + add gap-delivery test
Round-4 review (qwen-code-ci-bot, against the now-corrected resume model):
- connection-registry: the attachSessionStream CONTRACT comment still cited a
`promptAbort?.abort()` call in the index.ts onClose handler that an earlier
commit removed. Rewrite it to describe the current model — each stream's pump
has its own abort controller and teardown is identity-guarded in
`onPumpSettled`, so installing the new stream first makes the old stream
settle into detach-with-grace rather than tearing down the in-flight prompt.
- dispatch: the stream_error frame comment ("no bus id, so no SSE id: line")
contradicted the code passing `event.id`. Make it truthful: pass the cursor
through if present; a synthetic terminal frame has no id so none is written.
- connection-registry.test: add the explicit detach → produce gap events →
reattach → flush-exactly-once test (the PR's core value prop at the registry
layer), incl. a second reattach asserting the buffer drained.
The two Criticals in the same review referenced `resumeCursor` /
`lastFlushedEventId` / `Math.max`, all removed in prior commits — obsolete
against current code (answered + resolved on the threads).
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(daemon): preserve stream order on resume + harden grace/permission paths
Round-5 review (wenshao + qwen-code-ci-bot):
- [Critical, wenshao] Out-of-order completion on resume. attachSessionStream
flushed id-less buffered JSON-RPC replies (e.g. a session/prompt result that
landed during the detach gap) immediately — ahead of the ring replay that
redelivers the content chunks preceding them, so a client could see "prompt
complete" before the body (the truncated-body failure §1.8 fixes). Now on
resume those id-less frames are DEFERRED in the buffer; the event pump
releases them via flushBufferedSessionFrames once the replay boundary
(replay_complete / state_resync_required) passes, preserving original order.
Fresh connects (no cursor, no replay) still flush the whole buffer in order.
- [Critical, ci-bot] Permission auto-denied during the reconnect grace window:
a permission_request arriving while binding.stream is detached cancel-denies,
so a client reconnecting within grace can't vote. The structural fix (defer
the vote across grace) belongs with the §1.7 permission-coordination
follow-up; here, log an operator breadcrumb when it fires during grace, and
document the synchronous-translateEvent INVARIANT the direct binding.stream
.send relies on.
- [ci-bot] Stale-stream detach is now tested (reclaim installs s2; a late s1
close is a no-op — no teardown, no grace re-arm). Grace-expiry teardown now
logs a breadcrumb so a vanished session is distinguishable from explicit
close. TS4111: bracket-access the index-signature exports entry in the SDK
surface test. transports browser bundle now has a size budget
(MAX_TRANSPORTS_BROWSER_BUNDLE_BYTES = 48KB; current ~29KB).
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(sdk): route session-scoped /acp responses so prompts don't hang
[Critical, wenshao] The published AcpHttpTransport could hang real
session/prompt + config requests. Its subscribeEvents() opened REST
GET /session/:id/events and only sendRequest's connection-scoped stream
resolved responses — but the daemon's replySession() routes session-scoped
JSON-RPC replies onto the session-scoped /acp stream, which the transport
never read. So a session/prompt reply was never observed → the pending
request never settled.
Switch subscribeEvents to the session-scoped /acp stream (GET /acp +
Acp-Session-Id) — the resumable §1.8 stream the daemon puts session replies
on — and dispatch each raw JSON-RPC frame by shape:
- response (id, no method) → resolve the shared pending map (the fix)
- notification (method, no id) → DaemonEvent via denormalizeAcpNotification,
stamped with the real bus id from the SSE
`id:` line (the synthetic denormalizer id is
not resume-compatible; supportsReplay=true
now tracks the authoritative cursor)
- session/request_permission → surfaced as a permission_request event so
consumers still see prompts (responding to
the vote is the §1.7 follow-up)
The connection-scoped stream still carries replies to connection-level
requests (initialize, session/new). Adds 4 subscribeEvents unit tests
(stream selection + headers, notification→event+busId, response consumed-not-
yielded, permission surfaced). Full SDK suite green (1062).
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(sdk,daemon): harden /acp SSE parser + grace/flush observability
Round-6 review (qwen-code-ci-bot), all additive / no behavioural side effects:
- [Critical] Unbounded SSE buffer in the new AcpHttpTransport session-stream
parser → OOM (tab crash for browser consumers). Add a 16 MiB cap mirroring
parseSseStream's MAX_BUF_CHARS, and reuse parseSseStream's CRLF-aware
`consumeFrames` splitter (now exported) instead of an inline `\n\n` scan —
closing the CRLF, multi-line `data:` join, and trailing-CR gaps in one go.
- Deferred-flush ordering race: the pump's post-loop safety flushDeferred()
now runs only on a non-aborted exit. An abort means the stream was
detached/reclaimed; flushing there could drain the deferred reply onto a
reclaiming stream ahead of its own replay (reintroducing the out-of-order
delivery the deferral prevents). On error the frames stay buffered for the
next attach — never lost.
- Grace reclaim now logs (detach + grace-expiry already did) so the reconnect
trail is complete for operators.
- sse-last-event-id doc: corrected the "shared by REST and ACP" claim — after
the #5809 serve-route split REST keeps its own copy; unifying them would
touch REST, so it's deferred (this PR keeps REST untouched).
Thread on a full deferred-flush integration test: the ordering invariant is
already locked at the unit layer (flushBufferedSessionFrames defer test +
gap-delivery test); a full-HTTP timing test against the FakeBridge would be
flake-prone, so it's intentionally not added.
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(daemon,sdk): harden /acp resumable stream from review round 7
Address review-pr findings on the §1.8 resumable stream, all additive /
backward-compatible (REST untouched, no behavioural side effects):
- connection-registry: guard flushBufferedSessionFrames against a closed
stream so deferred replies stay buffered for the next reconnect instead
of being dropped onto a dead socket. Keep the synchronous in-order
enqueue (SseStream serializes via one writeChain) — an await-per-frame
drain would let a live event interleave between deferred frames and
reorder the very replies this deferral preserves (W1).
- connection-registry: log at the moment of detach so an operator can
measure the real disconnect→reconnect gap against the grace window.
- index: route sessionId through logSafe() in the event-pump error log,
matching every other log line this PR adds (terminal-escape hardening).
- AcpHttpTransport: remove the abort listener in the finally block so a
long-lived signal reused across reconnects doesn't accumulate listeners.
- AcpHttpTransport: parse the SSE `id:` cursor with the server's strict
/^\d+$/ + MAX_SAFE_INTEGER rule instead of lenient Number() (rejects
proxy-mangled hex/exponential/empty cursors).
- AcpHttpTransport: document that an unparseable non-empty data frame is a
corrupt frame (not a heartbeat); tracing it is a follow-up once the SDK
grows a logger (the package lint config forbids console).
- tests: add sse-last-event-id.test.ts (parseLastEventId accept/reject +
safeLogValue control-char stripping/truncation) and a
flushBufferedSessionFrames closed-stream-retains-buffer case.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(daemon,sdk): round-8 review hardening for /acp resumable stream
All additive / backward-compatible (REST untouched, no behavioural side
effects):
- AcpHttpTransport: attach a no-op catch to abortPromise so an
already-aborted signal at entry (loop never enters, Promise.race never
consumes the rejection) can't surface as an unhandled rejection.
- AcpHttpTransport: document that opts.maxQueued does not apply to the
/acp transport (the session stream is backed by the daemon's
server-controlled EventBus ring; there is no client-tunable queue to
forward it to) — intentionally ignored, not silently mis-applied.
- index: run err.message through logSafe() in the event-pump error log
(CR/LF/ANSI in a bridge error string would otherwise reach stderr raw),
and add operator breadcrumbs for the previously-silent onPumpSettled
branches (pump-ended-while-open full close; superseded-stream no-op),
completing the detach/reclaim/grace trail.
- tests: assert subscribeEvents writes Last-Event-ID on the outbound GET
when resuming and omits it on a first connect (the resume cursor must
reach the wire), plus an already-aborted-signal case that would fail on
an unhandled rejection without the catch above.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(daemon): flush deferred /acp replies on replay_complete only
The EventBus emits `state_resync_required` BEFORE the replay frames (the
`epoch_reset` and `ring_evicted` paths both fall through to the replay
loop and still emit `replay_complete` at the end). The pump was releasing
the deferred id-less replies on EITHER boundary, so on a resync-triggering
resume the buffered `session/prompt` result was flushed ahead of the
replayed content chunks — the exact truncated-body reordering §1.8 fixes
(client sees "done" before the body).
Flush on `replay_complete` only. The live-only case (no cursor ⇒ no replay
⇒ no `replay_complete`) is still covered by the pump's post-loop safety
flush. Add an over-the-wire integration test (resume with a reply buffered
during the detach gap, bridge replays resync → content → replay_complete)
asserting the reply lands AFTER the replayed content; verified it fails
against the previous dual-boundary flush. Design doc updated.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(daemon): close two §1.8 grace/replay-ordering holes
Both additive / in-scope / no REST change:
- Replay-window reply ordering (connection-registry, dispatch): the
resumptive-attach deferral only covered id-less replies ALREADY buffered
from the detach gap. A prompt that finished AFTER the new stream attached
but BEFORE replay drained went straight out live via `sendSession`,
overtaking replay frames not yet sent. Add a per-binding `replayPending`
flag (armed on resumptive attach, cleared on `replay_complete` in
`flushBufferedSessionFrames`) and route `replySession`'s out-of-band
replies through a new `sendSessionReply` that defers while it's set.
In-band pump frames keep using `sendSession`, so the `replay_complete`
frame itself can't be deferred (which would deadlock the release).
- Connection reaper vs session grace (index, connection-registry): the
conn-stream-close reaper treated only LIVE session streams as activity,
so a session detached into its own `SESSION_GRACE_MS` window (stream
undefined, graceTimer armed) didn't count — the connection could be
reaped at `CONN_GRACE_MS`, 404-ing the imminent session resume and
aborting the in-flight prompt early. Add `hasRecoverableSession()` and
treat a grace-armed session as activity in the reaper guard.
Unit tests for both at the registry layer.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(sdk): thread query params into ACP transport route extractors
The exported ACP HTTP/WS transports reduced request URLs to `pathname`
before the route table built JSON-RPC params, so every query parameter
from the REST-style DaemonClient helpers was dropped — e.g.
`readWorkspaceFile('a.ts', { maxBytes: 123 })` (`/file?path=a.ts&maxBytes=123`)
produced `_qwen/file/read` with `params: {}`. Same for `/file/bytes`,
`/stat`, `/list`, `/glob`, and `context-usage?detail=true`.
Pass `parsedUrl.searchParams` into `extractParams` and coerce each query
value to the type the daemon's ACP handlers require — the daemon validates
`maxBytes`/`line`/`limit`/`offset` as real numbers and `detail` as the
boolean `true`, neither of which a raw query string satisfies. Helpers
`strParam`/`numParam`/`boolParam` keep the per-route extractors terse.
`query` is optional so the existing path-only extractors are unaffected.
(`/workspace/voice/transcribe` has no ACP route at all — separate gap,
binary audio doesn't belong on the JSON-RPC transport.)
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(sdk): route session-stream replies via a background pump (no-subscriber prompt)
The daemon answers POST /session/:id/prompt (and session/cancel,
set_config_option, set_mode, set_model) with 202 and routes the JSON-RPC
result onto the SESSION stream via replySession — not the connection
stream the transport pumps. So a DaemonClient that calls prompt() but
never iterates subscribeEvents had nothing reading that reply, and
sendRequest()'s pending promise never resolved → prompt() hung forever.
For these session-reply methods, sendRequest now opens a reference-counted
background session-reply pump (GET /acp + Acp-Session-Id) that routes
JSON-RPC responses to `pending`, released when the request settles. It's
suppressed when a subscribeEvents consumer is already iterating that
session (tracked via activeSessionSubscriptions) — the daemon's session
stream is single-reader, so a competing GET would detach the consumer's;
in that case the consumer already routes the reply (the W2 fix). The pump
skips notifications and permission requests (method-bearing frames) so a
permission request id can't be mis-routed onto a pending response slot.
All five methods require an owned session, so the pump's GET is always
authorized. Disposed pumps are aborted in dispose().
Verified the new test times out without the pump and passes with it.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(serve): sequence /acp deferred replies by bus watermark + harden grace/reap
Address review on the §1.8 resumable-stream fixes:
- replayPending is now set from the current attach mode every time
(resume arms, fresh connect clears) so an aborted resume that skipped
its boundary flush can't strand the flag and buffer every later reply
forever (MsyIq, MylZ4).
- Deferred out-of-band replies carry a watermark (anchorId = bus head at
produce time) and release only once the pump delivers through that id,
via per-event releaseDeferredSessionReplies + endReplayDeferral at
replay_complete. A result produced during a slow replay no longer jumps
ahead of tail content still flowing as live events behind the boundary
(MsyIt). Unanchored fallback replies still release at the boundary.
- Connection reap re-evaluates after a session reclaim grace expires
(connGraceExpired + onSessionGraceExpired), so a conn blocked from
reaping by a then-recoverable session no longer lingers to the 30-min
idle sweep (MsyIs).
- Wrap the grace-timer teardown in try/catch so a throwing detach
callback can't crash the daemon from a bare setTimeout (MylZ8).
- sse-last-event-id reuses the shared logSafe sanitizer (covers C1 +
Unicode bidi) instead of a narrower divergent regex (M1isz); refresh
stale replayPending/flush JSDoc (MselO).
Unit tests cover the replayPending reset, watermark ordering, grace
expiry hook, and grace-timer try/catch.
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(sdk): scope pending sweeps per stream + reset bus cursor on invalid id
Address review on the ACP HTTP transport:
- Tag each pending request with its routing scope (connection vs a
sessionId). A connection-stream failure now sweeps only conn-scoped
pendings, so it can't reject a session/prompt the session stream is
about to resolve; the session reply pump mirrors this for its own
scope (MselM).
- An invalid id: line later in an SSE frame resets the bus cursor to
undefined rather than carrying a stale earlier value into the event
(MselW).
- Strengthen the W2 response-routing test to register a pending request
and assert the frame RESOLVES it, not merely that it isn't yielded
(MylZ-). Add tests for the per-stream sweep partition and the id reset.
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(sdk): accept `kind`-tagged _qwen/notify envelopes (don't drop resume signals)
The daemon's session-stream translateEvent stamps `_qwen/notify` events
under `kind` (state_resync_required, replay_complete, stream_error,
model_switched, …), but denormalizeAcpNotification read only `type` and
returned undefined for them — so subscribeEvents silently dropped every
such event. During a ring-overflow resume the SDK would never see
state_resync_required and would apply replayed events to stale state.
Read `params['type'] ?? params['kind']` (preferring `type`, so other
producers are unaffected) and add an SDK test feeding a `kind`-tagged
notify through subscribeEvents (M2bvl).
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(sdk): reject session-scoped pendings when the subscription stream closes
A `session/prompt` reply routed through an active subscribeEvents consumer
(no reply pump is started while a subscription is live) would hang if that
session SSE stream closed before the reply arrived: the connection-stream
catch only sweeps conn-scoped pendings, and subscribeEventsInner's finally
cleaned up the reader but never the pendings.
Sweep session-scoped pendings in that finally too, gated so it only fires
when this is the session's last delivery route (no other active
subscription — the ref-count still includes self here — and no reply pump),
mirroring the reply-pump and connection-stream sweeps. Add a regression
test (M2iHz).
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(sdk): harden ACP SSE readers + reply-pump handoff + empty query param
Address the latest review wave on the transport:
- pumpConnStream now bounds its unread SSE buffer with the same
MAX_SSE_BUF_CHARS guard the two session readers already have — the OOM
vector (a server that never emits a `\n\n` boundary) was open on 1 of 3
readers — and attaches the no-op abortPromise.catch() crash guard (M3BYQ).
- pumpSessionReplies mirrors subscribeEventsInner's abort handling: named
listener ref removed in finally (no leak on a clean drain of a reused
signal) + abortPromise.catch() so a pre-aborted signal can't surface an
unhandledrejection; and it throws the HTTP status on a non-OK response so
the failure is diagnosable rather than a silent void return (M3BYT, M3BYY).
- subscribeEvents aborts any existing background reply pump for the session
before opening the consumer stream. The single-reader session stream
detaches the pump anyway; aborting it skips its teardown sweep so it can't
spuriously reject the very `session/prompt` the consumer now delivers (M3BYa).
- numParam treats an empty value (`?maxBytes=`) as absent, not Number('')===0
(M3BYd).
Tests: empty-numeric-param omission, and the reply-pump abort-on-subscribe
handoff (pump aborted, its pending not rejected).
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(serve): log a breadcrumb when the replySession anchor is unavailable
The getSessionLastEventId fallback (deferring a reply unanchored when the
ACP binding briefly outlives the bridge session) was silent. Emit a scoped
stderr breadcrumb so an operator can tell that benign teardown race apart
from an unexpected bridge regression that starts exercising the fallback
(M3BYf).
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(sdk): validate content-type in pumpSessionReplies before parsing as SSE
pumpSessionReplies fed any 2xx body straight to the SSE frame parser. A
non-SSE response (an HTML error page / a JSON proxy error injected by a
CDN) would be consumed as garbage or hang the pump waiting for `data:`
lines that never arrive — strictly weaker validation than its sibling
subscribeEventsInner, which already guards content-type.
Mirror that guard: between the res.ok check and getReader(), reject a body
that isn't text/event-stream (cancelling it first). Add a test that a
no-subscriber session/prompt whose reply pump GET returns text/html
rejects instead of hanging (M3pAM).
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(sdk): close reply-pump handoff strand race + scope-guard reply resolution
Address the latest review wave:
- subscribeEvents now removes the aborted reply pump's map entry
SYNCHRONOUSLY, not just aborting it. Otherwise, if the subscription
exited before the pump's async `.finally` deleted the entry, BOTH
stranded-pending guards missed (the consumer sweep saw the entry still
present and deferred; the pump's sweep skipped on abort) — a live
session/prompt stayed in `pending` forever. Synchronous removal makes the
consumer sweep deterministically responsible (M3w6Y).
- Reply resolution (both the session-reply pump and the subscribeEvents
consumer path) now skips a reply whose pending is scoped to a DIFFERENT
session — defense-in-depth against a future daemon misroute silently
cross-delivering across the SDK boundary (M3w6d).
- denormalizeAcpNotification prefers a NON-EMPTY `type`; an empty-string
`type` no longer wins over a valid `kind` and drops the event (M3w6i).
Tests: reply-pump handoff happy-path (delivers/resolves) + strand case
(rejects, not stranded); empty-`type`→`kind` fallback; the SSE buffer cap
firing; the unanchored-reply hold/release branches; and connGraceExpired
reset on reconnect (M3w6e, M3w6f, M3w6g).
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(sdk): sweep session pendings in the subscribe wrapper + carry pump error
Two follow-ups on the reply-pump handoff:
- The session-scoped pending sweep moves from subscribeEventsInner's
read-loop finally to the subscribeEvents WRAPPER finally. The read-loop
finally only runs once the pump reaches the loop; a fast failure (fetch
reject / non-OK / wrong content-type, all before the loop) skipped it and
stranded the pending. The wrapper finally always runs, so it covers the
fast-fail path too (M4DWq).
- ensureSessionReplyPump captures the pump's error (HTTP 401/404, wrong
content-type) and rejects swept pendings WITH it instead of a generic
message, so a caller can tell auth failure from a network drop (M4DWx).
Test: a 401 on the session GET (inner throws before its read loop) still
rejects the in-flight session-scoped pending via the wrapper sweep.
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(sdk): carry the subscription error into the wrapper sweep + guard tests
Follow-ups on the reply-pump handoff:
- The subscribeEvents wrapper sweep now rejects with the actual cause of the
subscription's exit (captured from a try/catch around the inner generator)
instead of a hard-coded generic message. On the fast-fail path (401 /
wrong content-type thrown before the inner read loop) this wrapper finally
is the only sweep that fires, so the caller now sees the real failure —
parity with the reply-pump's pumpError reason (M4W9a).
Tests:
- the fast-fail sweep reason carries the 401 (not a generic message);
- the M3pAM non-SSE rejection asserts the content-type cause reaches the
caller (proves pumpError propagation) (M4W9g);
- cross-session scope guard, both the consumer and the reply-pump
resolution paths: a reply on session A's stream must not resolve a pending
scoped to session B (M4W9e).
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(serve): guard onSessionGraceExpired in the grace timer against an uncaught throw
The session grace-expiry setTimeout protected closeSessionStream with a
try/catch but called the owner-supplied onSessionGraceExpired callback
outside it. From a bare setTimeout, an uncaught throw there would crash the
whole daemon — the same hazard the teardown guard exists for. Wrap it in
its own try/catch (separate from teardown, so the conn-reap re-check still
runs even if teardown threw). Add a regression test (M4i9z).
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(sdk): make conn-stream pump CRLF-aware; align replay opt-in doc
The connection-scoped SSE reply pump split frames with an LF-only
`buf.indexOf('\n\n')`. A server or proxy emitting `\r\n\r\n` frame
separators produces no `\n\n` substring, so the scan never found a
boundary: the unread buffer grew to the OOM cap and the pump threw,
leaving every connection-scoped JSON-RPC reply unresolved. Reuse the
shared CRLF-aware `consumeFrames` splitter (and strip a trailing CR
per data line) so the conn pump frames exactly like the session
readers. Add a regression test that delivers a conn-scoped reply over
`\r\n\r\n` and asserts it resolves.
Also update the design doc: the in-repo SDK `AcpHttpTransport` opts in
to replay in this PR (`supportsReplay = true` + resends Last-Event-ID),
so the backward-compat note no longer reads as "keeps false until it
opts in". Only the external agent-web transport flip stays deferred
(already listed under Out of scope).
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(daemon,sdk): log replay-deferral arm; document session-reply routing invariant
Add a stderr breadcrumb when a resume arms `replayPending`: while armed,
`sendSessionReply` defers every out-of-band reply until the pump delivers
`replay_complete`. If that sentinel never arrives (a dropped frame or a
pump error), the replies stay buffered indefinitely with no other trace —
the log gives operators a starting point. Silent on a fresh connect (no
deferral). Covered by a new test.
Also strengthen the `SESSION_STREAM_REPLY_METHODS` doc comment: name the
authoritative daemon call sites (dispatch.ts), spell out the hang failure
mode if the set drifts, and record a build-time grep / shared-constant
enforcement as a follow-up (a cross-package invariant the SDK can't type-check).
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(sdk): use bracket notation for _meta index-signature access (TS4111)
`extractParams` returns `Record<string, unknown>`, so dot access to
`params._meta` violates `noPropertyAccessFromIndexSignature` (set in the
root tsconfig). The esbuild bundle path doesn't typecheck, so CI's build
stayed green, but strict `tsc --noEmit` reports 6 × TS4111 at these sites
(added with the query-param routing change). Switch all six to
`params['_meta']`. Purely syntactic — runtime behavior is unchanged.
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(sdk): don't silently hang conn-scoped requests on a failed conn stream
`pumpConnStream` swallowed two failure paths: a non-2xx / no-body `GET
/acp` did a bare `return`, and read-loop errors were caught and dropped.
Either way the pump promise RESOLVED, so `openConnStream`'s `.catch`
never ran — connection-scoped JSON-RPC pendings stayed in the map
forever, and `connStreamAbort` was never cleared, so `ensureConnStream`
saw it non-null and never reopened the stream (every later 202 request
hung with no pump to deliver its reply).
- A non-2xx / missing-body response now throws (HTTP status in the
message) so the catch sweep rejects the conn-scoped pendings.
- The read-loop catch rethrows real errors and only swallows an
intentional abort (dispose / reconnect, which owns its own cleanup).
- `openConnStream` clears `connStreamAbort` in a `.finally` (guarded on
controller identity) so the stream reopens on the next request after
ANY settle — clean close, error, or abort.
Regression test: a 500 `GET /acp` rejects the conn-scoped pending (leaves
session-scoped ones for their own stream) and the next request reopens.
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(daemon,sdk): conn-stream error propagation, listener cleanup, buffer eviction, param parsing
Address review findings on the resumable /acp stream and exported SDK
transports — all additive/backward-compatible, REST untouched:
- openConnStream: reject connection-scoped pendings with the pump's REAL
error (HTTP 401/503, network drop) instead of a generic message, mirroring
ensureSessionReplyPump.
- pumpConnStream: keep the abort listener in a named ref and remove it in
finally so a long-lived signal reused across reconnects doesn't accumulate
listeners (mirrors the session readers).
- sendRequest: remove the abort listener on the happy path (the `{ once: true }`
listener self-removes only when the signal fires), preventing per-call
listener buildup on a shared caller signal.
- pushCapped: under a content flood, evict a REPLAYABLE id-bearing frame (the
ring redelivers it) before an irreplaceable id-less deferred reply — dropping
the latter would hang the session/prompt caller — and log the dropped id.
- acpRouteTable.boolParam: treat a present-but-empty value (`?detail=`) as
absent, matching numParam, so `{ detail: false }` isn't forwarded for an
unset param.
- connection-registry resume flush: hoist the `splice(0)` snapshot into a named
local to make the re-entrant copy-semantics invariant visible.
Tests: boolParam empty-value omission; pre-attach buffer keeps the id-less
reply under a 400-frame content flood. Document two exported-transport
limitations (permission voting; session RPC awaited inside the subscribeEvents
loop) as §1.7-adjacent follow-ups in the design doc.
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(daemon): never evict an irreplaceable id-less reply from the pre-attach buffer
The previous pushCapped change preferred evicting id-bearing (ring-replayable)
frames, but left a degenerate hole: when the buffer fills with ONLY id-less
deferred replies (no id-bearing entry exists), findIndex returned -1, dropIndex
fell back to 0, and the oldest deferred JSON-RPC reply was evicted — silently
hanging its session/prompt caller, the exact failure the guard exists to
prevent (wenshao).
Fix: when there is no replayable id-bearing frame to evict, do NOT drop —
append and let the id-less replies exceed the soft cap. The cap is a memory
bound against a CONTENT flood (id-bearing frames); id-less replies are bounded
by the number of in-flight session RPCs the client actually issued
(client-controlled, tiny), so they can't run away in practice. Log once when
over the soft cap. The connection buffer (no id accessor) keeps its FIFO
eviction unchanged.
Test: 300 all-id-less replies buffered past the 256 cap are all delivered on
reconnect, none evicted.
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(daemon): hard ceiling + transition-only logging for the id-less reply buffer
Follow-ups on the prior id-less-eviction fix (wenshao):
- Defense-in-depth HARD cap. The soft-cap path never drops id-less replies,
relying on "id-less replies are RPC-bounded" — true today but enforced only
by convention. Add HARD_BUFFERED_FRAMES_CAP (4× soft = 1024): past it, drop
the oldest id-less reply and log loudly, so a future non-RPC-bounded producer
or a buggy client can't grow the daemon heap without limit.
- Log at the soft-cap transition only (buf.length === MAX_BUFFERED_FRAMES), not
on every over-cap push — the comment said "once" but it logged linearly with
over-cap depth (~44 lines for 300 entries).
Tests: assert the soft-cap warning fires exactly once for a 300-entry overflow;
new test that 1100 id-less replies are bounded at the 1024 hard cap (oldest
dropped, newest kept, loud breach log emitted).
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(daemon,sdk): release all deferred replies when replay evicted frames; guard conn pump against session-scoped pending
When ring replay overflows and emits state_resync_required, the watermark
anchor guarantee is void (the anchored frame may have been evicted), so
hold-until-watermark could freeze deferred session replies indefinitely.
Track eviction through the pump loop and flush ALL buffered session frames
at replay_complete in that case instead of waiting on the watermark.
Also harden the SDK conn-stream pump: never resolve a session-scoped pending
entry from the connection stream (scope guard), and document the fresh-attach
(non-resumptive) caveat for ensureSessionReplyPump.
Tests: add FakeBridge.getSessionLastEventId so integration replySession no
longer throws (anchorId now reachable); cover the eviction cascade-release
path, the conn-stream session-scope guard, and shared reply-pump ref-counting.
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(daemon): flush deferred replies on mid-replay iterator error; cover anchored watermark e2e
On an iterator error mid-replay the catch path re-throws, which drives
onPumpSettled; while the session stream is still open that takes the
closeSessionStream branch (full teardown, not a detach-with-grace), so any
still-deferred session replies in the binding buffer were dropped rather than
preserved. Flush them in the catch before signalling stream_error — same
safety flush as the happy-path completion (the iterator has terminated, so no
content frame can still race ahead of them). Correct the now-inaccurate
happy-path comment that claimed error-path frames stay buffered.
Add an end-to-end transport test for the anchored watermark path: with a real
getSessionLastEventId, a deferred reply is held through pre-watermark content
and released ON its anchor mid-replay, before replay_complete — distinguishing
the watermark release from the unanchored release-at-boundary path.
Document two deferrals in the design doc: response-replay idempotency for an
already-resolved permission (a conformant client dedupes on _meta.requestId;
full re-send belongs with the permission-coordination follow-up) and an
automated guard for the SESSION_STREAM_REPLY_METHODS drift.
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* feat(daemon,sdk): log replay effectiveness; de-shadow pump sweep; document resume/permission edges
Add an operator breadcrumb at replay completion (resumed-from cursor, delivery
high-water mark, bus replayed count, eviction flag) so 'did resume recover the
gap?' is answerable from server logs.
Rename the reply-pump sweep loop variable so it no longer shadows the outer
pump-map entry (unrelated types).
Clarify why the resume path drops id-bearing buffered frames (the event pump is
aborted on detach, so only id-less out-of-band replies accumulate during the
gap; ring replay owns id-bearing recovery and eviction is signalled via
state_resync_required). Document two opt-in-transport edges as
permission-coordination follow-ups: the no-subscriber reply pump's GET stream
causing an agent permission_request to be routed to the pump and dropped, and
why an automated SESSION_STREAM_REPLY_METHODS drift guard needs dataflow (the
prompt reply is decoupled from its case block).
Generated with AI
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
---------
Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
|
||
|---|---|---|
| .. | ||
| README.md | ||
| sse-resumable-stream.md | ||
Daemon ACP-over-HTTP → Official ACP Streamable HTTP Transport
Targets
daemon_mode_b_main. Branch:feat/daemon-acp-http-streamable. Author: arnoo.gao. Date: 2026-05-24. Status: Design v1 → implementation. Design-first per repo workflow: this doc lands before/with the implementation PR so the wire contract is reviewable.
0. TL;DR
The daemon (qwen serve) today speaks a bespoke REST + SSE dialect to web/SDK
clients, while speaking real ACP JSON-RPC over stdio to the spawned qwen --acp
child. This proposal adds a second northbound transport that implements the
official ACP Streamable HTTP transport (RFD #721) at a single /acp endpoint,
so any ACP-native client (Zed, Goose, future SDKs) can drive the daemon directly
over the standard protocol — no qwen-specific REST knowledge required.
Decision: dual-transport, additive. The new /acp endpoint is mounted
alongside the existing REST surface, reusing the same HttpAcpBridge +
EventBus underneath. The REST API is not removed. Rationale in §6.
Decision: extension namespace = _qwen/… (single-underscore prefix, the
ACP-spec-reserved form for custom methods) for daemon features that have no
standard ACP method (model switch, workspace introspection, heartbeat,
multi-client permission policy, SSE backpressure tuning). Rationale in §5.
A complete, locally-runnable reference implementation ships in this PR
(packages/cli/src/serve/acp-http/) plus a verification harness
(scripts/acp-http-smoke.mjs).
1. Background — what "ACP over HTTP" means today
Three tiers (verified at commit 0c0430939):
┌──────────────┐ bespoke REST + SSE (HTTP/1.1) ┌────────────┐ ACP JSON-RPC ┌──────────────┐
│ web / SDK │ ───────────────────────────────► │ qwen │ (stdio NDJSON) │ qwen --acp │
│ client │ ◄─── GET /session/:id/events ──── │ serve │ ◄─────────────► │ child (Agent)│
│ (ACP client) │ (text/event-stream) │ (daemon) │ ndJsonStream │ │
└──────────────┘ └────────────┘ └──────────────┘
northbound: NOT ACP wire bridge southbound: real ACP
1.1 Northbound (client ↔ daemon) — bespoke, today
- Express 5 app in
packages/cli/src/serve/server.ts(~30 routes). - Discrete REST verbs, not JSON-RPC:
POST /session(create),POST /session/:id/prompt,POST /session/:id/cancel,POST /session/:id/load|resume,POST /session/:id/model,POST /session/:id/permission/:requestId,POST /session/:id/heartbeat,DELETE /session/:id, plus/workspace/*,/capabilities,/health.
- Server→client streaming:
GET /session/:id/events→text/event-stream.- Frames:
id: <n>\nevent: <type>\ndata: <json>\n\n(server.ts:formatSseFrame, ~2626). - Per-session monotonic
id+Last-Event-IDresume backed by a ring-bufferEventBus(acp-bridge/src/eventBus.ts). - Event
types:session_update,client_evicted,slow_client_warning,state_resync_required,stream_error, …
- Frames:
- Auth:
Authorization: Bearer <token>(serve/auth.ts), CORS deny + host allowlist. - Backpressure: per-connection serialized write chain + 15 s heartbeat comments.
1.2 Southbound (daemon ↔ child) — already ACP
acp-bridge/src/spawnChannel.tsspawnsqwen --acp, wraps stdin/stdout withndJsonStreamfrom@agentclientprotocol/sdk(^0.14.1).acp-bridge/src/bridge.ts:729new ClientSideConnection(() => client, channel.stream)— the daemon is the ACP client, the child is the ACP agent.- Extension methods already in use on this leg:
unstable_setSessionModel,unstable_resumeSession,unstable_listSessions(acp-integration/acpAgent.ts).
1.3 Why migrate the northbound
- Every client (webui, TS SDK, Java SDK, Python SDK, VSCode companion) re-implements the bespoke REST mapping. An ACP-standard endpoint lets ACP-native editors attach with zero qwen-specific glue.
- Aligns the daemon's remote surface with the protocol it already speaks internally.
2. Target: ACP Streamable HTTP (RFD #721)
Merged Draft RFD (agentclientprotocol/agent-client-protocol#721, merged 2026-04-22).
Not yet normative; not yet in any SDK. We implement against the RFD wire design.
2.1 Endpoint & verbs (single /acp)
| Verb | Behavior |
|---|---|
POST /acp |
Send JSON-RPC. initialize → 200 + JSON body (capabilities) and sets Acp-Connection-Id. All other requests/notifications → 202 Accepted, empty body; the response (if any) is delivered on the matching long-lived SSE stream. |
GET /acp |
Open a long-lived SSE stream. (Upgrade: websocket → WebSocket; deferred, see §7.) |
DELETE /acp |
Terminate the connection → 202. |
2.2 Two-tier long-lived streams
- Connection-scoped stream:
GET /acpwith headerAcp-Connection-Id, no session header. Carries connection-level responses (session/new,session/load,authenticate) and connection-level notifications. - Session-scoped stream:
GET /acpwithAcp-Connection-IdandAcp-Session-Id. Carriessession/updatenotifications, agent→client requests (session/request_permission,fs/read_text_file, …), and responses to session POSTs (session/prompt,session/cancel).
2.3 Identity (3 layers)
Acp-Connection-Id(HTTP header) — transport binding, minted atinitialize.Acp-Session-Id(HTTP header) — required on session-scoped GET + session POSTs.sessionId(JSON-RPC param) — inside method params (must match the header).
2.4 Divergences from MCP StreamableHTTP
ACP uses long-lived streams (not per-request SSE), two ID headers (connection
vs session), 202-for-non-initialize, HTTP/2-required, WebSocket-required-client. We
borrow the single-endpoint + POST/GET-SSE + session-header skeleton but adapt to the
long-lived dual-ID model. We do not reuse @modelcontextprotocol/sdk's
StreamableHTTPServerTransport (its per-request stream model and single
Mcp-Session-Id don't fit).
2.5 Standard methods (confirmed from current schema)
- Client→Agent requests:
initialize,authenticate,session/new,session/load,session/prompt,session/resume,session/close,session/list,session/set_mode,session/set_config_option,logout. - Client→Agent notification:
session/cancel. - Agent→Client requests:
fs/read_text_file,fs/write_text_file,session/request_permission,terminal/create|output|wait_for_exit|kill|release. - Agent→Client notification:
session/update.
3. Architecture of the new transport
The daemon must present an ACP Agent surface over HTTP northbound, while it
remains an ACP client to the child southbound. The /acp layer is therefore a
JSON-RPC router that terminates the HTTP transport and bridges into the existing
HttpAcpBridge.
POST /acp (JSON-RPC requests/responses/notifs)
client ──────────────────────────────────────────────► ┌───────────────────────────┐
(editor) │ AcpHttpTransport │
◄── GET /acp (connection-scoped SSE) ────────── │ - connection registry │
◄── GET /acp (session-scoped SSE) ───────────── │ - JSON-RPC id correlation│
│ - method dispatch │
└────────────┬──────────────┘
│ reuses
┌────────────▼──────────────┐
│ HttpAcpBridge + EventBus │ (unchanged)
└────────────┬──────────────┘
│ ACP stdio (unchanged)
qwen --acp child
3.1 New module layout (packages/cli/src/serve/acp-http/)
| File | Responsibility |
|---|---|
index.ts |
mountAcpHttp(app, bridge, opts) — registers /acp routes on the existing Express app. |
connection-registry.ts |
Acp-Connection-Id → AcpConnection (connection SSE writer, Map<sessionId, SessionStream>, pending agent→client requests by JSON-RPC id, monotonic id allocator). TTL + DELETE cleanup. |
json-rpc.ts |
JSON-RPC 2.0 parse/validate/serialize helpers; error codes (-32600 etc.); _qwen/ namespace guard. |
dispatch.ts |
Maps inbound JSON-RPC methods → HttpAcpBridge calls. Maps BridgeEvents → outbound JSON-RPC frames. The translation table (§4). |
sse-stream.ts |
Long-lived SSE writer (reuses the backpressure/heartbeat pattern from server.ts). Distinct from REST /events (different framing: full JSON-RPC objects, not qwen event envelopes). |
No change to bridge.ts / eventBus.ts (additive consumer only).
3.2 Connection & session lifecycle
POST /acp {initialize}→ mintconnectionId, createAcpConnection, reply200with{protocolVersion, agentCapabilities, _meta:{qwen:{…}}}+Acp-Connection-Idheader.- Client opens
GET /acp(connection-scoped) carryingAcp-Connection-Id. POST /acp {session/new}→202; daemon callsbridge.createSession(...); pushes the JSON-RPC response (withsessionId) down the connection stream.- Client opens
GET /acp(session-scoped) withAcp-Connection-Id+Acp-Session-Id; daemonbridge.subscribeEvents(sessionId)and pipes translated frames. POST /acp {session/prompt}→202;bridge.sendPrompt(...);session/updatenotifications stream live on the session stream; the final prompt response ({id, result:{stopReason}}) is pushed on the session stream when it settles.- Agent→client request (e.g.
session/request_permission) is emitted as a JSON-RPC request on the session stream with a daemon-allocated id; the client answers viaPOST /acp {id, result};dispatchresolves it through the bridge's permission API. DELETE /acp(or connection-stream close + TTL) tears down sessions/subscriptions.
4. Translation table (bridge ⇄ ACP/HTTP)
4.1 Inbound (client POST → bridge)
| ACP method | Bridge call | Response routed to | |
|---|---|---|---|
initialize |
(none; capabilities from capabilities.ts) |
inline 200 |
|
authenticate |
existing auth provider (serve/auth/*) |
connection stream | |
session/new |
bridge.createSession |
connection stream | |
session/load / session/resume |
`bridge.restoreSession('load' | 'resume')` | connection stream |
session/prompt |
bridge.sendPrompt |
session stream (deferred until settle) | |
session/cancel (notif) |
bridge.cancel |
— | |
session/list |
bridge.listSessions (unstable_listSessions) |
connection stream | |
session/set_mode |
approval-mode route logic | session stream | |
| JSON-RPC response (to agent→client req) | resolve pending (§4.3) |
— | |
_qwen/session/set_model |
bridge.setSessionModel (unstable_setSessionModel) |
session stream | |
_qwen/workspace/list etc. |
workspace introspection routes | connection stream | |
_qwen/session/heartbeat |
bridge.heartbeat |
connection stream |
4.2 Outbound (BridgeEvent → JSON-RPC on session stream)
| BridgeEvent.type | Emitted as |
|---|---|
session_update |
{method:"session/update", params:<data>} notification |
| permission request | {id:<n>, method:"session/request_permission", params} request |
client_evicted / slow_client_warning / state_resync_required |
{method:"_qwen/notify", params:{kind,…}} notification |
stream_error |
JSON-RPC error response on the active prompt id (or _qwen/notify) |
| prompt settle | {id:<promptId>, result:{stopReason}} |
4.3 Pending agent→client requests
AcpConnection keeps Map<jsonRpcId, {sessionId, kind, bridgeRequestId, resolve}>.
When the client POSTs a JSON-RPC response object, dispatch matches id, then calls the
bridge resolution path (e.g. permission POST /session/:id/permission/:requestId
internal equivalent).
v1 status: only the
session/request_permissionagent→client round-trip is implemented.fs/*andterminal/*agent→client forwarding is deferred (§7) — the daemon does not yet advertisefs/terminalclient-capability negotiation on/acp, so ACP clients should not assume filesystem/terminal semantics over this transport in v1. The intended end state (forwardfs/*to the client; fall back to the daemon's workspace FS when the client lacks thefscapability) is the follow-up described in §7.
5. Extension strategy (requirement #2)
ACP reserves any method starting with _ for custom extensions and provides _meta
on every type. The codebase's southbound leg already uses unstable_* method names.
Northbound choice: vendor-namespaced _qwen/<area>/<verb> method names
(spec-compliant _ prefix). Capabilities advertised under
agentCapabilities._meta.qwen at initialize so clients feature-detect before use.
| Need | No standard ACP method? | Extension |
|---|---|---|
| Model switch | yes | _qwen/session/set_model |
| Workspace MCP/skills/providers/env introspection | yes | _qwen/workspace/list, _qwen/workspace/<area> |
| Heartbeat / last-seen | yes | _qwen/session/heartbeat |
| Multi-client permission policy (consensus/designated) | partial | session/request_permission + _meta.qwen.policy |
SSE backpressure tuning (maxQueued) |
yes | Acp-Qwen-Max-Queued header on session GET |
Resume cursor (ring Last-Event-ID) |
RFD Phase 4 | Last-Event-ID header + _meta.qwen.eventId on frames |
Standard methods are never renamed; extensions are strictly additive and ignorable.
6. Dual-transport vs. replace (requirement #4)
Decision: dual-transport (additive).
- The official transport is a Draft RFD, not normative, and absent from every SDK — hard-replacing would couple us to an unratified design and break webui + 3 SDKs + VSCode companion at once.
- The REST surface carries features with no clean ACP mapping yet (workspace
introspection, multi-client permission mediation, ring-buffer resume, capability
registry). Those degrade to
_qwen/*extensions on/acpbut the REST surface stays authoritative until the RFD ratifies. - Both transports share one
HttpAcpBridge+EventBusinstance, so there is no state duplication —/acpand/session/*can even drive the same live session concurrently (multi-client is already supported by the bridge). - Toggle (v1, shipped): on by default;
QWEN_SERVE_ACP_HTTP=0disables the mount. A--no-acp-httpCLI flag and anacp_httptag in/capabilitiesfor client feature- detection are deferred to a follow-up (not in v1) — until then clients detect the transport by probingPOST /acp {initialize}.
Migration path: once the RFD ratifies and SDKs ship, REST routes can be reframed as a
thin compat shim over /acp (separate, later PR).
7. Scope of the implementation PR
In scope (runnable + verified locally):
POST /acpdispatch forinitialize,session/new,session/prompt,session/cancel,session/load, JSON-RPC response handling.- Connection-scoped + session-scoped
GET /acpSSE streams with JSON-RPC framing. session/updatestreaming + final prompt response correlation.session/request_permissionagent→client round-trip._qwen/session/set_modelextension as the worked example of #2.- Bearer-auth + host allowlist reuse (same middleware as REST).
- Unit tests (
acp-http/*.test.ts) + a black-box smoke script driving a real daemon.
Deferred (documented, not built now):
- WebSocket upgrade path (RFD-required client cap; SSE suffices for local verify).
- HTTP/2 multiplexing (we run HTTP/1.1; POST and long-lived GET use separate sockets, which works for CLI/Node clients and ≤6-connection browsers). Documented divergence.
- Full
fs/*+terminal/*agent→client forwarding (permission path proves the mechanism; rest is mechanical follow-up). - SSE resumability hardening parity with the ring buffer (Phase 4 in RFD).
8. Local verification plan
npm run build(or workspace build ofcli+acp-bridge).- Start daemon:
qwen serve --listen 127.0.0.1:0 --token <t>(or env token). - Run
node scripts/acp-http-smoke.mjs:POST /acp {initialize}→ assert200+Acp-Connection-Id.- Open connection SSE;
POST {session/new}→ assert response on stream. - Open session SSE;
POST {session/prompt:"say hi"}→ assert ≥1session/updatethen a final{result:{stopReason}}. - Trigger a tool needing permission → assert
session/request_permissionrequest, POST a grant response → assert prompt completes. POST {_qwen/session/set_model}→ assert model switch +session/update.
- Vitest:
acp-http/*.test.tsgreen.
9. Risks
| Risk | Mitigation |
|---|---|
| RFD changes before ratification | Behind capability tag + _qwen namespace; isolated module; easy to revise. |
| HTTP/1.1 vs required HTTP/2 | Localhost/CLI clients unaffected; documented; h2 is a transport swap later. |
| Two transports on one bridge race | Bridge already supports multi-client; reuse its locking. |
fs/* forwarding vs daemon-local FS |
Capability-gated: forward when client declares fs, else local. |
10. Implementation & verification log (v1)
Implemented in packages/cli/src/serve/acp-http/ (json-rpc.ts, sse-stream.ts,
connection-registry.ts, dispatch.ts, index.ts), mounted from server.ts
via mountAcpHttp(app, bridge, { boundWorkspace }).
Automated (packages/cli/src/serve/acp-http/*.test.ts)
transport.test.ts boots a real Express server + the real mountAcpHttp over
a controllable fake bridge and drives it with fetch + manual SSE parsing.
15 tests green, covering: initialize 200 + Acp-Connection-Id; unknown-conn
400; session/new reply on the connection stream; prompt → session/update
stream + final result correlation; session/request_permission agent→client→
agent round-trip; _qwen/session/set_model; method-not-found; DELETE teardown.
Live daemon (real model)
Booted qwen serve --port 8767 --token … --workspace … (bundle entry so the
spawned qwen --acp child is self-contained) and ran scripts/acp-http-smoke.mjs:
✓ initialize: connectionId=… protocolVersion=1
✓ session/new: sessionId=…
→ prompt: "Reply with the single word: pong"
pong
✓ prompt complete: 10 session/update frames, stopReason=end_turn
✓ DELETE /acp — connection closed
ALL CHECKS PASSED ✅
Error-path was also confirmed live: when the child failed to start, the bridge
timeout surfaced to the client as a JSON-RPC error frame on the connection
stream ({"id":2,"error":{"code":-32603,…}}), proving id-correlation + the
202/SSE split under failure.
Review fold-in — bridge-issued clientId (found in live verify)
First live run failed session/prompt with "client id … is not registered for
session". Root cause: spawnOrAttach/loadSession ignore a caller-supplied
clientId the bridge has never issued and stamp a fresh one (returned in
BridgeSession.clientId); the dispatcher was echoing the connection's own
(unregistered) id on sendPrompt. Fix: persist the bridge-stamped id on the
SessionBinding and echo it on every per-session call (sessionCtx). Re-verified
green above.
11. Review round 2 — fold-ins
Two independent reviews (correctness/concurrency + protocol-conformance/security) plus a self-read.
All fixes verified by the expanded vitest suite (18 tests) + a fresh live smoke run
(21 session/update frames → stopReason=end_turn).
| # | Severity | Finding | Fix |
|---|---|---|---|
| R1 | P0 | Session-stream reconnect was permanently dead: SessionBinding.abort was created once and reused; on stream close it was aborted forever, so a reconnect's subscribeEvents(signal) got an already-aborted signal and received zero events. |
attachSessionStream now installs a fresh AbortController per stream (and closes any prior stream); index.ts pumps on that fresh signal. |
| R2 | P0 | await dispatcher.handle() ran after res.end(202); a throwing bridge call (notably the un-try/caught isResponse path) would reject and surface as an unhandled rejection → possible daemon crash. |
Wrapped the isResponse path in try/catch; .catch() on the awaited handle(...) and on pumpSessionEvents(...). |
| R3 | P1 | No connection→session ownership: any authenticated connection could open the session SSE for, or prompt, any sessionId in the workspace (read-eavesdrop; prompt was only blocked incidentally by the unregistered-clientId error). | AcpConnection.ownedSessions populated by session/new/load/resume; session stream returns 403 and per-session POSTs return INVALID_PARAMS for unowned ids (requireOwned). |
| R4 | P1 | mountAcpHttp handle was discarded → TTL sweep timer + live SSE streams leaked on shutdown. |
Handle parked on app.locals; runQwenServe close hook calls dispose() before bridge.shutdown() (mirrors the device-flow registry). |
| R5 | P1 | Pending permission leak: closing a session/connection with a permission outstanding left the bridge blocked awaiting a vote. | closeSessionStream/destroy cancel matching pending requests via an injected onAbandonPending → cancelAbandonedPermission. |
| R6 | P1 | Pre-attach frame buffers (connBuffer/binding.buffer) were unbounded. |
Capped at 256 frames (drop-oldest), matching the EventBus maxQueued. |
| R7 | P2 | initialize ignored the client's requested protocolVersion. |
Negotiates min(requested, 1). |
| R8 | P2 | No Acp-Session-Id ↔ params.sessionId cross-check (RFD §2.3). |
POST asserts they agree; mismatch → INVALID_PARAMS. |
| R9 | P2 | session/cancel request-form (with id) never answered; duplicate top-level _meta.qwen. |
Reply when an id is present; single agentCapabilities._meta.qwen. |
Accepted / documented (not fixed in v1)
- Prompt-result vs trailing
session/updateordering (P2):handlePromptawaitssendPromptthen writes the result frame, while updates stream concurrently. In practice the bridge publishes allsession/updates to the bus beforesendPromptresolves and both share one ordered SSE write chain, so the result lands last (confirmed: 21 updates then result). A strict barrier is a possible later hardening if a client reducer proves sensitive. - Browser
EventSourcecan't setAuthorization—/acpGET streams require the bearer header, so browsers need the deferred WebSocket path (§7); CLI/Node clients are unaffected. - The daemon's real trust boundary remains the bearer token + single-workspace bind (same as the REST surface); R3's ownership check is defense-in-depth + contract correctness, not a tenant boundary.
12. Review round 3 — PR bot fold-ins (#4472)
Two automated PR reviewers plus the summary bot.
All fixes verified by the suite (now 22 tests) + a fresh live run (16 session/update → end_turn).
| # | Severity | Finding | Fix | ||
|---|---|---|---|---|---|
| B1 | P0 | handlePrompt's AbortController was never aborted — a disconnecting/cancelling client left the agent running (burned model quota, blocked the session FIFO). Flagged by both bots + 5 sub-agents. |
promptAbort parked on SessionBinding; aborted by session/cancel and by session/connection teardown (closeSessionStream/destroy). |
||
| B2 | P0 | sessionCtx missing fromLoopback → every ACP permission vote treated as remote; local-only policy would reject loopback clients. |
Capture loopback at initialize (kernel remoteAddress, not forgeable headers) → AcpConnection.fromLoopback → threaded through sessionCtx. |
||
| B3 | P0 | SSE write failures silently swallowed → zombie streams (heartbeats fire, zero events delivered, no logs). | First write failure logs + closes the stream. | ||
| B4 | P0 | Idle sweep destroyed connections with no log + no connection cap (initialize-flood). | Sweep logs each reap; pumpSessionEvents calls touch() (long quiet prompts aren't reaped); maxConnections cap (64) → 503. |
||
| B5 | P1 | sessionCtx silently fell back to the connection's unregistered clientId when the binding lacked one (untested, always-fired in FakeBridge). |
Throw on missing stamped clientId (invariant violation); FakeBridge now stamps one. |
||
| B6 | P1 | `session/new | load | resumeacceptedcwd` unvalidated (REST validates string/length/absolute — amplification DoS). |
Shared parseOptionalWorkspaceCwd (string, ≤4096, absolute). |
| B7 | P1 | session/prompt forwarded an unvalidated prompt to the bridge. |
validatePrompt (non-empty array of objects), mirroring REST. |
||
| B8 | P1 | Raw bridge error messages echoed to the client. | toRpcError maps known bridge errors to coded, client-safe shapes; unknown → generic Internal error (full detail still to stderr). |
||
| B9 | P1 | nextId used sequential negatives — a client legally using negative ids could collide in pending. |
Daemon-originated ids are now strings (_qwen_perm_N), disjoint from any client id. |
||
| B10 | P2 | resolveClientResponse param type excluded JsonRpcError; conn-scoped SSE stream had no onClose; DELETE with no header was a silent 202; SseStream.close ran onClose outside try/catch; session/load·resume·close untested. |
Widened param to JsonRpcResponse; conn stream logs on close; DELETE missing header → 400; onClose wrapped in try/catch; added load/resume/close + DELETE-400 tests. |
Out of scope (base-branch daemon_mode_b_main, not this diff) — the second reviewer flagged
typecheck errors in acpAgent.ts (entryCount/entrySummary/sessionClose) and other pre-existing
items it explicitly attributed to the base branch (introduced by #4353). Tracked separately; not
touched here.
Still deferred (documented): per-connection secret for DELETE/connection ownership (token remains
the boundary); WebSocket + HTTP/2 (§7); strict prompt-result vs trailing-update barrier (§11).
13. Review round 4 — PR fold-ins (rebased onto #4469)
Branch rebased onto daemon_mode_b_main (#4353 + #4469) — clean, no conflicts. Two PR
reviewers (GPT-5 + qwen3.7-max). Suite now 25 tests; live re-verified (125 session/update
→ end_turn).
| # | Severity | Finding | Fix |
|---|---|---|---|
| C1 | P0 | Round-3 "SSE write-failure handling" was documented but NOT implemented — SseStream still left it to discarding callers (zombie streams). |
writeRaw now owns it: first write rejection logs once + close()s; doWrite also listens for 'error' (rejects promptly instead of hanging to 'close'); onClose wrapped in try/catch. |
| C2 | P1 | fromLoopback captured only at initialize + helper narrower than REST → local-only votes from a later POST misjudged. |
Per-request loopback threaded through handle→sessionCtx/resolveClientResponse; isLoopbackReq widened to 127.0.0.0/8 + ::ffff:127.* + ::1 (matches REST). |
| C3 | P1 | Error routing inferred stream from params.sessionId → conn-scoped method failures (session/load/resume/close/heartbeat) misrouted to a non-existent session stream (silent loss). |
CONN_ROUTED_METHODS set; errors route the same way as the success path. |
| C4 | P1 | bridge.detachClient never called on teardown → stale bridge-stamped client ids linger in knownClientIds()/voter sets. |
Registry takes a DetachSessionFn; closeSessionStream/destroy detach each owned session (best-effort). |
| C5 | P1 | session/close skipped local cleanup if bridge.closeSession threw. |
closeSessionStream moved into a finally. |
| C6 | P2 | Windows cwd (C:\…) rejected by startsWith('/'). |
path.isAbsolute (platform-aware), matching REST. |
| C7 | P2 | protocolVersion could negotiate 0/negative. |
Clamp Math.max(1, Math.min(requested, 1)); tests for 0/neg/huge/invalid. |
| C8 | P2 | session/load/resume accepted empty sessionId. |
Reject empty with INVALID_PARAMS. |
| C9 | P2 | Notification-form session/prompt errors vanished silently. |
Log on the no-id path. |
| C10 | P2 | Session SSE flushed buffered frames before headers/retry:. |
open() before attachSessionStream. |
| C11 | P2 | Duplicate local logStderr. |
Shared writeStderrLine from utils/stdioHelpers. |
| C12 | P2 | Docs advertised --no-acp-http flag, acp_http capability tag, and fs/* forwarding not in v1. |
Doc aligned to shipped surface (env-var toggle only; fs/*+terminal/* + flag + tag marked deferred). |
Still deferred (unchanged): WebSocket + HTTP/2; per-connection secret for DELETE/ownership
(token + single-workspace remains the boundary); strict prompt-result ordering barrier; the
as never bridge-boundary casts (targeted, noted for an adapter-types follow-up).
14. Review round 5 — PR fold-ins
One more reviewer pass (qwen3.7-max). Suite 26 tests, live re-verified.
| # | Severity | Finding | Fix |
|---|---|---|---|
| D1 | P0 | resolveClientResponse deleted the pending entry BEFORE calling respondToSessionPermission. A malformed vote (result: {}) makes the bridge mediator throw — and with the pending entry already gone, teardown's abandonPendingForSession can't cancel it, so the agent's prompt hangs on a vote that never resolves (a token-holder could stall a session with one bad POST). |
Wrap the vote in try/catch; on any failure fall back to cancelAbandonedPermission so the mediator is always released. New test covers the malformed-vote path. |
| D2 | P1 | Session-stream onClose aborted only the event pump, not binding.promptAbort — a client disconnect (tab close / network drop) left the in-flight prompt running (quota + FIFO) until idle TTL. |
onClose now also aborts the session's promptAbort. |
| D3 | P1 | When pumpSessionEvents rejected, the .catch only logged — the SSE stream stayed open heartbeating but delivering nothing (zombie, no reconnect signal). |
.catch now also closeSessionStream(sessionId). |
15. Review round 6 — PR fold-ins
Another reviewer pass (qwen3.7-max). Suite 28 tests, live re-verified.
| # | Severity | Finding | Fix |
|---|---|---|---|
| E1 | P0 | handlePrompt overwrote binding.promptAbort without aborting the prior controller — two concurrent session/prompts for one session orphaned the first (runs to completion in the bridge FIFO, unabortable by session/cancel). |
Abort the prior promptAbort before installing the new one. Test added. |
| E2 | P0 | The subscribeEvents-throws path sent a stream_error notify then returned (resolved) — the caller's .catch never fired, leaving a zombie SSE stream (heartbeats, no events, no reconnect signal). |
Re-throw after the notify so the caller's .catch closes the stream. Test asserts prompt closure. |
| E3 | P1 | SSE heartbeat didn't mark the connection active — a long prompt with no intermediate events for >30 min got idle-reaped (streams + prompts killed). | SseStream takes an onHeartbeat hook; both GET handlers pass () => conn.touch(). |
| E4 | P2 | pumpSessionEvents .catch closed by sessionId — a reconnect between the throw and the microtask could kill the NEW stream. |
Identity-guard: only close if binding.stream is still this stream. |
| E6 | P2 | sendSession auto-created a binding — a late pump/reply frame after closeSessionStream resurrected a ghost binding that buffered up to 256 frames forever. |
sendSession is now lookup-only: drops frames when the session has no live binding. |
| E5 | accepted | session/load/resume don't reject when another live connection owns the session ("hijack"). |
Accepted, not changed: the daemon's trust boundary is the bearer token + single-workspace bind, and multi-client attach is intentional (the bridge is multi-client by design; REST has the same property). A token-holder gains no capability they lack via REST. Tracked with the other token-boundary items (DELETE ownership, §13). |
16. Review round 7 — PR fold-ins
Another reviewer pass (qwen3.7-max). Suite 30 tests, live re-verified.
| # | Severity | Finding | Fix |
|---|---|---|---|
| F1 | P0 | Concurrent session/close TOCTOU: ownedSessions.delete ran only in finally (after the await), so two concurrent closes both passed requireOwned → misleading error to the 2nd + redundant bridge close. |
Delete the ownership gate SYNCHRONOUSLY before the await; bridge close runs once. Test added. |
| F2 | P1 | Pump lifecycle: a CLEAN iterator end (subprocess ended, done) resolved → the .catch never fired → zombie stream; and a MID-STREAM iterator error sent no stream_error. |
pumpSessionEvents wraps the whole loop (sync + mid-stream errors send stream_error then re-throw); the consumer .then(onDone, onErr) closes the stream on BOTH paths (identity-guarded). Tests added. |
| F3 | P2 | 503 connection-cap rejection had no stderr log. | writeStderrLine with the cap value. |
| F4 | P2 | _qwen/notify stream_error spread let event.data.kind shadow the discriminator. |
Spread first, then kind: 'stream_error'. |
| F5 | P2 | MAX_WORKSPACE_PATH_LENGTH redeclared (= 4096) vs the canonical fs/paths.js. |
Import from ../fs/paths.js (no divergence). |
| F6 | P2 | isObjectParams duplicated json-rpc.isObject. |
Import isObject. |
| F7 | P2 | Raw process.stderr.write in index.ts/sse-stream.ts vs writeStderrLine elsewhere. |
Unified on writeStderrLine across the module. |
17. REST 等价对齐 + 扩展方案审计落地(round 8)
目标:让 /acp 成为 REST+SSE 的等价替代。本批基于审计结论重构扩展方案,并补齐所有 bridge 已暴露的能力;bridge 尚未拥有的能力(文件 I/O、设备流、agents/memory CRUD)按架构正确性要求先由 acp-bridge 补齐(见 §17.3)。
17.1 扩展方案审计 → 落地(替换 §5 的旧方案)
依据仓库实装 SDK @agentclientprotocol/sdk@0.14.1(非仅官网)核对:
session/set_config_option是一等(非unstable_)方法,请求{sessionId, configId, value},category含model/mode/thought_level;而set_model仍走unstable_setSessionModel。- 规范保留
_前缀给扩展,示例为域风格_zed.dev/…;厂商数据放_meta按域名分键。
落地:
- 命名空间
_qwen/→ 反向域名_qwen/;_meta统一_meta:{ "qwen": … }(含initialize能力广告与session/request_permission的 requestId)。 - 模型 + 审批模式 → 标准
session/set_config_option(configId:"model"|"mode"),路由到现有bridge.setSessionModel/setSessionApprovalMode;session/new结果广告configOptions(取自子进程会话状态getSessionContextStatus().state.configOptions,已是 ACP 形状)。删除厂商_qwen/session/set_model。 - REST(http+sse) 无需同步修改:两 transport 共用同一 bridge,状态天然一致。
17.2 本批新增的 /acp 方法(bridge 已支持,1:1 对齐 REST)
| REST | /acp |
bridge |
|---|---|---|
POST /session/:id/model / approval-mode |
标准 session/set_config_option(model/mode) |
setSessionModel / setSessionApprovalMode |
GET /session/:id/context |
_qwen/session/context |
getSessionContextStatus |
GET /session/:id/supported-commands |
_qwen/session/supported_commands |
getSessionSupportedCommandsStatus |
PATCH /session/:id/metadata |
_qwen/session/update_metadata |
updateSessionMetadata |
GET /workspace/{mcp,skills,providers,env,preflight} |
_qwen/workspace/{…} |
getWorkspace*Status |
POST /workspace/init |
_qwen/workspace/init |
initWorkspace |
POST /workspace/tools/:name/enable |
_qwen/workspace/set_tool_enabled |
setWorkspaceToolEnabled |
POST /workspace/mcp/:server/restart |
_qwen/workspace/restart_mcp_server |
restartMcpServer |
(既有:session/new·load·resume·close·list·prompt·cancel、heartbeat、permission、events 已对齐。)
17.3 仍缺口 → 要求 acp-bridge 先补齐(架构正确性)
REST 的 文件 I/O(/file /glob /list /stat /file/write /file/edit)、设备流登录(/workspace/auth/*)、agents CRUD(/workspace/agents)、memory CRUD(/workspace/memory)目前不在 HttpAcpBridge 上——REST 路由直接调 route 级服务(WorkspaceFileSystemFactory、DeviceFlowRegistry、SubagentManager、writeWorkspaceContextFile),绕过了 bridge。
决策(采纳评审/owner 意见):不让 /acp transport 再去直连这些 route 级服务(那会复制 REST 的架构漂移、并使 transport 耦合翻倍)。正确做法是先在 @qwen-code/acp-bridge 的 HttpAcpBridge 上补齐这些能力(如 readWorkspaceFile/writeWorkspaceFile/globWorkspace、startDeviceFlow/pollDeviceFlow、listAgents/upsertAgent/deleteAgent、readMemory/writeMemory),让 REST 与 /acp 都经由 bridge。届时 /acp 再加 _qwen/fs/*、_qwen/auth/*、_qwen/workspace/agent*、_qwen/workspace/memory*(文件读因无标准 ACP client→agent 方法,属合法厂商扩展)。
完整等价 = 本批(bridge 已有能力)+ acp-bridge 补齐缺口后的后续批。
18. Review round 9 — PR fold-ins
| # | Severity | Finding | Fix |
|---|---|---|---|
| G1 | P1 (regression) | Session-stream reconnect aborted the in-flight prompt: attachSessionStream closed the OLD stream before installing the new one, and the old stream's onClose unconditionally aborted promptAbort — so a reconnecting client (network glitch/roaming) lost its running prompt. |
Install the new stream BEFORE closing the old; identity-guard onClose's prompt-abort (only abort if THIS is still the session's live stream). Test added (prompt survives reconnect). |
| G2 | P2 | session/cancel passed undefined as the CancelNotification body, dropping client-supplied cancel fields (reason/context) that REST forwards. |
Forward { ...params, sessionId } (mirrors REST). |
Rebased onto latest daemon_mode_b_main (#4473/#4483/#4484/#4500), no conflicts. Suite 33 tests, live re-verified.
19. 路线图 / 后续 PR(防遗忘)
本 PR(#4472)= ACP Streamable HTTP transport + 全部 bridge-backed 能力对齐 + 官方扩展方案。已转 ready。达到「/acp 完全等价 REST+SSE」尚需:
- Follow-up PR 1 — acp-bridge 能力补齐(前置 / bridge-first):
HttpAcpBridge新增 文件 I/O、设备流、agents CRUD、memory CRUD 方法;REST 路由改走 bridge(消除直连 route 级服务的漂移)。 - Follow-up PR 2 —
/acp剩余对齐(依赖 PR 1):_qwen/fs/*、_qwen/auth/*、_qwen/workspace/agent*、_qwen/workspace/memory*→ 完全等价 REST。
跟踪:#3803(open decisions)、#4175(Mode B roadmap)均已 comment。 Deferred 硬化项见 PR 描述「已知 deferred」。
20. Extension-namespace rename + SDK-transport analysis (round 11)
- Namespace
_qwen.ai/→_qwen/: ACP's only hard rule is the leading_; the_zed.dev/domain segment is convention-by-example, not a MUST. Sinceqwenis distinctive, we use the shorter bare form._metakey likewise"qwen". (Survey of real agents: Zed/gemini-cli mostly use_meta-on-standard-methods + ACP's ownunstable_*; bare custom_methods are rare — our_qwen/*are genuinely-new workspace/session ops with no standard equivalent, so a_method is the right tool.) - Why hand-rolled transport (not SDK-based): the TS SDK ships only
ndJsonStream(stdio); RFD #721 HTTP is SDK Phase-3 (not implemented). The SDKConnectionis single-duplex-stream; our transport is multi-stream (POSTs + connection-SSE + per-session-SSE) and needs outbound demux by sessionId — which our dispatcher already knows at routing time. A full SDK rewrite fights that model and wouldn't remove the bulk (bridge translation, SSE lifecycle, ownership, EventBus→JSON-RPC). Pragmatic improvement (candidate follow-up): adopt the SDK's Zod schema validators + types for param validation while keeping the hand-rolled transport. SDK clients usingextMethod('_qwen/…')interoperate with our handlers (identical wire shape).