feat(daemon,sdk): resumable /acp session stream (Last-Event-ID) + opt-in SDK transports export (#5852)

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: 秦奇 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-06-30 02:07:48 +00:00
1 parent 8b4871bbc2
commit c302e3ec29
27 files changed
+4621 -161

No files matched your search

@@ -0,0 +1,292 @@
# ACP-over-HTTP — Resumable session event stream (`Last-Event-ID`)
> Status: design + implementation in this PR.
> Closes the resumability gap tracked as RFD Phase 4 in
> [`README.md`](./README.md) §7 / row "Resume cursor (ring `Last-Event-ID`)".
## Problem
The `/acp` Streamable-HTTP session event stream (`GET /acp` with an
`Acp-Session-Id` header) is **live-only**: it neither emits an SSE `id:`
sequence nor honours a `Last-Event-ID` request header on reconnect.
When a control-plane proxy idle-closes the long-lived SSE connection
mid-turn (the daemon itself sends `retry: 3000`, and ingress proxies cut
long SSE frequently), the client reconnects and re-claims ownership, but
**every content frame the daemon produced during the gap is lost** —
`session/update` notifications carrying `agent_thought_chunk` /
`agent_message_chunk`. The turn still reaches a terminal state (a
`turn_complete` is produced / synthesised), so the UI shows "done" with an
empty or truncated body. Re-sending the same prompt works, which is the
tell: the loss is in the transport gap, not the model.
Symptom and field evidence are catalogued in the integration notes as
**§1.8** (`sdk-known-issues.md`).
## What already exists (and why this is small)
The replay engine is **already built and battle-tested** — the gap is only
that the `/acp` transport is not wired to it.
`packages/acp-bridge/src/eventBus.ts`:
- Monotonic per-session `id`, starting at 1 (`nextId`, assigned in
`publish()`).
- Bounded ring buffer per session (`DEFAULT_RING_SIZE = 8000`, operator
override `qwen serve --event-ring-size`).
- `subscribeEvents(sessionId, { lastEventId, signal })` replays ring frames
with `id > lastEventId` before live events flow, and emits the synthetic
control frames `replay_complete`, `state_resync_required` (ring-evicted /
epoch reset on daemon restart), `client_evicted`, `slow_client_warning`.
The **REST** surface `GET /session/:id/events` already consumes all of
this: it reads `last-event-id` (`server.ts` → `parseLastEventId`), passes
it to `subscribeEvents`, and serialises each frame with an SSE `id:` line
(`formatSseFrame`). The bug is that the **`/acp` transport** does none of
this:
| Layer | REST `/session/:id/events` | `/acp` GET (today) |
| ----------------------------------------- | -------------------------- | --------------------------------------------- |
| reads `Last-Event-ID` header | yes | **no** |
| passes `lastEventId` to `subscribeEvents` | yes | **no** (`dispatch.ts pumpSessionEvents`) |
| emits SSE `id:` line | yes (`formatSseFrame`) | **no** (`SseStream.send` writes `data:` only) |
`acp-http/sse-stream.ts` even says so in a comment: _"no ring-buffer `id:`
sequencing — resumability is RFD Phase 4, deferred."_ This PR removes that
deferral.
## Wire decision — SSE `id:` line (not in-payload `_meta`)
The two SSE surfaces carry **different payloads**:
- REST streams **`BridgeEvent` envelopes** (`{ id, v, type, data, _meta }`).
The SDK parser (`sdk-typescript/src/daemon/sse.ts`) extracts the cursor
from the **JSON envelope's `id` field** (it only reads `data:` lines).
- `/acp` streams **raw JSON-RPC 2.0 objects** (`session/update`
notifications, `session/request_permission` requests, responses). These
have no envelope `id` to carry a bus cursor, and a JSON-RPC `id` means
something else (request id).
So for `/acp` the resume cursor is the **standard SSE `id:` line**:
- It is EventSource-native — a spec-compliant SSE client (incl. the
vendored `AcpHttpTransport`) auto-tracks the last `id:` and auto-sends it
back as the `Last-Event-ID` header on reconnect.
- It keeps the JSON-RPC payload clean (no non-standard `_meta.qwen.eventId`
injection into protocol frames).
- It mirrors what `formatSseFrame` already emits on REST, so both surfaces
share the **same** `eventBus` ids and the same `Last-Event-ID` semantics.
Only **bus-originated** frames carry an `id:` (`session/update`,
`session/request_permission`, daemon-pushed notifies). JSON-RPC
**responses/replies** that ride the session stream are _not_ bus events and
carry **no** `id:` — they are not in the ring and are intentionally not
replay-tracked (a lost in-flight prompt _response_ is the separately-tracked
§1.7 concern, out of scope here; §1.8 is about lost _content_ frames, which
are all bus `session/update` events).
Synthetic terminal frames (`client_evicted`, `stream_error`, …) have no bus
`id` and so emit no `id:` line — matching REST, so they don't burn a slot in
the monotonic sequence the client resumes from.
## Changes
1. **`transport-stream.ts`** — `send(message, id?: number)`. The optional
`id` is the bus event id for SSE cursor tracking.
2. **`sse-stream.ts`** — `send(message, id?)` prepends `id: ${id}\n` before
the `data:` line when `id !== undefined` (mirrors `formatSseFrame`).
3. **`ws-stream.ts`** — `send(message, id?)` accepts and **ignores** `id`:
WebSocket is a stateful connection, no SSE replay (consistent with
`AcpWsTransport.supportsReplay = false`).
4. **`connection-registry.ts`** — `sendSession(sessionId, frame, id?)`
threads `id` to `stream.send`. The per-session pre-attach **buffer**
stores `{ frame, id? }` pairs so a buffered frame keeps its cursor when
flushed on attach. (The connection-scoped buffer is unchanged — those
frames are JSON-RPC responses with no bus id.)
5. **`dispatch.ts`**
- `translateEvent` passes `event.id` through every `sendSession` /
`binding.stream.send` call for bus events.
- `pumpSessionEvents(conn, sessionId, signal, lastEventId?)` forwards
`lastEventId` to `subscribeEvents` — directly reusing the existing
ring replay.
6. **`index.ts`** — the `GET /acp` session-stream branch reads the
`Last-Event-ID` header (via a strict `parseLastEventId`, same accept-only-
decimal-digits rule as REST) and passes it to `pumpSessionEvents`.
No `eventBus`/bridge changes — the engine is reused verbatim.
## Making resume actually engage (session-stream grace/reclaim)
The `id:`/`Last-Event-ID` plumbing above is necessary but **not sufficient** —
on its own it never fires in the real flow. Previously, when a session SSE
stream closed at the transport level, the GET handler ran the **full**
`closeSessionStream` teardown: it removed the session from `ownedSessions`,
aborted the in-flight prompt, and detached the bridge client. In the real
EventSource/proxy order (old socket closes _first_, then the client
reconnects), that means a reconnect carrying `Last-Event-ID` is rejected
**403** by the ownership check before the cursor is ever read — and the prompt
producing the content was already aborted. The replay engine would have
nothing to reconnect to.
So a transport-level session-stream close now **detaches** instead of tears
down (`AcpConnection.detachSessionStream`): it stops only the stream + its
event subscription and **keeps the binding, ownership, the in-flight prompt,
and the bridge-client registration** alive for a grace window
(`SESSION_GRACE_MS`, mirroring `CONN_GRACE_MS`). A reconnect within the window
re-attaches (`attachSessionStream` clears the grace timer — reclaim) and the
ring replay backfills the gap. If no reconnect arrives, the grace timer runs
the full teardown — bounding the runaway-prompt cost. Full teardown remains
immediate for an explicit `session/close` and for connection teardown
(`destroy`). The GET handler branches on `stream.isClosed`: a transport close
→ detach-with-grace; a pump that ends while the stream is still open
(subprocess done / iterator error) → full close (zombie stream).
### Two replay-correctness guards this unlocks
Both are latent until resume actually runs; the grace/reclaim above makes them
reachable, so they ship together:
- **No double-delivery AND no silent loss (buffer ↔ ring).** A buffered bus
event is _also_ in the EventBus ring (it was published there to get its id).
So on a resume (`Last-Event-ID` present), `attachSessionStream` is given the
cursor and **does not flush id-bearing buffered frames at all** — the ring
replay (started at the client's cursor) is the single delivery path for every
bus event after the cursor. This is deliberately _not_ "flush the buffer, then
advance the replay cursor past it": a frame sent to the now-dead socket but
never received by the client has an id _below_ the buffer's ids yet _above_ the
client's cursor, so advancing the cursor past the buffer would **silently drop
it**. Letting the ring own all bus events delivers each exactly once with no
gap. Id-_less_ frames (JSON-RPC replies routed via `replySession`) are not ring
events, so the ring won't redeliver them — but they must not be flushed at
attach either: a buffered `session/prompt` _result_ flushed before replay would
arrive ahead of the content chunks that preceded it (client sees "done" before
the body — the exact truncated-body failure §1.8 fixes). So on resume the
id-less frames are **deferred**: left in the buffer, and the event pump releases
them (`flushBufferedSessionFrames`) once replay drains — on `replay_complete`
**only**, preserving original stream order. Crucially NOT on
`state_resync_required`: the EventBus emits that frame _before_ the replay
frames (then still emits `replay_complete` at the end), so flushing on it would
put the reply ahead of the replayed content. The live-only case (no
`Last-Event-ID` ⇒ no replay ⇒ no `replay_complete`) is covered by the pump's
post-loop safety flush. (A fresh connect with no `Last-Event-ID` has no ring
anchor, so it flushes the whole buffer immediately, in order, as before.)
- **Idempotent `permission_request` under replay.** A `permission_request` is
an id-bearing ring event, so a reconnect whose cursor precedes a still-
unanswered permission replays it. `translateEvent` now reuses the existing
`conn.pending` entry for that `bridgeRequestId` (re-sending the same outbound
JSON-RPC id for catch-up) instead of minting a second id + entry — no orphan
pending, no double-prompt for a client that dedupes on `_meta.requestId`.
`parseLastEventId` is extracted to a shared `serve/sse-last-event-id.ts` used
by both the REST and `/acp` surfaces, so their strict accept/reject rules and
operator logging can't drift.
## Backward compatibility
- **Old clients that don't send `Last-Event-ID`** → `lastEventId` is
`undefined` → `subscribeEvents` starts live, exactly as today.
- **Adding `id:` lines is backward-compatible SSE** — a client that ignores
the field is unaffected; an EventSource-based one starts tracking it for
free.
- **The vendored SDK `AcpHttpTransport` opts in to replay in this PR** —
it sets `supportsReplay = true` and resends `Last-Event-ID` on reconnect,
so gap frames are replayed from the ring and the §1.8 content loss is
closed with **no further daemon change needed**. (The separate external
`agent-web` transport flip stays deferred — see Out of scope.) The daemon
change remains inert for any consumer that still reports
`supportsReplay = false` and omits the header.
- The REST surface is untouched.
## Test plan
- `sse-stream.test.ts` — `send(msg, 7)` emits `id: 7\n` before `data:`;
`send(msg)` (no id) omits the `id:` line; ordering `id:` → `data:` →
blank line.
- `transport.test.ts` (end-to-end over the `/acp` transport):
- live `session/update` frames now arrive with an `id:` line;
- a `GET /acp` carrying `Last-Event-ID: N` flows the cursor to
`subscribeEvents`; a fresh stream with no header behaves as today;
- an overflow `Last-Event-ID` (> `MAX_SAFE_INTEGER`) → live-only;
- **real close-then-reconnect order**: close the old SSE _first_, then
reconnect with `Last-Event-ID` — assert **200 not 403** (ownership kept)
and the prompt is **not** aborted (grace/reclaim);
- a replayed `permission_request` reuses the pending entry (same outbound id).
- `connection-registry.test.ts` — a non-resume attach flushes the whole buffer
threading each frame's `id`; a **resume** attach (cursor present) skips the
id-bearing frames (ring replay owns them) but still flushes id-less JSON-RPC
replies; `detachSessionStream` keeps ownership/prompt across the grace window
then tears down on expiry; a reconnect within the window reclaims (cancels the
pending teardown).
- `ws-stream.test.ts` — `send(msg, id)` ignores the id: the WS wire frame is the
bare JSON, no SSE `id:` framing leaks in.
## Out of scope (still deferred)
- WebSocket / HTTP/2 transports.
- §1.7 cross-connection permission resolve (a vote POSTed on a different
`Acp-Connection-Id` than the one that streamed the prompt) — a separate,
security-sensitive concern tracked as its own follow-up. This PR does make
`permission_request` translation idempotent under replay (above), but does
not add the session-global requestId resolve. It also does not add
**response-replay idempotency for an ALREADY-RESOLVED permission**: once the
client has voted, the pending entry is consumed, so a later reconnect that
replays the (still-ringed) `permission_request` re-sends the prompt with the
same `_meta.requestId`. A conformant client dedupes on that id (the contract
the replay path already relies on) and the residual orphan pending entry is
reaped at teardown — the agent never stalls — but recording resolved outcomes
in a bounded per-session LRU to re-send the recorded vote (full idempotency
for non-deduping clients) belongs with this same permission-coordination
follow-up, since it adds resolved-permission state to the vote path.
- The lost in-flight _prompt response_ on the session stream — recovered
content frames all flow through the `eventBus` ring; a JSON-RPC response is
not a ring event.
- Consumer-side `supportsReplay` flip in the external `agent-web`
`AcpHttpTransport` (lives in a different repo; unblocked by this PR).
- **Permission voting through the exported SDK transports.** The exported
`AcpHttpTransport`/`AcpWsTransport` surface `session/request_permission` as a
`permission_request` event, but the SDK's vote APIs
(`respondToPermission` / `respondToSessionPermission`) map to a
`session/permission` request the ACP daemon has no handler for — it only
accepts a permission vote as the JSON-RPC _response_ echoing the outbound
`_qwen_perm_N` id. Wiring the vote round-trip is part of the §1.7
permission-coordination follow-up. A related facet: the no-subscriber session
**reply pump** (`ensureSessionReplyPump`) opens a real `GET /acp` session
stream, which the daemon treats as a live stream — so an agent
`permission_request` raised while only the reply pump is attached is ROUTED to
that stream and dropped by the pump (it forwards only JSON-RPC responses),
hanging the mediator, whereas with no stream at all the daemon cancel-denies
and the agent proceeds. Both the daemon-side "is this a real consumer or just
a reply pump?" distinction and the SDK-side handling (deny locally / surface
to a permission callback) belong with the same permission-coordination
follow-up, since the pump cannot itself cast a vote. Consumers that need
permission handling should open `subscribeEvents` before issuing session RPCs
(the documented contract), which gives the daemon a real consumer stream.
- **Session RPCs issued from inside the `subscribeEvents` loop on the exported
`AcpHttpTransport`.** The session `/acp` stream is single-reader: while a
consumer's async generator is parked between `yield`s, the reader is not
draining. If the consumer `await`s a session-routed RPC (`session/set_model`,
`session/prompt`, …) from within its own event-handling loop, `sendRequest`
suppresses the background reply pump (a subscription is "active") yet the
parked generator never reads the reply — the call hangs until the consumer
pulls the next event. The robust fix is to make the session reader a
background pump that always drains JSON-RPC replies and queues only
`DaemonEvent`s for the iterator; deferred as a focused follow-up since it is a
structural change to an opt-in, newly-exported transport and does not affect
the default REST transport.
- **Automated guard for the `SESSION_STREAM_REPLY_METHODS` ⇄ `replySession`
drift.** The SDK's `SESSION_STREAM_REPLY_METHODS` set must mirror the daemon's
`replySession(...)` call sites in `dispatch.ts` (a different package); a method
added there without adding it here opens no reply pump and a no-subscriber
`sendRequest` for it hangs until abort. Neither package's type system enforces
this. A CI guard (a lightweight script or vitest that extracts the daemon's
session-reply method names and diffs them against the SDK set) is the right
fix, but cross-package static-analysis tooling is its own focused task — and
not a trivial grep: a correct extractor needs light dataflow analysis, because
`session/prompt`'s reply is NOT emitted inside its `case 'session/prompt'`
block. The prompt kicks off asynchronously and its `replySession(...)` fires
later from the prompt-completion handler (a different call site), so a naive
"which `case` blocks contain `replySession`" scan would wrongly EXCLUDE
`session/prompt` and fail the build against a correct set. The set is small and
stable in the meantime, and the JSDoc on the constant documents the invariant;
the robust long-term fix is to have the daemon advertise its session-routed
method names (a shared source of truth) rather than scrape `dispatch.ts`.
@@ -4,16 +4,20 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { ConnectionRegistry } from './connection-registry.js';
import type { TransportStream } from './transport-stream.js';
class FakeStream implements TransportStream {
isClosed = false;
/** Records every send so tests can assert the bus `id` is threaded. */
readonly sent: Array<{ message: unknown; id?: number }> = [];
constructor(readonly kind: 'sse' | 'ws') {}
async send(_message: unknown): Promise<void> {}
async send(message: unknown, id?: number): Promise<void> {
this.sent.push({ message, id });
}
close(): void {
this.isClosed = true;
@@ -91,6 +95,755 @@ describe('ConnectionRegistry.getSnapshot', () => {
}
});
it('non-resume attach flushes all pre-attach buffered frames WITH their bus id', () => {
const registry = new ConnectionRegistry();
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
conn.getOrCreateSession('sess-1'); // binding exists, no stream yet
// Buffered before any stream attaches (id-bearing + an id-less frame).
conn.sendSession('sess-1', { a: 1 }, 5);
conn.sendSession('sess-1', { b: 2 }); // response frame, no bus id
conn.sendSession('sess-1', { c: 3 }, 8);
const stream = new FakeStream('sse');
const binding = conn.attachSessionStream(
'sess-1',
stream,
new AbortController(),
);
// Non-resume attach (no Last-Event-ID): flush EVERYTHING, each frame
// keeping its id across the buffer → stream handoff (a regression to
// `send(frame)` would drop the cursor for early §1.8 frames).
expect(stream.sent).toEqual([
{ message: { a: 1 }, id: 5 },
{ message: { b: 2 }, id: undefined },
{ message: { c: 3 }, id: 8 },
]);
// The binding no longer carries a `lastFlushedEventId` — the resume cursor
// is the client's Last-Event-ID verbatim (see the resume test below).
expect(
(binding as unknown as { lastFlushedEventId?: number })
.lastFlushedEventId,
).toBeUndefined();
} finally {
registry.dispose();
}
});
it('on resume, skips id-bearing buffered frames (ring owns them) AND defers id-less replies until flushBufferedSessionFrames (post-replay order)', () => {
// Two regressions in one path:
// (1) silent-frame-loss: a frame sent to the dead socket (id below the
// buffer's ids, above the client cursor) must come back via ring
// replay — so the buffer must NOT flush bus events on resume.
// (2) out-of-order completion: an id-less `session/prompt` result buffered
// during the gap must NOT be flushed at attach (it would arrive BEFORE
// the ring replays the content chunks that preceded it). It's deferred
// and released by the pump after `replay_complete`.
const registry = new ConnectionRegistry();
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
conn.getOrCreateSession('sess-1');
// Gap buffer holds two bus events (ids 6, 7) and one id-less reply.
conn.sendSession('sess-1', { a: 1 }, 6);
conn.sendSession('sess-1', { reply: true }); // JSON-RPC reply, no bus id
conn.sendSession('sess-1', { c: 3 }, 7);
const stream = new FakeStream('sse');
// Client resumes from id 3 (it never saw frame 4, lost in-flight).
conn.attachSessionStream('sess-1', stream, new AbortController(), 3);
// At attach: NOTHING is sent. Bus events (6,7) belong to the ring replay;
// the id-less reply is deferred so it can't jump ahead of replayed content.
expect(stream.sent).toEqual([]);
// The pump calls this once the replay boundary passes → the deferred
// reply is released, after the (replayed) content chunks.
conn.flushBufferedSessionFrames('sess-1');
expect(stream.sent).toEqual([
{ message: { reply: true }, id: undefined },
]);
} finally {
registry.dispose();
}
});
it('flushBufferedSessionFrames leaves frames buffered when the stream is already closed (no drop onto a dead socket)', () => {
const registry = new ConnectionRegistry();
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
conn.getOrCreateSession('sess-1');
conn.sendSession('sess-1', { reply: true }); // id-less reply
const s1 = new FakeStream('sse');
// Resume attach defers the id-less reply into the buffer (s1 stays empty).
conn.attachSessionStream('sess-1', s1, new AbortController(), 3);
expect(s1.sent).toEqual([]);
// Socket dies before the pump reaches the replay boundary.
s1.close();
conn.flushBufferedSessionFrames('sess-1');
expect(s1.sent).toEqual([]); // nothing dropped onto the dead stream
// The reply is still buffered: a fresh reconnect (non-resume) delivers it.
conn.detachSessionStream('sess-1', s1, 10_000);
const s2 = new FakeStream('sse');
conn.attachSessionStream('sess-1', s2, new AbortController());
expect(s2.sent).toEqual([{ message: { reply: true }, id: undefined }]);
} finally {
registry.dispose();
}
});
it('under a content flood the pre-attach buffer evicts id-bearing (ring-replayable) frames and keeps the irreplaceable id-less reply', () => {
// The buffer cap (256) is shared between id-bearing bus events (the ring
// redelivers them on reconnect) and id-less deferred JSON-RPC replies (the
// ring does NOT track them). A fast model flooding content during a detach
// gap must not evict the `session/prompt` reply — that would hang the
// caller. Eviction must prefer the replayable id-bearing frames.
const registry = new ConnectionRegistry();
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
conn.getOrCreateSession('sess-1');
// One irreplaceable id-less reply lands first, then a flood of id-bearing
// content frames well past the 256 cap.
conn.sendSessionReply('sess-1', { promptResult: true });
for (let i = 1; i <= 400; i++)
conn.sendSession('sess-1', { chunk: i }, i);
// Fresh reconnect flushes whatever survived. The id-less reply must be
// among the flushed frames (id-bearing frames were evicted preferentially).
const s = new FakeStream('sse');
conn.attachSessionStream('sess-1', s, new AbortController());
const replyDelivered = s.sent.some(
(x) =>
(x.message as { promptResult?: boolean }).promptResult === true &&
x.id === undefined,
);
expect(replyDelivered).toBe(true);
} finally {
registry.dispose();
}
});
it('never evicts an irreplaceable id-less reply even when the buffer is ENTIRELY id-less replies (no id-bearing frame to drop)', () => {
// Degenerate case wenshao flagged: if the gap buffer fills with only id-less
// deferred replies, there is no replayable frame to evict — dropping one
// would silently hang its caller. So pushCapped must NOT drop; it lets the
// irreplaceable replies exceed the soft cap (they're bounded by real
// in-flight RPC count, not a content flood). Every reply must survive.
const registry = new ConnectionRegistry();
const stderr = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
conn.getOrCreateSession('sess-1');
// Far past the 256 soft cap but under the 1024 hard cap: ALL id-less
// replies (no id-bearing frames).
const N = 300;
for (let i = 0; i < N; i++) conn.sendSessionReply('sess-1', { reply: i });
// Fresh reconnect flushes everything — not one reply was evicted.
const s = new FakeStream('sse');
conn.attachSessionStream('sess-1', s, new AbortController());
const replyIds = s.sent
.map((x) => (x.message as { reply?: number }).reply)
.filter((v) => typeof v === 'number');
expect(replyIds).toHaveLength(N);
expect(new Set(replyIds).size).toBe(N); // all distinct, none lost
// The soft-cap warning is the operator's only signal it was exceeded —
// assert it fired (exactly once, at the transition, not per push).
const softCapLogs = stderr.mock.calls.filter((c) =>
String(c[0]).includes('pre-attach buffer over soft cap'),
);
expect(softCapLogs).toHaveLength(1);
} finally {
stderr.mockRestore();
registry.dispose();
}
});
it('enforces a HARD ceiling on all-id-less buffer growth (defense-in-depth) — drops oldest and logs loudly past 4× the soft cap', () => {
// The soft cap never drops id-less replies, but an UNBOUNDED heap is worse
// than a hung caller — so past the 1024 hard cap pushCapped drops the oldest
// id-less reply and logs loudly. Bounds a pathological / buggy producer.
const registry = new ConnectionRegistry();
const stderr = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
conn.getOrCreateSession('sess-1');
const N = 1100; // past the 1024 hard cap
for (let i = 0; i < N; i++) conn.sendSessionReply('sess-1', { reply: i });
const s = new FakeStream('sse');
conn.attachSessionStream('sess-1', s, new AbortController());
const replyIds = s.sent
.map((x) => (x.message as { reply?: number }).reply)
.filter((v): v is number => typeof v === 'number');
// Buffer bounded at the hard cap (1024); the OLDEST were dropped, so the
// most-recent survive.
expect(replyIds).toHaveLength(1024);
expect(replyIds).toContain(N - 1); // newest kept
expect(replyIds).not.toContain(0); // oldest dropped
expect(
stderr.mock.calls.some((c) =>
String(c[0]).includes('HARD buffer cap breached'),
),
).toBe(true);
} finally {
stderr.mockRestore();
registry.dispose();
}
});
it('detachSessionStream is a no-op for a stale stream after reclaim (identity guard)', () => {
// The CONTRACT at the attach site marks this guard load-bearing: once a
// reclaim installs s2, the OLD stream s1 closing must NOT tear down or
// re-arm grace on the fresh binding — that would be frame loss
// indistinguishable from a network drop.
vi.useFakeTimers();
const detached: string[] = [];
const registry = new ConnectionRegistry(undefined, (sid) =>
detached.push(sid),
);
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
const s1 = new FakeStream('sse');
conn.attachSessionStream('sess-1', s1, new AbortController());
conn.detachSessionStream('sess-1', s1, 10_000); // grace armed for s1
const s2 = new FakeStream('sse');
conn.attachSessionStream('sess-1', s2, new AbortController()); // reclaim
const graceAfterReclaim = conn.sessions.get('sess-1')?.graceTimer;
expect(graceAfterReclaim).toBeUndefined(); // reclaim cleared the timer
// The stale s1 close arrives late — must be a pure no-op.
conn.detachSessionStream('sess-1', s1, 10_000);
expect(conn.sessions.get('sess-1')?.stream).toBe(s2); // s2 still bound
expect(conn.sessions.get('sess-1')?.graceTimer).toBeUndefined(); // no re-arm
expect(conn.ownsSession('sess-1')).toBe(true);
// And no teardown fires from the stale close.
vi.advanceTimersByTime(20_000);
expect(detached).not.toContain('sess-1');
expect(conn.sessions.get('sess-1')?.stream).toBe(s2);
} finally {
registry.dispose();
vi.useRealTimers();
}
});
it('detachSessionStream keeps ownership/prompt across the grace window, then tears down on expiry', () => {
vi.useFakeTimers();
const detached: string[] = [];
const registry = new ConnectionRegistry(undefined, (sid) =>
detached.push(sid),
);
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
const stream = new FakeStream('sse');
const binding = conn.attachSessionStream(
'sess-1',
stream,
new AbortController(),
);
const promptAbort = new AbortController();
binding.promptAbort = promptAbort;
// Transport-level close → detach with grace (NOT teardown).
conn.detachSessionStream('sess-1', stream, 10_000);
expect(conn.ownsSession('sess-1')).toBe(true);
expect(conn.sessions.has('sess-1')).toBe(true);
expect(promptAbort.signal.aborted).toBe(false); // prompt survives
expect(binding.stream).toBeUndefined(); // frames buffer until reconnect
// No reconnect within the window → full teardown.
vi.advanceTimersByTime(10_000);
expect(conn.ownsSession('sess-1')).toBe(false);
expect(conn.sessions.has('sess-1')).toBe(false);
expect(promptAbort.signal.aborted).toBe(true);
expect(detached).toContain('sess-1');
} finally {
registry.dispose();
vi.useRealTimers();
}
});
it('attachSessionStream within the grace window reclaims (cancels the pending teardown)', () => {
vi.useFakeTimers();
const detached: string[] = [];
const registry = new ConnectionRegistry(undefined, (sid) =>
detached.push(sid),
);
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
const s1 = new FakeStream('sse');
const binding = conn.attachSessionStream(
'sess-1',
s1,
new AbortController(),
);
const promptAbort = new AbortController();
binding.promptAbort = promptAbort;
conn.detachSessionStream('sess-1', s1, 10_000);
// Reconnect within grace.
const s2 = new FakeStream('sse');
conn.attachSessionStream('sess-1', s2, new AbortController());
// Past the original grace — teardown must NOT fire (timer cleared).
vi.advanceTimersByTime(20_000);
expect(conn.ownsSession('sess-1')).toBe(true);
expect(promptAbort.signal.aborted).toBe(false);
expect(detached).not.toContain('sess-1');
expect(conn.sessions.get('sess-1')?.stream).toBe(s2);
} finally {
registry.dispose();
vi.useRealTimers();
}
});
it('buffers events produced during the detach gap and flushes them exactly once on reattach', () => {
// End-to-end of the PR's core value prop at the registry layer: detach →
// produce gap events (no stream attached → buffered) → reattach → the gap
// events flush exactly once, in order. (A resuming reattach instead leaves
// id-bearing frames to the ring replay — covered by the resume test above.)
vi.useFakeTimers();
const registry = new ConnectionRegistry();
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
const s1 = new FakeStream('sse');
conn.attachSessionStream('sess-1', s1, new AbortController());
// Transport-level close → detach with grace; stream is gone, ownership
// and the binding survive so subsequent frames buffer.
conn.detachSessionStream('sess-1', s1, 10_000);
expect(conn.sessions.get('sess-1')?.stream).toBeUndefined();
// Gap events arrive while detached — they must buffer, not drop.
conn.sendSession('sess-1', { chunk: 'a' }, 10);
conn.sendSession('sess-1', { chunk: 'b' }, 11);
expect(s1.sent).toEqual([]); // old stream is gone — nothing leaks to it
// Non-resume reattach (no Last-Event-ID) → flush the whole gap buffer once.
const s2 = new FakeStream('sse');
conn.attachSessionStream('sess-1', s2, new AbortController());
expect(s2.sent).toEqual([
{ message: { chunk: 'a' }, id: 10 },
{ message: { chunk: 'b' }, id: 11 },
]);
// The buffer is drained — a second reattach delivers nothing again.
const s3 = new FakeStream('sse');
conn.attachSessionStream('sess-1', s3, new AbortController());
expect(s3.sent).toEqual([]);
} finally {
registry.dispose();
vi.useRealTimers();
}
});
it('defers an out-of-band reply that finishes DURING the replay window until replay_complete (sendSessionReply + replayPending)', () => {
// wenshao MsOpj: the gap-buffer deferral alone isn't enough — a prompt that
// finishes AFTER the resumptive attach but BEFORE replay drains would, via
// the plain live-send path, overtake replay frames not yet sent. The
// `replayPending` flag keeps `sendSessionReply` deferring through that
// window too.
const registry = new ConnectionRegistry();
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
conn.getOrCreateSession('sess-1');
// Resumptive attach (cursor present) → replayPending armed.
const s = new FakeStream('sse');
conn.attachSessionStream('sess-1', s, new AbortController(), 5);
// A prompt finishes mid-replay → out-of-band reply. Must NOT be sent yet.
conn.sendSessionReply('sess-1', { promptResult: true });
expect(s.sent).toEqual([]);
// The pump replays content (bus events) live, in order.
conn.sendSession('sess-1', { chunk: 'a' }, 6);
conn.sendSession('sess-1', { chunk: 'b' }, 7);
expect(s.sent).toEqual([
{ message: { chunk: 'a' }, id: 6 },
{ message: { chunk: 'b' }, id: 7 },
]);
// replay_complete → flush deferred reply AFTER the replayed content, and
// clear replayPending.
conn.flushBufferedSessionFrames('sess-1');
expect(s.sent).toEqual([
{ message: { chunk: 'a' }, id: 6 },
{ message: { chunk: 'b' }, id: 7 },
{ message: { promptResult: true }, id: undefined },
]);
// Past the boundary, a later reply is delivered live (no longer deferred).
conn.sendSessionReply('sess-1', { later: true });
expect(s.sent.at(-1)).toEqual({
message: { later: true },
id: undefined,
});
} finally {
registry.dispose();
}
});
it('hasRecoverableSession() is true while a session grace timer is armed, so the connection reaper treats it as active', () => {
// wenshao MsOpl: a detached-but-recoverable session (graceTimer armed,
// stream undefined) must count as connection activity, else the conn reaper
// can delete the whole connection mid SESSION_GRACE_MS and 404 the resume.
vi.useFakeTimers();
const registry = new ConnectionRegistry();
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
const s1 = new FakeStream('sse');
conn.attachSessionStream('sess-1', s1, new AbortController());
expect(conn.hasLiveSessionStream()).toBe(true);
expect(conn.hasRecoverableSession()).toBe(false);
// Transport close → detach with grace: no live stream, but recoverable.
conn.detachSessionStream('sess-1', s1, 10_000);
expect(conn.hasLiveSessionStream()).toBe(false);
expect(conn.hasRecoverableSession()).toBe(true);
// Reclaim within grace → no longer in a grace window.
const s2 = new FakeStream('sse');
conn.attachSessionStream('sess-1', s2, new AbortController());
expect(conn.hasRecoverableSession()).toBe(false);
expect(conn.hasLiveSessionStream()).toBe(true);
} finally {
registry.dispose();
vi.useRealTimers();
}
});
it('clears replayPending on a fresh re-attach after an aborted resume (MsyIq/MylZ4) — a later reply is delivered live, not stranded behind a replay boundary that never arrives', () => {
const registry = new ConnectionRegistry();
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
conn.getOrCreateSession('sess-1');
// Resumptive attach arms replayPending.
const s1 = new FakeStream('sse');
conn.attachSessionStream('sess-1', s1, new AbortController(), 5);
expect(conn.sessions.get('sess-1')?.replayPending).toBe(true);
// The resume is aborted before replay_complete (the normal reclaim path
// skips the flush, so no boundary clears the flag). A FRESH reconnect with
// no cursor must reset it from the current attach mode.
const s2 = new FakeStream('sse');
conn.attachSessionStream('sess-1', s2, new AbortController());
expect(conn.sessions.get('sess-1')?.replayPending).toBe(false);
// A later prompt result reaches the wire immediately — not buffered behind
// a replay boundary this live-only subscription will never emit.
conn.sendSessionReply('sess-1', { promptResult: true });
expect(s2.sent).toEqual([
{ message: { promptResult: true }, id: undefined },
]);
} finally {
registry.dispose();
}
});
it('logs a stderr breadcrumb when a resume arms replay deferral, and stays silent on a fresh connect', () => {
const registry = new ConnectionRegistry();
const stderr = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
conn.getOrCreateSession('sess-1');
// Fresh connect (no cursor): nothing to defer → no breadcrumb.
conn.attachSessionStream(
'sess-1',
new FakeStream('sse'),
new AbortController(),
);
expect(
stderr.mock.calls.some((c) =>
String(c[0]).includes('replay deferral armed'),
),
).toBe(false);
// Resumptive attach (cursor present): arming the deferral logs once with
// the session id and the cursor it resumed from.
conn.attachSessionStream(
'sess-1',
new FakeStream('sse'),
new AbortController(),
5,
);
const armed = stderr.mock.calls
.map((c) => String(c[0]))
.filter((line) => line.includes('replay deferral armed'));
expect(armed).toHaveLength(1);
expect(armed[0]).toContain('from id 5');
} finally {
stderr.mockRestore();
registry.dispose();
}
});
it('holds a deferred reply until the pump delivers through its anchor (MsyIt) — a result produced during a slow replay lands after live tail content, not at replay_complete', () => {
const registry = new ConnectionRegistry();
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
conn.getOrCreateSession('sess-1');
// Resume from cursor 5 while the turn is STILL running: its tail content
// (ids 8,9) will arrive as LIVE events AFTER replay_complete.
const s = new FakeStream('sse');
conn.attachSessionStream('sess-1', s, new AbortController(), 5);
// Replay redelivers the in-ring content (ids 6,7); the pump releases any
// replies it has caught up to after each frame.
conn.sendSession('sess-1', { chunk: 6 }, 6);
conn.releaseDeferredSessionReplies('sess-1', 6);
conn.sendSession('sess-1', { chunk: 7 }, 7);
conn.releaseDeferredSessionReplies('sess-1', 7);
// The prompt finishes mid-replay, anchored to the bus head (9) — two tail
// events (8,9) are still to come.
conn.sendSessionReply('sess-1', { promptResult: true }, 9);
expect(s.sent).toEqual([
{ message: { chunk: 6 }, id: 6 },
{ message: { chunk: 7 }, id: 7 },
]);
// replay_complete releases only replies anchored within the replayed range
// (≤ 7). The reply (anchor 9) must STAY deferred — flushing it here is
// exactly the MsyIt reordering bug.
conn.endReplayDeferral('sess-1', 7);
expect(conn.sessions.get('sess-1')?.replayPending).toBe(false);
const hasReply = () =>
s.sent.some(
(f) => (f.message as { promptResult?: boolean }).promptResult,
);
expect(hasReply()).toBe(false);
// Live tail flows; the reply waits until id 9 is actually delivered.
conn.sendSession('sess-1', { chunk: 8 }, 8);
conn.releaseDeferredSessionReplies('sess-1', 8);
expect(hasReply()).toBe(false);
conn.sendSession('sess-1', { chunk: 9 }, 9);
conn.releaseDeferredSessionReplies('sess-1', 9);
expect(s.sent).toEqual([
{ message: { chunk: 6 }, id: 6 },
{ message: { chunk: 7 }, id: 7 },
{ message: { chunk: 8 }, id: 8 },
{ message: { chunk: 9 }, id: 9 },
{ message: { promptResult: true }, id: undefined },
]);
} finally {
registry.dispose();
}
});
it('releases ALL deferred replies at replay_complete when the replay evicted frames (state_resync_required) — no cascading freeze on an unreachable anchor', () => {
// doudouOUC's cascading-freeze: a reply anchored ABOVE the surviving range
// (its anchor event was evicted from the ring on overflow) would otherwise
// wait for an id the pump never delivers — and because `sendSessionReply`
// gates inline delivery on an EMPTY buffer, every later reply piles up
// behind it forever. When the replay carried a `state_resync_required`, the
// ordering guarantee is void, so endReplayDeferral must release everything.
const registry = new ConnectionRegistry();
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
conn.getOrCreateSession('sess-1');
const s = new FakeStream('sse');
conn.attachSessionStream('sess-1', s, new AbortController(), 5); // resume
// Two replies anchored at id 9 — but the ring evicted everything, so no
// event with id ≥ 9 will ever be delivered.
conn.sendSessionReply('sess-1', { promptResult: true }, 9);
conn.sendSessionReply('sess-1', { second: true }, 9);
expect(s.sent).toEqual([]); // both deferred
// replay_complete WITH eviction (lastReplayed 4 < anchor 9): release all.
conn.endReplayDeferral('sess-1', 4, true);
expect(s.sent).toEqual([
{ message: { promptResult: true }, id: undefined },
{ message: { second: true }, id: undefined },
]);
// Cascade broken: buffer empty + replayPending cleared → a later reply
// reaches the wire immediately instead of stranding.
conn.sendSessionReply('sess-1', { third: true });
expect(s.sent).toContainEqual({
message: { third: true },
id: undefined,
});
} finally {
registry.dispose();
}
});
it('holds an UNANCHORED deferred reply during replay (Branch A) and releases it once the boundary clears replayPending (Branch B) (M3w6e)', () => {
const registry = new ConnectionRegistry();
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
conn.getOrCreateSession('sess-1');
const s = new FakeStream('sse');
conn.attachSessionStream('sess-1', s, new AbortController(), 5); // replayPending
// An unanchored reply (anchorId undefined — the getSessionLastEventId
// teardown-race fallback) buffered during replay.
conn.sendSessionReply('sess-1', { promptResult: true });
// Branch A: replayPending true → a watermark release must NOT emit it
// (releasing mid-replay would risk landing it ahead of replay content).
conn.releaseDeferredSessionReplies('sess-1', 99);
expect(s.sent).toEqual([]);
// Branch B: the boundary clears replayPending → the unanchored reply is
// released unconditionally.
conn.endReplayDeferral('sess-1', 5);
expect(s.sent).toEqual([
{ message: { promptResult: true }, id: undefined },
]);
} finally {
registry.dispose();
}
});
it('clearGraceTimer resets connGraceExpired so a stale expiry verdict cannot carry into a new window (M3w6f)', () => {
const registry = new ConnectionRegistry();
try {
const conn = registry.create(true);
if (!conn) return;
// Simulate the conn grace timer having fired earlier.
conn.connGraceExpired = true;
// A reconnect (attachConnStream) cancels the pending reap via clearGraceTimer.
conn.attachConnStream(new FakeStream('sse'));
expect(conn.connGraceExpired).toBe(false);
} finally {
registry.dispose();
}
});
it('invokes onSessionGraceExpired when a session reclaim grace expires (drives the deferred connection reap, MsyIs)', () => {
vi.useFakeTimers();
const registry = new ConnectionRegistry();
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
const s1 = new FakeStream('sse');
conn.attachSessionStream('sess-1', s1, new AbortController());
const onExpired = vi.fn();
conn.onSessionGraceExpired = onExpired;
conn.detachSessionStream('sess-1', s1, 10_000);
expect(onExpired).not.toHaveBeenCalled();
vi.advanceTimersByTime(10_000);
expect(onExpired).toHaveBeenCalledTimes(1);
} finally {
registry.dispose();
vi.useRealTimers();
}
});
it('grace-expiry teardown swallows a throwing detach callback (MylZ8) — the setTimeout never crashes the process', () => {
vi.useFakeTimers();
const onDetach = vi.fn(() => {
throw new Error('boom');
});
const registry = new ConnectionRegistry(undefined, onDetach);
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
const s1 = new FakeStream('sse');
conn.attachSessionStream('sess-1', s1, new AbortController());
conn.detachSessionStream('sess-1', s1, 10_000);
// Grace expiry → closeSessionStream → teardownBinding → onDetach throws.
// The try/catch must contain it so the bare setTimeout can't take the
// daemon down.
expect(() => vi.advanceTimersByTime(10_000)).not.toThrow();
expect(onDetach).toHaveBeenCalled();
} finally {
registry.dispose();
vi.useRealTimers();
}
});
it('grace-expiry swallows a throwing onSessionGraceExpired callback (M4i9z) — the setTimeout never crashes the process', () => {
vi.useFakeTimers();
const registry = new ConnectionRegistry();
try {
const conn = registry.create(true);
if (!conn) return;
conn.ownSession('sess-1');
const s1 = new FakeStream('sse');
conn.attachSessionStream('sess-1', s1, new AbortController());
const onExpired = vi.fn(() => {
throw new Error('boom');
});
conn.onSessionGraceExpired = onExpired;
conn.detachSessionStream('sess-1', s1, 10_000);
// onSessionGraceExpired runs from the bare setTimeout; its own try/catch
// must contain a throw so it can't take the daemon down.
expect(() => vi.advanceTimersByTime(10_000)).not.toThrow();
expect(onExpired).toHaveBeenCalledTimes(1);
// Teardown still completed (the callback runs after it).
expect(conn.sessions.has('sess-1')).toBe(false);
} finally {
registry.dispose();
vi.useRealTimers();
}
});
it('aborts the connection signal when the connection is deleted', () => {
const registry = new ConnectionRegistry();
try {
@@ -17,6 +17,18 @@ import type { TransportStream } from './transport-stream.js';
*/
const MAX_BUFFERED_FRAMES = 256;
/**
* Defense-in-depth hard ceiling for the degenerate all-id-less buffer case.
* Id-less deferred replies are never evicted at the soft cap (dropping one
* hangs its caller), and they're bounded in practice by the number of in-flight
* session RPCs — but that invariant is convention, not enforced by the type
* system or call sites. So a future id-less producer that ISN'T RPC-bounded, or
* a buggy client, can't grow the buffer without limit: past this hard cap we
* drop the oldest id-less reply and log loudly (its caller may hang, but an
* unbounded daemon heap is worse). 4× the soft cap.
*/
const HARD_BUFFERED_FRAMES_CAP = MAX_BUFFERED_FRAMES * 4;
/** Default cap on concurrent live connections (mirrors a bounded resource). */
const DEFAULT_MAX_CONNECTIONS = 64;
@@ -42,6 +54,21 @@ export type DetachSessionFn = (
clientId: string | undefined,
) => void;
/** A pre-attach session frame plus its optional bus event id (SSE cursor). */
interface BufferedSessionFrame {
frame: unknown;
id?: number;
/**
* For DEFERRED out-of-band replies only (`sendSessionReply`, always id-less):
* the bus head id at the moment the reply was produced. The reply must not be
* released to the wire until the pump has delivered every content event up to
* this id — otherwise a prompt result produced during a slow ring replay could
* land ahead of tail content that is still queued behind `replay_complete`
* (§1.8 W1). `undefined` ⇒ release at the next boundary unconditionally.
*/
anchorId?: number;
}
/**
* Tracks one logical ACP-over-HTTP connection (RFD #721). A connection is
* minted at `initialize`, keyed by `Acp-Connection-Id`, and may host many
@@ -60,8 +87,12 @@ export interface SessionBinding {
clientId?: string;
/** Session-scoped SSE stream (the client's `GET /acp` with both headers). */
stream?: TransportStream;
/** Frames emitted before the session stream attached, flushed on attach. */
buffer: unknown[];
/**
* Frames emitted before the session stream attached, flushed on attach.
* Each keeps its bus event id (when it has one) so the SSE `id:` resume
* cursor survives the buffer → live-stream handoff.
*/
buffer: BufferedSessionFrame[];
/**
* Aborts the bridge event subscription tied to the CURRENT session
* stream. Replaced with a fresh controller on every re-attach — a
@@ -77,6 +108,33 @@ export interface SessionBinding {
* the agent burning model quota on a result nobody will read.
*/
promptAbort?: AbortController;
/**
* Armed by `detachSessionStream` when the session stream closes at the
* transport level (proxy idle-close, network blip) WITHOUT an explicit
* `session/close`. The binding — ownership, prompt, bridge-client — is kept
* alive across the window so a reconnect (`attachSessionStream`) can resume
* (ring replay backfills the gap, §1.8). If no reconnect arrives the timer
* fires the full teardown, bounding the runaway-prompt cost. Cleared on
* reconnect and on teardown.
*/
graceTimer?: ReturnType<typeof setTimeout>;
/**
* Set from the CURRENT attach mode on every `attachSessionStream`: armed on a
* resumptive attach (with a `Last-Event-ID`), cleared on a fresh one (no
* `Last-Event-ID`) — never a one-way latch, or an aborted resume that skipped
* its flush would strand the flag and buffer every later reply forever. Also
* cleared when the ring replay boundary passes (`replay_complete` →
* `flushBufferedSessionFrames`). While set, OUT-OF-BAND session JSON-RPC
* replies (`replySession` — e.g. a `session/prompt` result that finishes
* mid-replay) are deferred into `buffer` instead of written live, so they
* can't overtake replay frames that haven't been sent yet. The flush boundary
* is `replay_complete` ONLY: `state_resync_required` is deliberately NOT one —
* the EventBus emits it BEFORE the replay frames, so flushing there would put
* deferred replies ahead of replayed content (the §1.8 reordering bug). In-band
* pump frames (`translateEvent`, including the `replay_complete` frame itself)
* are unaffected — they're already produced in replay order.
*/
replayPending?: boolean;
}
/** An agent→client request awaiting the client's JSON-RPC response. */
@@ -164,6 +222,22 @@ export class AcpConnection {
* `maxConnections`) for the full 30-min idle TTL.
*/
connGraceTimer?: ReturnType<typeof setTimeout>;
/**
* True once the connection grace timer has fired. The timer is one-shot, so
* if it fired while a session was still mid-reconnect (`hasRecoverableSession`
* blocked the reap), nothing would re-check the connection after that session
* later tore down — it would linger until the 30-min idle sweep. This flag
* lets `onSessionGraceExpired` (fired when a session grace expires) recognize
* "the conn grace already elapsed" and run the reap that was deferred.
*/
connGraceExpired = false;
/**
* Set by the transport layer (`index.ts`) when the connection grace timer is
* armed. Invoked after a session's reclaim grace expires so a connection that
* was blocked from reaping by a then-recoverable session gets re-evaluated
* instead of leaking until the idle sweep.
*/
onSessionGraceExpired?: () => void;
lastActiveMs: number = Date.now();
private idCounter = 0;
@@ -266,12 +340,32 @@ export class AcpConnection {
return false;
}
/**
* True if any session is mid-reconnect: its transport stream detached but
* its `SESSION_GRACE_MS` reclaim window is still armed (`graceTimer` set),
* so the binding — ownership + in-flight prompt — is being held open for an
* imminent resume. The connection reaper must count these as activity:
* otherwise, when the connection-scoped stream closes first and a session
* stream detaches just after, the connection grace timer could delete the
* whole connection (and `destroy()` abort the prompt) while the session is
* still inside its OWN grace window — the reconnect would then 404.
*/
hasRecoverableSession(): boolean {
for (const b of this.sessions.values()) {
if (b.graceTimer) return true;
}
return false;
}
/** Cancel a pending grace-period reap (e.g. on conn-stream reconnect). */
clearGraceTimer(): void {
if (this.connGraceTimer) {
clearTimeout(this.connGraceTimer);
this.connGraceTimer = undefined;
}
// A fresh grace window (or a reconnect that cancels one) starts clean — the
// prior window's "already expired" verdict must not carry over.
this.connGraceExpired = false;
}
/** Attach the connection-scoped stream and flush any buffered frames. */
@@ -293,13 +387,95 @@ export class AcpConnection {
* creating here would resurrect a ghost binding (no stream, no owner) that
* buffers up to 256 late pump/reply frames forever.
*/
sendSession(sessionId: string, frame: unknown): void {
sendSession(sessionId: string, frame: unknown, id?: number): void {
const binding = this.sessions.get(sessionId);
if (!binding) return;
if (binding.stream && !binding.stream.isClosed) {
void binding.stream.send(frame);
void binding.stream.send(frame, id);
} else {
pushCapped(binding.buffer, frame, `session ${sessionId}`);
pushCapped(
binding.buffer,
{ frame, id },
`session ${sessionId}`,
(e) => e.id,
);
}
}
/**
* Send an OUT-OF-BAND session JSON-RPC reply (a `session/prompt` result and
* friends from `replySession`) — id-less on the wire (no ring cursor).
*
* Unlike `sendSession`, this defers the reply behind a watermark while a ring
* replay is catching up. `anchorId` is the bus head id at the moment the reply
* was produced (every content event that should precede the reply has id ≤
* `anchorId`). The reply is held until the pump has DELIVERED through that id
* (`releaseDeferredSessionReplies`), so a prompt that finishes during a slow
* replay can't land ahead of tail content still queued behind `replay_complete`
* (§1.8 W1 — the truncated-body reordering).
*
* It defers when EITHER a resume replay is in flight (`replayPending`) OR the
* stream is detached OR replies are already queued ahead of it (`buffer` not
* empty) — that last case keeps a just-produced reply from overtaking an
* earlier one still waiting on its watermark. Once the buffer drains and replay
* is done, replies go straight to the wire (steady state, unchanged from a
* non-resumed stream).
*/
sendSessionReply(sessionId: string, frame: unknown, anchorId?: number): void {
const binding = this.sessions.get(sessionId);
if (!binding) return;
// Steady state — live stream, no replay in flight, nothing queued ahead —
// goes straight to the wire (same as a never-resumed stream). Otherwise
// defer with the watermark so ordering is preserved while catching up.
if (
binding.stream &&
!binding.stream.isClosed &&
!binding.replayPending &&
binding.buffer.length === 0
) {
void binding.stream.send(frame);
return;
}
pushCapped(
binding.buffer,
{ frame, id: undefined, anchorId },
`session ${sessionId}`,
(e) => e.id,
);
}
/**
* Release deferred out-of-band replies (`sendSessionReply`) whose watermark
* (`anchorId`) the pump has now passed: every leading id-less buffer entry
* with `anchorId ≤ deliveredId` is flushed to the live stream, in order. The
* pump calls this after delivering each content event (and at `replay_complete`
* with the last replayed id), so each reply lands immediately AFTER the last
* content event that preceded it — never ahead of tail content still queued.
*
* Stops at the first entry that is id-bearing (not a deferred reply) or whose
* anchor is still ahead of `deliveredId`, preserving stream order. No-op if the
* stream isn't live (the entries stay buffered for the next attach).
*/
releaseDeferredSessionReplies(sessionId: string, deliveredId: number): void {
const binding = this.sessions.get(sessionId);
if (!binding || !binding.stream || binding.stream.isClosed) return;
while (binding.buffer.length > 0) {
const front = binding.buffer[0];
// Only id-less deferred replies are watermark-gated; anything else (a
// stray id-bearing frame) marks the end of the releasable prefix.
if (front.id !== undefined) break;
if (front.anchorId === undefined) {
// No watermark (rare: the bus head was unreadable when the reply was
// produced — a session-teardown race). It can't be sequenced against a
// content id, so hold it until the replay boundary passes
// (`endReplayDeferral` clears `replayPending`, then this releases it).
// Releasing mid-replay could land it ahead of not-yet-sent content.
if (binding.replayPending) break;
} else if (front.anchorId > deliveredId) {
break;
}
binding.buffer.shift();
void binding.stream.send(front.frame);
}
}
@@ -313,26 +489,252 @@ export class AcpConnection {
sessionId: string,
stream: TransportStream,
abort: AbortController,
resumeFromEventId?: number,
): SessionBinding {
const binding = this.getOrCreateSession(sessionId);
// Reclaim: a reconnect within the grace window cancels the pending
// teardown so ownership/prompt survive the transport-level blip. Log it so
// an operator can tell "reclaimed within grace" apart from a first attach
// (the detach + grace-expiry paths already log; this completes the trail).
if (binding.graceTimer) {
clearTimeout(binding.graceTimer);
binding.graceTimer = undefined;
writeStderrLine(
`qwen serve: /acp session reclaimed within grace (${logSafe(sessionId)})`,
);
}
const prevStream = binding.stream;
binding.abort.abort();
binding.abort = abort;
// Install the NEW stream BEFORE closing the old one. The old stream's
// `onClose` is identity-guarded on `binding.stream` (see the session-GET
// handler in `index.ts` — `if (conn.sessions.get(sessionId)?.stream ===
// stream) ...promptAbort?.abort()`), so installing first means a
// reconnect's close can't abort the in-flight prompt (the client is
// reconnecting, not leaving — the prompt must survive). CONTRACT: that
// identity guard and this ordering must stay in lockstep.
// Install the NEW stream BEFORE closing the old one. Each stream's event
// pump has its OWN abort controller, and the post-pump teardown in the
// session-GET handler (`index.ts` `onPumpSettled`) is identity-guarded on
// `binding.stream`: a settling stream only acts if it is STILL the bound
// stream (`conn.sessions.get(sessionId)?.stream === stream`). Installing
// first means the old stream settles against a binding that already points
// at the new stream, so it falls into detach-with-grace instead of tearing
// down the in-flight prompt — the client is reconnecting, not leaving, and
// the prompt must survive. CONTRACT: that identity guard and this ordering
// must stay in lockstep.
binding.stream = stream;
if (prevStream && prevStream !== stream && prevStream !== this.connStream) {
prevStream.close();
}
for (const frame of binding.buffer.splice(0)) void stream.send(frame);
// Flush buffered pre-attach frames produced during the detach gap.
//
// FRESH CONNECT (`resumeFromEventId === undefined`, no `Last-Event-ID`):
// there's no ring replay, so the buffer is the only delivery path — flush
// everything now, in order.
//
// RESUME (`resumeFromEventId !== undefined`): the ring replay the event pump
// starts at that cursor already redelivers every BUS event (`id !==
// undefined`) after the cursor — including frames lost in-flight to the dead
// socket — so we do NOT flush id-bearing frames here (flushing would
// double-deliver, and advancing a cursor past them to dedupe would silently
// drop an in-flight-lost frame whose id sits below the buffer's ids).
//
// Id-LESS frames are JSON-RPC replies (`replySession`), NOT ring events, so
// the ring won't redeliver them. But flushing them HERE — before replay —
// would deliver e.g. a `session/prompt` result BEFORE the ring replays the
// content chunks that preceded it, so the client would see "prompt complete"
// ahead of the body (the exact truncated-body failure §1.8 fixes). So on
// resume we DEFER id-less frames: leave them in the buffer for the pump to
// flush after `replay_complete` (`flushBufferedSessionFrames`), preserving
// original stream order.
// Set the deferral flag from the CURRENT attach mode every time — never a
// one-way latch. Resume arms it (keep deferring out-of-band replies until
// the replay boundary passes — not just the ones already buffered from the
// detach gap, but any prompt that finishes DURING the replay window). A
// fresh connect CLEARS it: the prior resumptive attach may have been aborted
// before its pump reached `replay_complete` (the normal reclaim path skips
// the flush), which would otherwise leave the flag stuck true and buffer
// every later `sendSessionReply` — including `session/prompt` results —
// behind a replay boundary this live-only subscription will never emit.
binding.replayPending = resumeFromEventId !== undefined;
if (binding.replayPending) {
// Breadcrumb: while armed, `sendSessionReply` defers every out-of-band
// reply (e.g. `session/prompt` results) 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. Logging the arm gives operators a starting point when responses
// look stuck behind the replay window.
writeStderrLine(
`qwen serve: /acp replay deferral armed (${logSafe(sessionId)}, from id ${resumeFromEventId})`,
);
}
// Drain the gap buffer into a separate array first: on the resume path the
// loop pushes id-less entries BACK into `binding.buffer`, so iterating the
// live buffer would be re-entrant. `splice(0)` snapshots + clears in one
// step; the explicit local makes the copy-semantics invariant visible.
const gap = binding.buffer.splice(0);
for (const entry of gap) {
if (resumeFromEventId === undefined) {
void stream.send(entry.frame, entry.id); // fresh connect: flush all now
} else if (entry.id !== undefined) {
// Resume: ring replay owns bus events, so drop the buffered copy to
// avoid double-delivery (the same id arrives via replay). This branch is
// near-vacuous on the resume path: id-bearing frames are buffered ONLY
// by `sendSession` from the event pump, which is aborted the moment the
// stream detaches — so during the detach gap that precedes a resume the
// buffer accumulates only id-LESS out-of-band replies. Even the rare
// race (an in-flight `translateEvent` buffering one id-bearing frame
// just after detach) is covered: that id is still in the ring and the
// replay redelivers it. The only residual loss needs that frame to ALSO
// be ring-evicted before reconnect, which the replay signals to the
// client as `state_resync_required` (not a silent gap) — strictly better
// than the pre-resume live-only behaviour, which lost every gap frame.
continue;
} else {
binding.buffer.push(entry); // resume: defer id-less past replay
}
}
return binding;
}
/**
* The ring replay drained (`replay_complete`): stop deferring NEW replies that
* are anchored within the replayed range, and release every buffered reply
* whose watermark the replay already passed (`anchorId ≤ lastReplayedId`).
*
* Replies anchored ABOVE the replayed range stay buffered — their content
* hasn't been delivered yet because the turn was still running at reconnect
* (the content flows as LIVE events after `replay_complete`). The per-event
* `releaseDeferredSessionReplies` calls drain those as the matching live
* content arrives, so a result produced during a slow replay still lands after
* its tail content (§1.8 W1), not at the boundary.
*/
endReplayDeferral(
sessionId: string,
lastReplayedId: number,
evictionOccurred = false,
): void {
const binding = this.sessions.get(sessionId);
if (!binding) return;
// Replay boundary passed → new replies are no longer gated by the replay
// window itself (they may still defer behind a non-empty buffer).
binding.replayPending = false;
if (evictionOccurred) {
// The replay emitted a `state_resync_required` (ring eviction / epoch
// reset): anchor events may have been dropped from the ring and will
// NEVER be delivered. A watermark-gated release would then leave any
// reply anchored above the surviving range deferred forever — and because
// `sendSessionReply` gates inline delivery on an EMPTY buffer, every later
// reply piles up behind it too (a cascading freeze: agent runs, heartbeats
// flow, but no result ever reaches the client). With the ordering
// guarantee already void after an eviction, release everything now — an
// ordering imperfection beats a permanently frozen session.
this.flushBufferedSessionFrames(sessionId);
return;
}
this.releaseDeferredSessionReplies(sessionId, lastReplayedId);
}
/**
* Final, UNCONDITIONAL flush of everything still buffered — used when the pump
* ends with no more events coming (clean iterator end / live-only subscription
* with no replay boundary). Releases any remaining deferred replies regardless
* of watermark: their anchored content will never arrive, so holding them
* would strand the result forever.
*
* No-op if the session has no live stream (frames stay buffered for the next
* attach) — but `replayPending` is cleared regardless, since no replay is in
* flight once the pump has settled.
*
* The frames are enqueued synchronously, in buffer order: `SseStream`
* serializes every `send` through one `writeChain`, so wire order is fixed by
* call order here. We deliberately do NOT `await` each `send` between `shift`s
* — doing so would open a window where a live event arriving mid-drain enqueues
* BETWEEN two deferred frames, reordering the very replies this deferral exists
* to keep in order (§1.8 W1).
*/
flushBufferedSessionFrames(sessionId: string): void {
const binding = this.sessions.get(sessionId);
if (!binding) return;
// Pump settled → no replay in flight; stop deferring out-of-band replies.
binding.replayPending = false;
if (
!binding.stream ||
binding.stream.isClosed ||
binding.buffer.length === 0
)
return;
for (const { frame, id } of binding.buffer.splice(0)) {
void binding.stream.send(frame, id);
}
}
/**
* Transport-level session-stream close (proxy idle-close / network blip) —
* as opposed to an explicit `session/close`. Detaches ONLY the stream and
* its event subscription while KEEPING the binding, ownership, the in-flight
* prompt, and the bridge-client registration, so a reconnect within
* `graceMs` can resume (ring replay backfills the gap — §1.8). If no
* reconnect arrives, the grace timer runs the full `closeSessionStream`
* teardown, bounding the runaway-prompt cost. Identity-guarded: a stale
* stream's close can't detach a newer reconnect's stream.
*/
detachSessionStream(
sessionId: string,
stream: TransportStream,
graceMs: number,
): void {
const binding = this.sessions.get(sessionId);
if (!binding || binding.stream !== stream) return;
// Breadcrumb at the moment of detach so an operator can measure the actual
// disconnect→reconnect gap against the grace window (the reclaim/expiry
// logs alone can't tell "reclaimed with 0.5s to spare" from "9.5s").
writeStderrLine(
`qwen serve: /acp session stream detached (${logSafe(sessionId)}), ` +
`grace=${graceMs}ms`,
);
// Stop the closing stream's event pump; the prompt + ownership live on.
binding.abort.abort();
// Drop the stream ref so frames produced during the gap buffer until the
// reconnect re-attaches and flushes them.
binding.stream = undefined;
if (binding.graceTimer) clearTimeout(binding.graceTimer);
binding.graceTimer = setTimeout(() => {
// Grace expired with no reconnect → full teardown (aborts the prompt,
// releases ownership, detaches the bridge client). Log it so an operator
// debugging a vanished session can tell grace-expiry teardown apart from
// an explicit `session/close` or connection drop.
writeStderrLine(
`qwen serve: /acp session grace expired (${logSafe(sessionId)}), ` +
`no reconnect within ${graceMs}ms — tearing down`,
);
// `closeSessionStream` → `teardownBinding` runs external callbacks
// (`abandonPendingForSession`, `onDetachSession`) that can throw. This
// runs from a bare `setTimeout`, so an uncaught throw would crash the
// whole daemon process (taking down every other session). `destroy()`
// guards `teardownBinding` for the same reason; mirror it here.
try {
this.closeSessionStream(sessionId);
} catch (err) {
writeStderrLine(
`qwen serve: /acp teardown failed during grace expiry ` +
`(${logSafe(sessionId)}): ` +
(err instanceof Error ? err.message : String(err)),
);
}
// Grace expiry may have removed the last recoverable session that was
// blocking a pending connection reap; let the owner re-check. Guard it in
// its OWN try/catch (separate from the teardown above, so it runs even if
// teardown threw): this callback is owner-supplied and still runs from the
// bare `setTimeout`, so an uncaught throw here would equally crash the
// daemon.
try {
this.onSessionGraceExpired?.();
} catch (err) {
writeStderrLine(
`qwen serve: /acp onSessionGraceExpired failed during grace expiry ` +
`(${logSafe(sessionId)}): ` +
(err instanceof Error ? err.message : String(err)),
);
}
}, graceMs);
binding.graceTimer.unref?.();
}
closeSessionStream(sessionId: string): void {
const binding = this.sessions.get(sessionId);
if (!binding) return;
@@ -361,6 +763,10 @@ export class AcpConnection {
}
private teardownBinding(binding: SessionBinding): void {
if (binding.graceTimer) {
clearTimeout(binding.graceTimer);
binding.graceTimer = undefined;
}
binding.abort.abort();
binding.promptAbort?.abort();
// Don't close the stream if it's the shared connStream (WS reuses
@@ -399,12 +805,53 @@ export class AcpConnection {
}
}
function pushCapped(buf: unknown[], frame: unknown, label = 'stream'): void {
function pushCapped<T>(
buf: T[],
frame: T,
label = 'stream',
getId?: (entry: T) => number | undefined,
): void {
if (buf.length >= MAX_BUFFERED_FRAMES) {
buf.shift();
writeStderrLine(
`qwen serve: /acp pre-attach buffer full (${label}), dropped oldest frame`,
);
// Prefer evicting a REPLAYABLE id-bearing frame over an irreplaceable
// id-less one. On the session buffer, id-bearing entries are EventBus
// events the ring redelivers on reconnect, while id-less entries are
// deferred JSON-RPC replies (`sendSessionReply`) the ring does NOT track —
// dropping one would hang the `session/prompt` caller forever. So under a
// content flood during a detach gap, evict the oldest id-bearing frame and
// keep the reply.
const replayable = getId ? buf.findIndex((e) => getId(e) !== undefined) : 0;
if (replayable === -1) {
// Degenerate case: the buffer is ENTIRELY id-less deferred replies, so
// there is nothing replaceable to evict. Dropping one would silently hang
// its caller (the exact failure this guard prevents), so we do NOT drop at
// the soft cap — id-less replies are bounded in practice by in-flight RPC
// count. But enforce a HARD ceiling as defense-in-depth: past it, an
// unbounded daemon heap is the worse failure, so drop the oldest and log
// loudly.
if (buf.length >= HARD_BUFFERED_FRAMES_CAP) {
buf.shift();
writeStderrLine(
`qwen serve: /acp HARD buffer cap breached (${label}) — dropping ` +
`oldest id-less reply (its caller may hang); buffer was ${
buf.length + 1
}`,
);
} else if (buf.length === MAX_BUFFERED_FRAMES) {
// Log ONCE, at the soft-cap transition — not on every subsequent push,
// which would scale linearly with the over-cap depth.
writeStderrLine(
`qwen serve: /acp pre-attach buffer over soft cap (${label}) — ` +
`id-less replies are irreplaceable, not dropping`,
);
}
} else {
const [dropped] = buf.splice(replayable, 1);
const droppedId = getId?.(dropped);
writeStderrLine(
`qwen serve: /acp pre-attach buffer full (${label}), dropped frame` +
(droppedId !== undefined ? ` id ${droppedId}` : ''),
);
}
}
buf.push(frame);
}
+182 -9
View File
@@ -2738,22 +2738,98 @@ export class AcpDispatcher {
conn: AcpConnection,
sessionId: string,
signal: AbortSignal,
lastEventId?: number,
): Promise<void> {
try {
const iterable = this.bridge.subscribeEvents(sessionId, { signal });
// `lastEventId` (from the `Last-Event-ID` reconnect header) drives the
// EventBus ring replay: events with `id > lastEventId` still buffered
// are replayed before live events flow, recovering content frames lost
// in a mid-turn proxy gap (§1.8). `undefined` ⇒ live-only, as before.
const iterable = this.bridge.subscribeEvents(sessionId, {
signal,
...(lastEventId !== undefined ? { lastEventId } : {}),
});
// On resume, `attachSessionStream` defers id-less buffered replies (e.g. a
// `session/prompt` result produced during the detach gap) so they land
// AFTER the content chunks that preceded them. Each deferred reply carries
// a WATERMARK (`anchorId` = the bus head id when it was produced); release
// it only once the pump has DELIVERED through that id. We track the highest
// delivered id and, after each content frame, release replies the pump has
// now caught up to (`releaseDeferredSessionReplies`).
//
// `replay_complete` is the boundary for the REPLAY range only: it releases
// replies anchored within the replayed frames and stops gating new replies
// on the replay window. Replies anchored ABOVE the replay range (the turn
// was still running at reconnect, so the tail arrives as LIVE events after
// `replay_complete`) keep deferring until the per-event release below
// delivers their content — a result produced during a slow replay must not
// jump ahead of that tail (§1.8 W1, MsyIt).
//
// NOT released on `state_resync_required`: the EventBus emits that frame
// FIRST, before the replay frames (both the `epoch_reset` and
// `ring_evicted` paths fall through to the replay loop + `replay_complete`).
// It is id-less, so the per-event release skips it anyway.
let lastDeliveredId = lastEventId ?? 0;
// A `state_resync_required` means the ring evicted frames (overflow /
// epoch reset), so an anchor event a deferred reply is waiting on may
// never be delivered. Track it so `endReplayDeferral` can release ALL
// deferred replies (anchor guarantee void) instead of stranding them
// behind an unreachable watermark — the cascading-freeze fix.
let sawEviction = false;
for await (const event of iterable) {
if (signal.aborted) break;
// Count event delivery as connection activity so a long, quiet prompt
// (no inbound HTTP) isn't reaped by the idle-TTL sweep.
conn.touch();
this.translateEvent(conn, sessionId, event);
if (event.type === 'state_resync_required') sawEviction = true;
if (typeof event.id === 'number') {
lastDeliveredId = event.id;
conn.releaseDeferredSessionReplies(sessionId, event.id);
}
if (event.type === 'replay_complete') {
conn.endReplayDeferral(sessionId, lastDeliveredId, sawEviction);
// Operator breadcrumb for "did resume recover the gap?": one line per
// resumed stream stating the cursor it resumed from, how far delivery
// reached, the bus-reported replayed count, and whether the ring had
// evicted frames (a gap the replay could not fully backfill).
const replayedCount = (event.data as { replayedCount?: number })
?.replayedCount;
writeStderrLine(
`qwen serve: /acp replay complete (${logSafe(sessionId)}) ` +
`from=${lastEventId ?? 'none'} delivered_through=${lastDeliveredId} ` +
`count=${replayedCount ?? 'n/a'} evicted=${sawEviction}`,
);
}
}
// Safety: a live-only subscription (no cursor → no replay boundary) or a
// clean end without a boundary frame still releases anything STILL deferred
// (its anchored content will never arrive now) — but NOT if this pump was
// aborted. An abort means the stream was detached/reclaimed; flushing here
// could drain the deferred reply onto a RECLAIMING stream ahead of its own
// replay (reintroducing the very out-of-order delivery the deferral
// prevents). The reclaiming pump owns the buffer then and will release
// after its replay boundary. On an iterator error mid-replay the catch
// below performs the SAME flush (a re-throw with the stream still open
// closes it outright rather than detaching with grace, so the buffered
// replies must be released there too — otherwise they are lost).
if (!signal.aborted) conn.flushBufferedSessionFrames(sessionId);
} catch (err) {
// Symmetric for the SYNC `subscribeEvents` throw and a MID-STREAM
// iterator error: surface a `stream_error` to the client, then re-throw
// so the caller's `.catch()` closes the stream. Returning would leave a
// zombie SSE stream (heartbeats, no events, no reconnect signal).
if (!signal.aborted) {
// The iterator has terminated (errored), so no more content frames will
// arrive through this pump and the ordering constraint the deferral
// protected against no longer applies. Flush any still-deferred session
// replies onto the (still-open) stream BEFORE signalling stream_error:
// the re-throw drives `onPumpSettled`, and while the stream is still
// open that takes the `closeSessionStream` branch (full teardown, NOT a
// detach-with-grace), which would otherwise DROP the buffered replies
// instead of preserving them for a reconnect. Same safety flush as the
// happy-path completion above.
conn.flushBufferedSessionFrames(sessionId);
conn.sendSession(
sessionId,
notification(`${QWEN_METHOD_NS}notify`, {
@@ -2777,7 +2853,13 @@ export class AcpDispatcher {
switch (event.type) {
case 'session_update': {
// `event.data` is the ACP `SessionNotification` (params shape).
conn.sendSession(sessionId, notification('session/update', event.data));
// `event.id` is the bus cursor → SSE `id:` line for `Last-Event-ID`
// resume (the content frames §1.8 recovers all flow through here).
conn.sendSession(
sessionId,
notification('session/update', event.data),
event.id,
);
return;
}
case 'permission_request': {
@@ -2796,6 +2878,22 @@ export class AcpDispatcher {
// there is none, cancel (deny-safe) rather than register+stall.
const binding = conn.sessions.get(sessionId);
if (!binding?.stream || binding.stream.isClosed) {
// KNOWN GAP (tracked as the §1.7 cross-connection permission
// follow-up): when this fires DURING a reconnect grace window
// (`binding.graceTimer` set), the prompt is intentionally kept alive,
// but the permission is still cancel-denied here — so a client
// reconnecting within grace can't vote on it (ring replay re-delivers
// the request, but the mediator already resolved it cancelled). Log a
// breadcrumb so an operator can correlate an auto-denied permission
// with a transient disconnect. The structural fix (defer the
// permission across grace) belongs with the permission-coordination
// follow-up, not this content-stream PR.
if (binding?.graceTimer) {
writeStderrLine(
`qwen serve: /acp permission cancel during reconnect grace ` +
`(${logSafe(sessionId)}); vote not deferred (see §1.7 follow-up)`,
);
}
const cancelled = this.cancelAbandonedPermission(
{ sessionId, bridgeRequestId: data.requestId },
// Pass the bridge-stamped clientId when the binding still exists
@@ -2816,12 +2914,52 @@ export class AcpDispatcher {
}
return;
}
const id = conn.nextId();
conn.pending.set(id, {
sessionId,
bridgeRequestId: data.requestId,
kind: 'permission',
});
// Idempotent under ring replay: a `permission_request` is an
// id-bearing ring event, so a reconnect whose `Last-Event-ID` precedes
// a still-unanswered request replays it here. Reuse the existing
// pending entry for this bridge requestId (re-send the SAME outbound id
// for catch-up) instead of minting a second id + entry — which would
// orphan one until teardown and double-prompt a client that doesn't
// dedupe on `_meta.requestId`.
//
// KNOWN LIMITATION (already-resolved replay): if the client ALREADY
// voted, the vote handler consumed the pending entry, so the scan below
// finds no match and mints a fresh entry + re-sends the prompt. The
// re-sent prompt carries the SAME `_meta.requestId`, so a conformant
// client (the dedupe contract this whole replay path already relies on)
// recognises and drops it; the only residual is a transient orphan
// pending entry, reaped by `abandonPendingForSession` at teardown — the
// agent does NOT stall, since its permission was resolved by the prior
// vote. Full response-replay idempotency for non-deduping clients (an
// LRU of resolved requestIds re-sending the recorded outcome) belongs
// with the permission-coordination follow-up (§1.7), not this
// content-stream PR, as it would add resolved-permission state to the
// vote path.
let id: string | undefined;
for (const [existingId, p] of conn.pending) {
if (
p.kind === 'permission' &&
p.sessionId === sessionId &&
p.bridgeRequestId === data.requestId
) {
id = existingId;
break;
}
}
if (id === undefined) {
id = conn.nextId();
conn.pending.set(id, {
sessionId,
bridgeRequestId: data.requestId,
kind: 'permission',
});
}
// INVARIANT: this sends straight to `binding.stream` (not via
// `conn.sendSession`) and is safe ONLY because `translateEvent` runs
// synchronously from the pump — `binding.stream` was checked non-null
// above and cannot be detached mid-call. Do NOT introduce an `await`
// between that check and this send: a detach during the gap would set
// `binding.stream = undefined` and this would throw `TypeError`.
void binding.stream.send(
request(id, 'session/request_permission', {
sessionId: data.sessionId,
@@ -2829,6 +2967,9 @@ export class AcpDispatcher {
options: data.options,
_meta: { [QWEN_META_KEY]: { requestId: data.requestId } },
}),
// Carry the bus cursor: a permission request is a real sequenced
// event, so the client must resume past it.
event.id,
);
return;
}
@@ -2841,18 +2982,25 @@ export class AcpDispatcher {
...(event.data as object),
kind: 'stream_error',
}),
// Pass the bus cursor through if present; a synthetic terminal frame
// has no bus id (event.id undefined) so no SSE `id:` line is written.
event.id,
);
return;
}
default: {
// client_evicted / slow_client_warning / state_resync_required /
// model_switched / approval_mode_changed / … → opaque qwen notify.
// `event.id` is undefined for the synthetic control frames (no SSE
// `id:` line, so they don't burn a slot in the resume sequence) and
// set for ring-backed daemon events.
conn.sendSession(
sessionId,
notification(`${QWEN_METHOD_NS}notify`, {
kind: event.type,
data: event.data,
}),
event.id,
);
}
}
@@ -2995,7 +3143,32 @@ export class AcpDispatcher {
// violating the JSON-RPC one-response-per-request contract. Fall back to
// the connection-scoped stream so an id'd request always gets its reply.
if (conn.sessions.has(sessionId)) {
conn.sendSession(sessionId, frame);
// Out-of-band reply: `sendSessionReply` defers it behind an in-flight ring
// replay so a prompt finishing mid-replay can't overtake not-yet-sent
// content frames (§1.8 W1). Anchor the reply to the bus head id NOW —
// every content event that should precede it has id ≤ this — so the pump
// releases it only after delivering through that id, even when the tail
// content is still flowing as live events behind `replay_complete`.
//
// The ACP binding can outlive the bridge session for a beat (concurrent
// teardown); `getSessionLastEventId` throws then. Fall back to an
// unanchored defer (released at the next boundary) — never let a missing
// watermark turn a reply into a thrown error.
let anchorId: number | undefined;
try {
anchorId = this.bridge.getSessionLastEventId(sessionId);
} catch (err) {
// Expected on the teardown race; log a breadcrumb so an operator can
// tell that benign case apart from an unexpected bridge regression that
// starts exercising this fallback (which would otherwise be invisible).
anchorId = undefined;
writeStderrLine(
`qwen serve: /acp replySession(${logSafe(sessionId)}) ` +
`anchor unavailable, deferring unanchored: ` +
logSafe(err instanceof Error ? err.message : String(err)),
);
}
conn.sendSessionReply(sessionId, frame, anchorId);
} else {
// Fallback fired — log it so an operator can correlate "reply arrived on
// the connection stream, not the session stream" with a mid-flight
+104 -32
View File
@@ -23,7 +23,14 @@ import {
import { SseStream } from './sse-stream.js';
import { WsStream } from './ws-stream.js';
import type { RateLimitTier } from '../rate-limit.js';
import { RPC, error as rpcError, isRequest, parseInbound } from './json-rpc.js';
import {
RPC,
error as rpcError,
isRequest,
logSafe,
parseInbound,
} from './json-rpc.js';
import { parseLastEventId } from '../sse-last-event-id.js';
import {
ClientMcpWsConnection,
type ClientMcpServerProvider,
@@ -101,6 +108,16 @@ function extractUpgradeBearer(req: IncomingMessage): string | undefined {
*/
const CONN_GRACE_MS = 10_000;
/**
* Grace window after a SESSION-scoped SSE stream closes at the transport level
* (proxy idle-close, network blip) without an explicit `session/close`. The
* binding — ownership, in-flight prompt, bridge-client — is kept alive so a
* reconnect within the window resumes via ring replay (§1.8) instead of being
* rejected (403) and re-spawning. Short enough to bound the runaway-prompt
* cost if the client never returns. Mirrors `CONN_GRACE_MS`.
*/
const SESSION_GRACE_MS = 10_000;
const WS_EXEMPT_METHODS = new Set([
'_qwen/session/heartbeat',
'_qwen/session/update_metadata',
@@ -469,21 +486,38 @@ export function mountAcpHttp(
// idle TTL. After the grace window, reap UNLESS a reconnect
// re-attached the conn stream (clears the timer) OR a session
// stream is still live (client is active — only the conn stream
// blipped, don't kill its sessions/prompts).
// blipped, don't kill its sessions/prompts) OR a session is itself
// mid-reconnect within its OWN grace window (`hasRecoverableSession`)
// — reaping then would 404 the imminent session resume and abort the
// in-flight prompt before SESSION_GRACE_MS promised.
conn.clearGraceTimer();
conn.connGraceTimer = setTimeout(() => {
// Reap iff the grace has elapsed and this dead conn stream is still
// current with nothing live or mid-reconnect. Shared by the grace
// timer and the post-session-grace re-check (below) so a connection
// blocked from reaping by a then-recoverable session doesn't linger
// until the 30-min idle sweep after that session finally tears down.
const reapConnIfDead = () => {
if (
registry.get(connId) === conn &&
conn.connStream === stream &&
!conn.hasLiveSessionStream()
conn.connGraceExpired &&
!conn.hasLiveSessionStream() &&
!conn.hasRecoverableSession()
) {
writeStderrLine(
`qwen serve: /acp reaping connection ${connId.slice(0, 8)} (conn stream gone, no live session stream)`,
);
registry.delete(connId);
}
};
conn.connGraceTimer = setTimeout(() => {
conn.connGraceExpired = true;
reapConnIfDead();
}, CONN_GRACE_MS);
conn.connGraceTimer.unref?.();
// When a session's reclaim grace expires it may have been the last
// thing blocking this reap; re-evaluate then too.
conn.onSessionGraceExpired = reapConnIfDead;
},
() => conn.touch(),
);
@@ -508,46 +542,84 @@ export function mountAcpHttp(
const stream = new SseStream(
res,
() => {
// Stream closed (tab close / network drop / crash): stop the event
// pump AND abort any in-flight prompt for this session — otherwise
// the agent keeps running (quota, FIFO) until idle TTL.
// Transport-level close (tab close / network drop / proxy idle-close):
// stop THIS stream's event pump only. The prompt + ownership are NOT
// torn down here — `detachSessionStream` (below) keeps them across a
// grace window so a reconnect can resume (§1.8). Only an expired grace,
// an explicit `session/close`, or connection teardown aborts the prompt.
ac.abort();
// BUT only abort the prompt when THIS is still the session's live
// stream. A reconnect already installed a newer stream — the prompt
// must survive the old stream's close. CONTRACT: this identity guard
// pairs with `attachSessionStream`'s install-before-close ordering
// (connection-registry.ts) — keep both in lockstep.
if (conn.sessions.get(sessionId)?.stream === stream) {
conn.sessions.get(sessionId)?.promptAbort?.abort();
}
},
() => conn.touch(),
);
// Open (write SSE headers + `retry:`) BEFORE attaching, so the protocol
// handshake precedes any buffered frames the attach flushes.
stream.open();
conn.attachSessionStream(sessionId, stream, ac);
// Identity-guarded close: only tear down if THIS stream is still the
// session's current one (a reconnect between settle and this microtask
// would otherwise kill the fresh stream).
const closeIfCurrent = () => {
if (conn.sessions.get(sessionId)?.stream === stream) {
// Resume cursor: an EventSource/SSE client auto-resends the last `id:` it
// saw as `Last-Event-ID` on reconnect. Drives the EventBus ring replay so
// content frames produced during a mid-turn proxy gap are recovered (§1.8).
const lastEventId = parseLastEventId(
headerOf(req, 'last-event-id'),
'/acp ',
);
// Pass the resume cursor INTO attach: when resuming, attach skips flushing
// id-bearing buffered frames because the ring replay below redelivers every
// bus event after `lastEventId` exactly once — including any frame lost
// in-flight to the dead socket (whose id sits below the buffer's ids but
// above the client's cursor). Advancing the cursor past the buffer instead
// would silently drop that frame; flushing AND replaying would double-send.
// Id-less JSON-RPC replies are still flushed (they aren't ring events).
conn.attachSessionStream(sessionId, stream, ac, lastEventId);
// When the pump settles, branch on WHY:
// • the transport closed the stream (proxy idle-close / tab close) →
// DETACH with a grace window: keep ownership + the in-flight prompt so a
// reconnect resumes (§1.8); full teardown only if no reconnect arrives.
// • the pump ended while the stream is still open (subprocess done /
// iterator error) → the stream is a zombie; full close now.
// Both are identity-guarded so a stale stream can't act on a newer one.
// INVARIANT (cross-file, mirrors the CONTRACT comment in
// `connection-registry.ts` `attachSessionStream`): the identity checks below
// (`conn.sessions.get(sessionId)?.stream === stream`) are load-bearing and
// depend on `attachSessionStream` installing the NEW stream BEFORE the old
// one's pump settles here. If a refactor closed the old stream first, this
// settling pump would see `stream !== current` and fall into the "superseded"
// no-op while the reclaim is mid-flight — skipping detach-with-grace and
// aborting the prompt instead of keeping it alive for reconnect. The
// identity guard, not a flag, is what keeps a stale close from tearing down a
// fresh reclaim. Covered by connection-registry.test.ts "detachSessionStream
// is a no-op for a stale stream after reclaim (identity guard)".
const onPumpSettled = () => {
if (stream.isClosed) {
// Transport-closed → detach-with-grace. detachSessionStream logs the
// detach breadcrumb itself (with the grace window).
conn.detachSessionStream(sessionId, stream, SESSION_GRACE_MS);
} else if (conn.sessions.get(sessionId)?.stream === stream) {
// Pump ended while the stream is still open (subprocess done / iterator
// error) → the stream is a zombie; full close now. Logged so the
// operator trail can tell this apart from a transport-close detach.
writeStderrLine(
`qwen serve: /acp session stream pump ended while open ` +
`(${logSafe(sessionId)}) — closing`,
);
conn.closeSessionStream(sessionId);
} else {
// Guard mismatch: a stale stream's pump settled after a newer reclaim
// already took over. No-op, but log it so the trail isn't silent.
writeStderrLine(
`qwen serve: /acp session stream pump settled for a superseded ` +
`stream (${logSafe(sessionId)}) — no-op`,
);
}
};
void dispatcher.pumpSessionEvents(conn, sessionId, ac.signal).then(
// NORMAL completion (iterator returned `done` — subprocess ended): close
// so the stream isn't a zombie heartbeating with nothing left to deliver.
closeIfCurrent,
(err: unknown) => {
void dispatcher
.pumpSessionEvents(conn, sessionId, ac.signal, lastEventId)
.then(onPumpSettled, (err: unknown) => {
writeStderrLine(
`qwen serve: /acp event pump error (${sessionId}): ${
err instanceof Error ? err.message : String(err)
}`,
`qwen serve: /acp event pump error (${logSafe(sessionId)}, lastEventId=${
lastEventId ?? 'none'
}): ${logSafe(err instanceof Error ? err.message : String(err))}`,
);
closeIfCurrent();
},
);
onPumpSettled();
});
});
// ── DELETE /acp ────────────────────────────────────────────────────
@@ -63,6 +63,36 @@ describe('SseStream', () => {
);
});
it('send(message, id) prepends an `id:` line before `data:` (resume cursor)', async () => {
const res = mockRes();
const s = new SseStream(res);
s.open();
await s.send({ jsonrpc: '2.0', method: 'session/update', params: {} }, 7);
const joined = (res as unknown as { chunks: string[] }).chunks.join('');
expect(joined).toContain(
'id: 7\ndata: {"jsonrpc":"2.0","method":"session/update","params":{}}\n\n',
);
});
it('send(message) without an id omits the `id:` line (synthetic/response frames)', async () => {
const res = mockRes();
const s = new SseStream(res);
s.open();
await s.send({ jsonrpc: '2.0', id: 1, result: {} });
const joined = (res as unknown as { chunks: string[] }).chunks.join('');
expect(joined).toContain('data: {"jsonrpc":"2.0","id":1,"result":{}}\n\n');
expect(joined).not.toMatch(/(^|\n)id: /);
});
it('send(message, 0) emits `id: 0` (id 0 is a real cursor, not "absent")', async () => {
const res = mockRes();
const s = new SseStream(res);
s.open();
await s.send({ ping: true }, 0);
const joined = (res as unknown as { chunks: string[] }).chunks.join('');
expect(joined).toContain('id: 0\ndata: {"ping":true}\n\n');
});
it('close() ends the response once and is idempotent', () => {
const res = mockRes();
const s = new SseStream(res);
+13 -6
View File
@@ -18,9 +18,9 @@ import { writeStderrLine } from '../../utils/stdioHelpers.js';
* - respect backpressure (`res.write` → false ⇒ await `drain`),
* - emit periodic comment heartbeats to keep NAT/proxies alive.
*
* This mirrors the battle-tested pattern in `server.ts`'s SSE handler but
* trimmed to what the ACP transport needs (no ring-buffer `id:` sequencing —
* resumability is RFD Phase 4, deferred per the design doc §7).
* This mirrors the battle-tested pattern in `server.ts`'s SSE handler,
* including the optional ring-buffer `id:` sequencing that drives
* `Last-Event-ID` resume (see `docs/design/daemon-acp-http/sse-resumable-stream.md`).
*/
export class SseStream {
readonly kind = 'sse' as const;
@@ -63,9 +63,16 @@ export class SseStream {
this.res.on('error', this.cleanupFn);
}
/** Serialize a JSON-RPC message as one SSE frame. */
send(message: unknown): Promise<void> {
return this.writeRaw(`data: ${JSON.stringify(message)}\n\n`);
/**
* Serialize a JSON-RPC message as one SSE frame. When `id` is supplied
* (a bus event id) prepend an `id:` line so an EventSource/SSE client
* tracks it and resends it as `Last-Event-ID` on reconnect — the resume
* cursor for ring replay. Omitted for JSON-RPC responses and synthetic
* terminal frames (no bus id), matching REST `formatSseFrame`.
*/
send(message: unknown, id?: number): Promise<void> {
const idLine = id !== undefined ? `id: ${id}\n` : '';
return this.writeRaw(`${idLine}data: ${JSON.stringify(message)}\n\n`);
}
get isClosed(): boolean {
@@ -10,7 +10,13 @@
*/
export interface TransportStream {
readonly kind: 'sse' | 'ws';
send(message: unknown): Promise<void>;
/**
* Serialize one frame. `id` is the bus event id (`BridgeEvent.id`) used as
* the SSE `id:` resume cursor — present only for ring-backed session events,
* omitted for JSON-RPC responses and synthetic terminal frames. The
* WebSocket transport ignores it (stateful connection, no SSE replay).
*/
send(message: unknown, id?: number): Promise<void>;
close(): void;
readonly isClosed: boolean;
}
@@ -184,9 +184,19 @@ class FakeBridge {
}
subscribeThrows = false;
/** Records every subscribeEvents call so tests can assert the resume cursor. */
subscribeCalls: Array<{ sessionId: string; lastEventId?: number }> = [];
/** Parallel to `subscribeCalls`: each subscription's abort signal, so a test
* can detect when a closed stream's pump has actually stopped server-side. */
subscribeSignals: Array<AbortSignal | undefined> = [];
subscribeEvents(sessionId: string, opts?: { signal?: AbortSignal }) {
subscribeEvents(
sessionId: string,
opts?: { signal?: AbortSignal; lastEventId?: number },
) {
if (this.subscribeThrows) throw new Error('subscribe failed');
this.subscribeCalls.push({ sessionId, lastEventId: opts?.lastEventId });
this.subscribeSignals.push(opts?.signal);
const q = pushQueue(opts?.signal);
this.queues.set(sessionId, q);
return q.iterable;
@@ -200,6 +210,18 @@ class FakeBridge {
return Promise.resolve({ stopReason: 'end_turn' });
}
/**
* Bus head id the daemon stamps as a deferred reply's WATERMARK (`anchorId`)
* in `replySession`. Configurable per test so the watermark-ordering path can
* be exercised end-to-end with a REAL anchor (without this method the daemon's
* try/catch fell back to `anchorId = undefined`, so every integration test
* only ever hit the unanchored path).
*/
sessionLastEventId: number | undefined = undefined;
getSessionLastEventId(_sessionId: string): number | undefined {
return this.sessionLastEventId;
}
respondToSessionPermission() {
return true;
}
@@ -529,6 +551,53 @@ async function* readSse(
}
}
/**
* Like `readSse` but yields the RAW frame text (so the `id:` resume-cursor
* line is visible — `readSse` only keeps the parsed `data:` payload).
*/
async function* readSseRaw(
res: Response,
signal: AbortSignal,
): AsyncGenerator<string> {
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = '';
signal.addEventListener('abort', () => void reader.cancel().catch(() => {}));
while (true) {
const { value, done } = await reader.read();
if (done) return;
buf += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, idx);
buf = buf.slice(idx + 2);
// Skip the `retry:` hint and comment-only heartbeats (no `data:` line).
if (frame.split('\n').some((l) => l.startsWith('data: '))) yield frame;
}
}
}
/** Read the next N RAW data frames (with `id:` lines) from an SSE response. */
async function takeRawFrames(
res: Response,
n: number,
timeoutMs = 2000,
): Promise<string[]> {
const out: string[] = [];
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
for await (const f of readSseRaw(res, ac.signal)) {
out.push(f);
if (out.length >= n) break;
}
} finally {
clearTimeout(timer);
ac.abort();
}
return out;
}
/** Read the next N data frames from an SSE response, then abort. */
async function takeFrames(
res: Response,
@@ -853,6 +922,397 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
).toBe('end_turn');
});
it('live session/update frames carry an SSE `id:` resume cursor', async () => {
bridge.promptBehavior = async (_s, q) => {
q.push({
type: 'session_update',
data: {
sessionId: 'sess-1',
update: { sessionUpdate: 'agent_message_chunk' },
},
});
await new Promise((r) => setTimeout(r, 20));
return { stopReason: 'end_turn' };
};
const connId = await initialize();
await newSession(connId);
const sessStream = await openStream(connId, 'sess-1');
const got = takeRawFrames(sessStream, 1);
await new Promise((r) => setTimeout(r, 50));
await post(connId, {
jsonrpc: '2.0',
id: 5,
method: 'session/prompt',
params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] },
});
const frames = await got;
// `pushQueue` stamps bus ids from 1, so the first session_update is id 1.
// The frame MUST carry `id: 1` before its `data:` line — that is the
// cursor an SSE client echoes as `Last-Event-ID` on reconnect.
expect(frames[0]).toMatch(/(^|\n)id: 1\ndata: /);
expect(frames[0]).toContain('"method":"session/update"');
});
it('GET Last-Event-ID flows to subscribeEvents as the resume cursor', async () => {
const connId = await initialize();
await newSession(connId);
// Reconnect carrying a cursor → subscribeEvents gets lastEventId=42.
const resumed = await fetch(`${base}/acp`, {
headers: {
accept: 'text/event-stream',
'acp-connection-id': connId,
'acp-session-id': 'sess-1',
'last-event-id': '42',
},
});
await waitUntil(() => bridge.subscribeCalls.length >= 1);
expect(bridge.subscribeCalls.at(-1)).toEqual({
sessionId: 'sess-1',
lastEventId: 42,
});
await resumed.body?.cancel().catch(() => {});
// A non-numeric header is rejected (logged) → live-only (undefined).
const bad = await fetch(`${base}/acp`, {
headers: {
accept: 'text/event-stream',
'acp-connection-id': connId,
'acp-session-id': 'sess-1',
'last-event-id': 'not-a-number',
},
});
await waitUntil(() => bridge.subscribeCalls.length >= 2);
expect(bridge.subscribeCalls.at(-1)).toEqual({
sessionId: 'sess-1',
lastEventId: undefined,
});
await bad.body?.cancel().catch(() => {});
// A fresh stream with no header → live-only (undefined), as before.
const fresh = await openStream(connId, 'sess-1');
await waitUntil(() => bridge.subscribeCalls.length >= 3);
expect(bridge.subscribeCalls.at(-1)).toEqual({
sessionId: 'sess-1',
lastEventId: undefined,
});
await fresh.body?.cancel().catch(() => {});
});
it('real close-then-reconnect order keeps ownership (no 403) + prompt alive, resumes via Last-Event-ID', async () => {
let promptSignal: AbortSignal | undefined;
bridge.promptBehavior = async (_s, q, signal) => {
promptSignal = signal;
q.push({
type: 'session_update',
data: {
sessionId: 'sess-1',
update: { sessionUpdate: 'agent_message_chunk' },
},
});
// Keep the prompt running across the disconnect + reconnect.
await new Promise((r) => setTimeout(r, 1000));
return { stopReason: 'end_turn' };
};
const connId = await initialize();
await newSession(connId);
const s1 = await openStream(connId, 'sess-1');
await waitUntil(() => bridge.subscribeCalls.length >= 1); // pump subscribed
const r1 = frameReader(s1);
const ack = await post(connId, {
jsonrpc: '2.0',
id: 5,
method: 'session/prompt',
params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] },
});
expect(ack.status).toBe(202);
await r1.next(); // first content frame (bus id 1)
// Close the OLD stream FIRST — the real EventSource/proxy order (the
// existing reconnect tests overlap streams, hiding this).
r1.close();
await s1.body?.cancel().catch(() => {});
// Wait until the daemon has PROCESSED the close (old pump's signal aborted).
await waitUntil(() => bridge.subscribeSignals[0]?.aborted === true);
// Detach-with-grace, NOT teardown: the in-flight prompt must survive.
expect(promptSignal?.aborted).toBe(false);
// Reconnect carrying the cursor — must be 200 (ownership kept), not 403.
const s2 = await fetch(`${base}/acp`, {
headers: {
accept: 'text/event-stream',
'acp-connection-id': connId,
'acp-session-id': 'sess-1',
'last-event-id': '1',
},
});
expect(s2.status).toBe(200);
await waitUntil(() => bridge.subscribeCalls.length >= 2);
expect(bridge.subscribeCalls.at(-1)).toEqual({
sessionId: 'sess-1',
lastEventId: 1,
});
expect(promptSignal?.aborted).toBe(false); // still alive after reconnect
await s2.body?.cancel().catch(() => {});
});
it('on resume, a reply buffered during the gap is flushed on replay_complete — AFTER replayed content, NOT on the pre-replay state_resync_required frame', async () => {
// Guards the core §1.8 ordering invariant end-to-end through the pump:
// the EventBus emits `state_resync_required` BEFORE the replay frames (then
// `replay_complete` at the end). If the deferred id-less reply were flushed
// on the resync frame it would land ahead of the replayed content — the
// truncated-body failure this PR fixes. It must flush on `replay_complete`.
let openGate: () => void = () => {};
const gate = new Promise<void>((r) => {
openGate = r;
});
bridge.promptBehavior = async (_s, _q) => {
// Hold the prompt open across the disconnect; its result is produced
// only after the gate opens (i.e. during the detach gap) → buffered.
await gate;
return { stopReason: 'end_turn' };
};
const connId = await initialize();
await newSession(connId);
const s1 = await openStream(connId, 'sess-1');
await waitUntil(() => bridge.subscribeCalls.length >= 1);
const ack = await post(connId, {
jsonrpc: '2.0',
id: 5,
method: 'session/prompt',
params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'go' }] },
});
expect(ack.status).toBe(202);
// Close s1 → detach-with-grace (old pump's signal aborts).
await s1.body?.cancel().catch(() => {});
await waitUntil(() => bridge.subscribeSignals[0]?.aborted === true);
// Now release the prompt: its result is sent while detached → buffered as an
// id-less reply. Settle briefly so it lands in the buffer before reconnect
// (same in-process settle idiom the harness uses elsewhere).
openGate();
await new Promise((r) => setTimeout(r, 50));
// Reconnect carrying a cursor → the buffered id-less reply is DEFERRED.
const s2 = await fetch(`${base}/acp`, {
headers: {
accept: 'text/event-stream',
'acp-connection-id': connId,
'acp-session-id': 'sess-1',
'last-event-id': '0',
},
});
expect(s2.status).toBe(200);
await waitUntil(() => bridge.subscribeCalls.length >= 2);
// Drive the resume sequence the real EventBus would: resync FIRST, then the
// replayed content, then replay_complete.
const q2 = bridge.queues.get('sess-1')!;
q2.push({
type: 'state_resync_required',
data: { reason: 'ring_evicted' },
});
q2.push({
type: 'session_update',
data: {
sessionId: 'sess-1',
update: { sessionUpdate: 'agent_message_chunk', text: 'REPLAYED' },
},
});
q2.push({ type: 'replay_complete', data: { replayedCount: 1 } });
const r2 = frameReader(s2);
const order: string[] = [];
try {
// Read until we've seen the deferred reply (or a safety cap).
for (let i = 0; i < 8 && !order.includes('reply'); i++) {
const f = (await r2.next()) as {
method?: string;
result?: unknown;
params?: { kind?: string; update?: { text?: string } };
};
if (f.method === undefined && 'result' in f) order.push('reply');
else if (f.params?.kind === 'state_resync_required')
order.push('resync');
else if (f.params?.update?.text === 'REPLAYED') order.push('content');
else if (f.params?.kind === 'replay_complete')
order.push('replay_complete');
}
} finally {
r2.close();
}
// The reply must arrive AFTER the replayed content (and thus after the
// pre-replay resync frame) — not flushed early on state_resync_required.
expect(order).toContain('reply');
expect(order).toContain('content');
expect(order.indexOf('content')).toBeLessThan(order.indexOf('reply'));
expect(order.indexOf('resync')).toBeLessThan(order.indexOf('content'));
});
it('on resume, an ANCHORED deferred reply releases mid-replay at its watermark — after the anchor content, BEFORE replay_complete (end-to-end through getSessionLastEventId)', async () => {
// Exercises the anchored watermark path end-to-end: `replySession` stamps
// the reply with `anchorId = getSessionLastEventId(sessionId)` (a real
// number here, not the undefined fallback), so the reply is held until the
// pump has DELIVERED content through that id — then released MID-replay,
// before `replay_complete`. This distinguishes the watermark release from
// the unanchored "release at replay_complete" path (covered above): the
// reply must land between the anchor content and the replay boundary, and
// NOT before the content that precedes its watermark.
bridge.sessionLastEventId = 2; // reply's watermark anchors at bus id 2.
let openGate: () => void = () => {};
const gate = new Promise<void>((r) => {
openGate = r;
});
bridge.promptBehavior = async (_s, _q) => {
await gate; // result produced during the detach gap → buffered.
return { stopReason: 'end_turn' };
};
const connId = await initialize();
await newSession(connId);
const s1 = await openStream(connId, 'sess-1');
await waitUntil(() => bridge.subscribeCalls.length >= 1);
const ack = await post(connId, {
jsonrpc: '2.0',
id: 7,
method: 'session/prompt',
params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'go' }] },
});
expect(ack.status).toBe(202);
// Close s1 → detach-with-grace; release the prompt while detached so its
// reply buffers as a deferred reply WITH anchorId=2.
await s1.body?.cancel().catch(() => {});
await waitUntil(() => bridge.subscribeSignals[0]?.aborted === true);
openGate();
await new Promise((r) => setTimeout(r, 50));
// Reconnect carrying a cursor → replay in flight, the reply is DEFERRED.
const s2 = await fetch(`${base}/acp`, {
headers: {
accept: 'text/event-stream',
'acp-connection-id': connId,
'acp-session-id': 'sess-1',
'last-event-id': '0',
},
});
expect(s2.status).toBe(200);
await waitUntil(() => bridge.subscribeCalls.length >= 2);
// Clean replay (no eviction): two id-bearing content frames straddling the
// watermark, then replay_complete. The reply must release on id=2, NOT id=1.
const q2 = bridge.queues.get('sess-1')!;
q2.push({
type: 'session_update',
id: 1,
data: {
sessionId: 'sess-1',
update: { sessionUpdate: 'agent_message_chunk', text: 'C1' },
},
});
q2.push({
type: 'session_update',
id: 2,
data: {
sessionId: 'sess-1',
update: { sessionUpdate: 'agent_message_chunk', text: 'C2' },
},
});
q2.push({ type: 'replay_complete', data: { replayedCount: 2 } });
const r2 = frameReader(s2);
const order: string[] = [];
try {
// Read past the reply through to the replay boundary so the ordering of
// reply vs replay_complete is observable (the distinguishing assertion).
for (let i = 0; i < 12 && !order.includes('replay_complete'); i++) {
const f = (await r2.next()) as {
method?: string;
result?: unknown;
params?: { kind?: string; update?: { text?: string } };
};
if (f.method === undefined && 'result' in f) order.push('reply');
else if (f.params?.update?.text === 'C1') order.push('c1');
else if (f.params?.update?.text === 'C2') order.push('c2');
else if (f.params?.kind === 'replay_complete')
order.push('replay_complete');
}
} finally {
r2.close();
}
// Held through the pre-watermark content (c1), released ON the watermark
// (c2) MID-replay, and BEFORE the replay boundary — the anchor, not the
// replay_complete boundary, gated the release.
expect(order).toContain('reply');
expect(order).toContain('c1');
expect(order).toContain('c2');
expect(order).toContain('replay_complete');
expect(order.indexOf('c1')).toBeLessThan(order.indexOf('c2'));
expect(order.indexOf('c2')).toBeLessThan(order.indexOf('reply'));
expect(order.indexOf('reply')).toBeLessThan(
order.indexOf('replay_complete'),
);
});
it('GET Last-Event-ID past MAX_SAFE_INTEGER → live-only (undefined)', async () => {
const connId = await initialize();
await newSession(connId);
const overflow = await fetch(`${base}/acp`, {
headers: {
accept: 'text/event-stream',
'acp-connection-id': connId,
'acp-session-id': 'sess-1',
'last-event-id': '9007199254740992', // MAX_SAFE_INTEGER + 1
},
});
await waitUntil(() => bridge.subscribeCalls.length >= 1);
expect(bridge.subscribeCalls.at(-1)).toEqual({
sessionId: 'sess-1',
lastEventId: undefined,
});
await overflow.body?.cancel().catch(() => {});
});
it('a replayed permission_request reuses the pending entry (idempotent, same outbound id)', async () => {
bridge.promptBehavior = async (_s, q) => {
const perm = {
requestId: 'perm-1',
sessionId: 'sess-1',
toolCall: { name: 'shell' },
options: [{ optionId: 'allow', name: 'Allow' }],
};
q.push({ type: 'permission_request', data: perm });
// Simulate a ring replay re-delivering the SAME bridge request (a
// reconnect whose Last-Event-ID precedes the still-pending permission).
q.push({ type: 'permission_request', data: perm });
await new Promise((r) => setTimeout(r, 50));
return { stopReason: 'end_turn' };
};
const connId = await initialize();
await newSession(connId);
const sess = await openStream(connId, 'sess-1');
await waitUntil(() => bridge.subscribeCalls.length >= 1);
const reader = frameReader(sess);
await post(connId, {
jsonrpc: '2.0',
id: 5,
method: 'session/prompt',
params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'go' }] },
});
const f1 = (await reader.next()) as { method: string; id: unknown };
const f2 = (await reader.next()) as { method: string; id: unknown };
expect(f1.method).toBe('session/request_permission');
expect(f2.method).toBe('session/request_permission');
// SAME outbound JSON-RPC id ⇒ one pending entry reused, not a 2nd orphan.
expect(f1.id).toBe(f2.id);
reader.close();
});
it('permission request round-trips agent→client→agent', async () => {
let resolvedWith: unknown;
bridge.respondToSessionPermission = ((
@@ -51,6 +51,19 @@ describe('WsStream', () => {
stream.close();
});
it('send() ignores the bus event id — no SSE `id:` framing on the WS wire', async () => {
// WsStream.send accepts `id` only for TransportStream parity; WebSocket is
// stateful and has no Last-Event-ID replay. The wire payload must be the
// bare JSON message — if a refactor ever let the id leak into the frame it
// would corrupt the WS protocol with SSE-specific framing.
const stream = new WsStream(ws as never);
await stream.send({ data: 1 }, 42);
expect(ws.sent).toEqual(['{"data":1}']);
expect(ws.sent[0]).not.toContain('id:');
expect(ws.sent[0]).not.toContain('42');
stream.close();
});
it('send() serializes writes sequentially (no interleaving)', async () => {
const stream = new WsStream(ws as never);
const p1 = stream.send({ seq: 1 });
+4 -1
View File
@@ -52,7 +52,10 @@ export class WsStream implements TransportStream {
this.heartbeat.unref();
}
send(message: unknown): Promise<void> {
// `_id` (bus event id) is accepted for `TransportStream` parity but ignored:
// WebSocket is a stateful connection with no SSE `Last-Event-ID` replay
// (matches `AcpWsTransport.supportsReplay = false`).
send(message: unknown, _id?: number): Promise<void> {
const data = JSON.stringify(message);
const next = this.writeChain.then(
() =>
@@ -0,0 +1,97 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const stdioMocks = vi.hoisted(() => ({
writeStderrLine: vi.fn(),
}));
vi.mock('../utils/stdioHelpers.js', () => ({
writeStderrLine: stdioMocks.writeStderrLine,
}));
const { parseLastEventId } = await import('./sse-last-event-id.js');
/** The single line `parseLastEventId` logged, or `undefined` if it was silent. */
function loggedLine(): string | undefined {
const calls = stdioMocks.writeStderrLine.mock.calls;
return calls.length > 0 ? (calls[calls.length - 1][0] as string) : undefined;
}
describe('parseLastEventId', () => {
beforeEach(() => {
stdioMocks.writeStderrLine.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('accepts "0" as a valid cursor (not a falsy reject)', () => {
expect(parseLastEventId('0')).toBe(0);
expect(stdioMocks.writeStderrLine).not.toHaveBeenCalled();
});
it('accepts a plain decimal id', () => {
expect(parseLastEventId('42')).toBe(42);
expect(stdioMocks.writeStderrLine).not.toHaveBeenCalled();
});
it.each(['1abc', '1.5', '-1', '0x10', '1e5', ' 7 '])(
'rejects non-decimal %j with a log',
(raw) => {
expect(parseLastEventId(raw)).toBeUndefined();
expect(loggedLine()).toContain('not a decimal integer');
},
);
it('rejects the empty string SILENTLY (the common no-resume first connect)', () => {
expect(parseLastEventId('')).toBeUndefined();
expect(stdioMocks.writeStderrLine).not.toHaveBeenCalled();
});
it('rejects a non-string (undefined header) silently', () => {
expect(parseLastEventId(undefined)).toBeUndefined();
expect(stdioMocks.writeStderrLine).not.toHaveBeenCalled();
});
it('rejects a value past MAX_SAFE_INTEGER with a log', () => {
const tooBig = String(Number.MAX_SAFE_INTEGER) + '0'; // pure digits, overflows
expect(parseLastEventId(tooBig)).toBeUndefined();
expect(loggedLine()).toContain('exceeds Number.MAX_SAFE_INTEGER');
});
it('threads the logPrefix into the rejection log', () => {
parseLastEventId('nope', '/acp ');
expect(loggedLine()).toContain('/acp ');
});
});
describe('safeLogValue (exercised via parseLastEventId rejection logs)', () => {
beforeEach(() => {
stdioMocks.writeStderrLine.mockClear();
});
it('strips CR/LF (log-forging), ANSI ESC and NUL from the logged value', () => {
parseLastEventId('1\r\nINJECTED\x1b[31m\x00x');
const line = loggedLine() ?? '';
expect(line).toContain('not a decimal integer');
// No raw control characters survive into the log line.
// eslint-disable-next-line no-control-regex
expect(line).not.toMatch(/[\x00-\x1f\x7f]/);
// The ESC byte specifically is gone (its trailing "[31m" text is harmless).
expect(line).not.toContain('\x1b');
});
it('truncates an over-long value to ~64 chars with an ellipsis', () => {
const raw = 'z'.repeat(200); // non-decimal ⇒ takes the reject-with-log path
parseLastEventId(raw);
const line = loggedLine() ?? '';
expect(line).toContain('…');
// The clipped value is far shorter than the 200-char input.
expect(line.length).toBeLessThan(raw.length);
});
});
@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { writeStderrLine } from '../utils/stdioHelpers.js';
import { logSafe } from './acp-http/json-rpc.js';
/** Truncate + sanitize an untrusted header value for a single log line. */
function safeLogValue(raw: unknown): string {
const s = typeof raw === 'string' ? raw : String(raw);
const clipped = s.length > 64 ? `${s.slice(0, 64)}…` : s;
// Reuse the shared sanitizer so this surface can't drift: `logSafe` strips C0
// controls + DEL AND the C1 controls (\x80-\x9f, incl. 8-bit CSI \x9b) and
// Unicode bidi/format chars (U+202E RTL override, zero-width, BOM) a crafted
// `Last-Event-ID` could use to spoof or split an operator's stderr log line.
return logSafe(clipped);
}
/**
* Parse a `Last-Event-ID` header into a bus event id for the ACP `GET /acp`
* SSE surface.
*
* NOTE: the REST `GET /session/:id/events` surface still has its own copy in
* `server/request-helpers.ts` (the two implement the same accept/reject rule).
* Unifying them onto this util is a worthwhile cleanup but is deliberately
* deferred: it would change the REST surface, and this PR keeps REST untouched
* (no behavioural side effects). Tracked as a follow-up.
*
* Stricter than `Number.parseInt`: accept ONLY pure decimal digits (so
* "1abc" / "1.5" don't silently parse to 1) and reject values past
* `Number.MAX_SAFE_INTEGER` (the EventBus's monotonic ids are bounded by it).
* Returns `undefined` for missing/invalid headers ⇒ live-only subscription.
* Rejections are logged with the offending value for operators; the common
* "first connect, no resume" case (missing/empty header) is silent.
*
* @param logPrefix distinguishes the surface in logs, e.g. `'/acp '` vs `''`.
*/
export function parseLastEventId(
raw: unknown,
logPrefix = '',
): number | undefined {
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
if (typeof raw === 'string' && raw.length > 0) {
writeStderrLine(
`qwen serve: ${logPrefix}rejected Last-Event-ID ${safeLogValue(raw)} ` +
`(not a decimal integer)`,
);
}
return undefined;
}
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n) || n > Number.MAX_SAFE_INTEGER) {
writeStderrLine(
`qwen serve: ${logPrefix}rejected Last-Event-ID ${safeLogValue(raw)} ` +
`(exceeds Number.MAX_SAFE_INTEGER)`,
);
return undefined;
}
return n;
}
+5
View File
@@ -17,6 +17,11 @@
"import": "./dist/daemon/index.js",
"require": "./dist/daemon/index.cjs"
},
"./daemon/transports": {
"types": "./dist/daemon/transports.d.ts",
"import": "./dist/daemon/transports.js",
"require": "./dist/daemon/transports.cjs"
},
"./package.json": "./package.json"
},
"bin": {
+68 -4
View File
@@ -37,6 +37,11 @@ const rootDir = join(__dirname, '..');
// Bumped from 130KB to 131KB for the workspace MCP resources drill-down
// (workspaceMcpResources client method + route + resource status types).
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 131 * 1024;
// The opt-in `daemon/transports` browser bundle legitimately ships the concrete
// ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so
// it's larger than the default barrel — but still budgeted so a future PR can't
// silently bloat what browser consumers (agent-web) pull in. Current size ~29KB.
const MAX_TRANSPORTS_BROWSER_BUNDLE_BYTES = 48 * 1024;
rmSync(join(rootDir, 'dist'), { recursive: true, force: true });
mkdirSync(join(rootDir, 'dist'), { recursive: true });
@@ -141,6 +146,47 @@ await esbuild.build({
treeShaking: true,
});
// Opt-in transports subpath (`@qwen-code/sdk/daemon/transports`): the concrete
// ACP transports + negotiateTransport. Kept out of the default daemon barrel
// (and its byte budget) so REST-only consumers stay tree-shaken; consumers who
// want resumable ACP-over-HTTP import this entry explicitly. Built as its own
// bundle for both browser (esm) and node (cjs) targets.
await esbuild.build({
entryPoints: [join(rootDir, 'src', 'daemon', 'transports.ts')],
bundle: true,
format: 'esm',
platform: 'browser',
target: 'es2022',
outfile: join(rootDir, 'dist', 'daemon', 'transports.js'),
sourcemap: false,
minify: true,
minifyWhitespace: true,
minifyIdentifiers: true,
minifySyntax: true,
legalComments: 'none',
keepNames: false,
treeShaking: true,
});
assertTransportsBundle(join(rootDir, 'dist', 'daemon', 'transports.js'));
await esbuild.build({
entryPoints: [join(rootDir, 'src', 'daemon', 'transports.ts')],
bundle: true,
format: 'cjs',
platform: 'node',
target: 'node22',
outfile: join(rootDir, 'dist', 'daemon', 'transports.cjs'),
sourcemap: false,
minify: true,
minifyWhitespace: true,
minifyIdentifiers: true,
minifySyntax: true,
legalComments: 'none',
keepNames: false,
treeShaking: true,
});
// Build serve-bridge CLI bin entry
await esbuild.build({
entryPoints: [join(rootDir, 'src', 'daemon-mcp', 'serve-bridge', 'bin.ts')],
@@ -172,10 +218,30 @@ function assertBrowserSafeBundle(filePath) {
`Browser daemon SDK bundle is ${size} bytes; expected <= ${MAX_DAEMON_BROWSER_BUNDLE_BYTES}`,
);
}
assertNoNodeBuiltins(filePath, 'Browser daemon SDK bundle');
}
// Browser-safety + size budget for the opt-in `daemon/transports` bundle.
// Larger budget than the default barrel (it ships the concrete transports), but
// still bounded so a future PR can't silently bloat what browser consumers pull.
function assertTransportsBundle(filePath) {
const size = statSync(filePath).size;
if (size > MAX_TRANSPORTS_BROWSER_BUNDLE_BYTES) {
throw new Error(
`Browser daemon transports bundle is ${size} bytes; expected <= ${MAX_TRANSPORTS_BROWSER_BUNDLE_BYTES}`,
);
}
assertNoNodeBuiltins(filePath, 'Browser daemon transports bundle');
}
// Node-builtin guard, shared by the budget-checked default daemon barrel and
// the opt-in `daemon/transports` bundle. The transports bundle is allowed to
// be larger (it ships the concrete ACP transports), but must still be
// browser-safe — agent-web consumes it in the browser.
function assertNoNodeBuiltins(filePath, label) {
const contents = readFileSync(filePath, 'utf8');
if (contents.includes('node:')) {
throw new Error('Browser daemon SDK bundle contains Node-only token node:');
throw new Error(`${label} contains Node-only token node:`);
}
const forbiddenBuiltins = [
'assert',
@@ -206,8 +272,6 @@ function assertBrowserSafeBundle(filePath) {
);
const found = contents.match(requirePattern);
if (found) {
throw new Error(
`Browser daemon SDK bundle contains Node-only token ${found[0]}`,
);
throw new Error(`${label} contains Node-only token ${found[0]}`);
}
}
@@ -115,9 +115,21 @@ export function denormalizeAcpNotification(
};
}
// Extension path: _qwen/notify is a generic notification envelope.
// Extension path: _qwen/notify is a generic notification envelope. The
// daemon's session-stream `translateEvent` stamps the event name under
// `kind` (e.g. `state_resync_required`, `replay_complete`, `stream_error`),
// while other producers use `type`. Accept either — preferring `type` — so a
// `kind`-tagged notify isn't silently dropped (which would, e.g., starve the
// SDK of the ring-overflow `state_resync_required` signal during a resume).
if (notification.method === '_qwen/notify') {
const type = params['type'];
// Prefer a NON-EMPTY `type`; otherwise fall back to `kind`. An empty-string
// `type` (e.g. a proxy that strips it to '') must not win over a valid
// `kind` and drop the event on the length guard below — that would starve
// the SDK of state_resync_required/replay_complete/stream_error on resume.
const type =
typeof params['type'] === 'string' && params['type'].length > 0
? params['type']
: params['kind'];
if (typeof type !== 'string' || type.length === 0) return undefined;
return {
id: nextSyntheticId++,
@@ -11,8 +11,11 @@ import type {
DaemonTransportSubscribeOptions,
} from './DaemonTransport.js';
import { DaemonTransportClosedError } from './DaemonTransport.js';
import { parseSseStream } from './sse.js';
import type { JsonRpcNotification } from './AcpEventDenormalizer.js';
import { consumeFrames } from './sse.js';
import {
denormalizeAcpNotification,
type JsonRpcNotification,
} from './AcpEventDenormalizer.js';
import {
matchRoute,
synthesizeResponse,
@@ -22,6 +25,40 @@ import {
mergeHeaders,
} from './acpTransportUtils.js';
/**
* Cap the unread SSE buffer of the session-stream parser. Mirrors
* `parseSseStream`'s `MAX_BUF_CHARS` — an unbounded buffer is a memory-pressure
* vector (a tab crash for browser consumers) if a server/proxy never emits a
* frame boundary or serves a non-SSE body.
*/
const MAX_SSE_BUF_CHARS = 16 * 1024 * 1024;
/**
* ACP methods whose JSON-RPC reply the daemon routes onto the SESSION stream
* (`replySession`) rather than the connection stream — every other request
* replies on the connection stream the transport already pumps. For these, a
* `sendRequest` made without an active `subscribeEvents` consumer must open a
* background session-reply pump or it would hang. All require an already-owned
* session, so the pump's `GET /acp` is always authorized.
*
* INVARIANT: this set must mirror the `replySession` call sites in the daemon
* dispatcher at `packages/cli/src/serve/acp-http/dispatch.ts`. That file lives
* in a different package the SDK doesn't import, so the coupling can't be
* type-checked. If a future PR adds a `replySession(...)` for a new method
* without adding it here, a no-subscriber `sendRequest` for that method opens
* no reply pump and HANGS until abort; removing one here that the daemon still
* routes to the session stream has the same effect. A build-time grep check or
* a shared method-name constant would enforce this mechanically — tracked as a
* follow-up (out of this PR's scope).
*/
const SESSION_STREAM_REPLY_METHODS = new Set<string>([
'session/prompt',
'session/cancel',
'session/set_config_option',
'session/set_mode',
'session/set_model',
]);
// ---------------------------------------------------------------------------
// JSON-RPC types
// ---------------------------------------------------------------------------
@@ -47,6 +84,51 @@ interface JsonRpcResponse {
interface PendingRequest {
resolve: (response: JsonRpcResponse) => void;
reject: (error: Error) => void;
/**
* Routing scope. `undefined` ⇒ the reply rides the CONNECTION stream;
* a sessionId ⇒ it rides that SESSION stream (the daemon routes a handful of
* `session/*` method replies there — see `SESSION_STREAM_REPLY_METHODS`). The
* two stream pumps share this one map but must only sweep THEIR OWN scope on
* failure: a connection-stream error must not reject a session-scoped pending
* the session stream is about to resolve, and vice-versa.
*/
sessionId?: string;
}
/**
* Map a `session/request_permission` JSON-RPC request (as the daemon sends it
* on the session-scoped `/acp` stream) to a `permission_request` DaemonEvent,
* mirroring what the REST surface emits so consumers handle it identically.
* The agent-stamped `requestId` (in `_meta.qwen.requestId`) is the correlator
* the eventual vote must echo (§1.7). Returns `undefined` if it can't be read.
*/
function permissionRequestToEvent(
msg: Record<string, unknown>,
busId: number | undefined,
): DaemonEvent | undefined {
const params = isRecord(msg['params']) ? msg['params'] : {};
const meta = isRecord(params['_meta']) ? params['_meta'] : undefined;
const qwenMeta = meta && isRecord(meta['qwen']) ? meta['qwen'] : undefined;
const requestId =
qwenMeta && typeof qwenMeta['requestId'] === 'string'
? qwenMeta['requestId']
: undefined;
if (!requestId) return undefined;
return {
id: busId,
v: 1,
type: 'permission_request',
data: {
requestId,
sessionId:
typeof params['sessionId'] === 'string'
? params['sessionId']
: undefined,
toolCall: params['toolCall'],
options: params['options'],
},
_meta: meta,
};
}
// ---------------------------------------------------------------------------
@@ -63,11 +145,19 @@ interface PendingRequest {
* connection-scoped SSE stream at `GET /acp` for subsequent responses.
*
* Subsequent `POST /acp` requests return 202 (ack); the real JSON-RPC
* response rides the connection-scoped SSE stream. Responses are
* correlated by `id` using a `Map<id, {resolve, reject}>`.
* response rides an SSE stream. Responses are correlated by `id` using a
* `Map<id, {resolve, reject}>` shared across both streams.
*
* Session events are received via a session-scoped SSE stream at
* `GET /acp` with appropriate headers (session filtering).
* Session events AND session-scoped JSON-RPC responses are received via the
* session-scoped SSE stream at `GET /acp` (with `Acp-Session-Id`), which is
* the resumable §1.8 stream the daemon's `replySession` routes session replies
* onto. `subscribeEvents` reads it and dispatches each frame: a JSON-RPC
* response resolves its pending request (so e.g. `session/prompt` doesn't hang
* waiting on a reply it would otherwise never observe), a notification becomes
* a `DaemonEvent`, and a `session/request_permission` request is surfaced as a
* `permission_request` event (responding to it is the §1.7 follow-up). The
* connection-scoped stream still carries replies to connection-level requests
* (e.g. `initialize`, `session/new`).
*/
export class AcpHttpTransport implements DaemonTransport {
private readonly baseUrl: string;
@@ -86,6 +176,26 @@ export class AcpHttpTransport implements DaemonTransport {
private readonly pending = new Map<number, PendingRequest>();
/** Abort controller for the connection-scoped SSE stream. */
private connStreamAbort: AbortController | undefined;
/**
* Per-session count of in-progress `subscribeEvents` iterations. When > 0 a
* consumer is already reading that session's `/acp` stream and routing its
* JSON-RPC replies to `pending` — so `sendRequest` must NOT open a competing
* background reply pump (the daemon's session stream is single-reader; a
* second `GET /acp` would detach the consumer's).
*/
private readonly activeSessionSubscriptions = new Map<string, number>();
/**
* Background reply pumps started by `sendRequest` for a session-scoped
* request when no consumer subscription is active, keyed by sessionId and
* reference-counted so concurrent session requests share one pump. The
* daemon routes replies to `session/prompt` & friends onto the SESSION
* stream (not the connection stream), so without this a `DaemonClient.prompt`
* that never iterates `subscribeEvents` would hang forever.
*/
private readonly sessionReplyPumps = new Map<
string,
{ abort: AbortController; refs: number }
>();
readonly type = 'acp-http' as const;
readonly supportsReplay = true;
@@ -141,14 +251,24 @@ export class AcpHttpTransport implements DaemonTransport {
// For notifications, send via POST /acp and return 204.
if (mapping.notification) {
const params = mapping.extractParams(segments, body, httpMethod);
const params = mapping.extractParams(
segments,
body,
httpMethod,
parsedUrl.searchParams,
);
await this.sendNotification(mapping.method, params, init.headers);
return synthesizeResponse(204, null);
}
// Normal request: POST /acp with the JSON-RPC request body.
// The POST returns 202 (ack); the real response rides the SSE stream.
const params = mapping.extractParams(segments, body, httpMethod);
const params = mapping.extractParams(
segments,
body,
httpMethod,
parsedUrl.searchParams,
);
const response = await this.sendRequest(
mapping.method,
params,
@@ -179,21 +299,99 @@ export class AcpHttpTransport implements DaemonTransport {
opts: DaemonTransportSubscribeOptions = {},
): AsyncGenerator<DaemonEvent> {
if (this._disposed) throw new DaemonTransportClosedError();
// Mark this session as having an active consumer reader for the duration of
// the iteration, so `sendRequest` knows replies are already being routed
// and won't open a competing background pump (single-reader session stream).
this.activeSessionSubscriptions.set(
sessionId,
(this.activeSessionSubscriptions.get(sessionId) ?? 0) + 1,
);
// The session `/acp` stream is single-reader: this consumer's GET will make
// the daemon detach any background reply pump started earlier by a no-iter
// `sendRequest`. Abort that pump NOW so it tears down cleanly (its sweep is
// skipped on abort) — otherwise its dying `.finally` would re-scan `pending`
// and spuriously reject the very `session/prompt` this consumer is taking
// over delivery of. The consumer now owns reply routing for this session.
//
// Delete the map entry SYNCHRONOUSLY (not just abort and let the pump's
// async `.finally` remove it). Otherwise, if this subscription exits before
// that microtask runs, BOTH stranded-pending guards miss: the consumer sweep
// (`subscribeEventsInner` finally) sees the entry still present and defers to
// the pump, while the pump's own sweep is skipped because its signal is
// aborted — leaving a live `session/prompt` in `pending` forever. Removing it
// here makes the consumer sweep deterministically responsible.
const existingPump = this.sessionReplyPumps.get(sessionId);
if (existingPump) {
existingPump.abort.abort();
this.sessionReplyPumps.delete(sessionId);
}
let subscriptionError: Error | undefined;
try {
yield* this.subscribeEventsInner(sessionId, opts);
} catch (err) {
// Capture WHY the subscription ended so the sweep below can reject with
// the real cause. 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 a generic message would hide the actual failure (M4DWx parity).
subscriptionError = err instanceof Error ? err : new Error(String(err));
throw err;
} finally {
const n = (this.activeSessionSubscriptions.get(sessionId) ?? 1) - 1;
if (n <= 0) this.activeSessionSubscriptions.delete(sessionId);
else this.activeSessionSubscriptions.set(sessionId, n);
// This consumer owned reply routing for the session (no reply pump runs
// while a subscription is active). On exit — whether the stream closed
// cleanly OR `subscribeEventsInner` failed fast before its read loop (a
// fetch reject / non-OK / wrong content-type) — if it was the LAST route
// (no other active subscription left, no reply pump), reject its still-live
// session-scoped pendings so a `session/prompt` caller can't hang on a
// reply that will never arrive. This finally ALWAYS runs, so it covers the
// fast-fail path the inner read-loop finally cannot. Mirrors the
// reply-pump and connection-stream sweeps.
if (!this._disposed && n <= 0 && !this.sessionReplyPumps.has(sessionId)) {
const reason =
subscriptionError ??
new Error('Session SSE stream closed unexpectedly');
for (const [id, entry] of this.pending) {
if (entry.sessionId !== sessionId) continue;
entry.reject(reason);
this.pending.delete(id);
}
}
}
}
private async *subscribeEventsInner(
sessionId: string,
opts: DaemonTransportSubscribeOptions = {},
): AsyncGenerator<DaemonEvent> {
await this.ensureInitialized();
// Open a session-scoped SSE stream. For ACP HTTP, we use
// the daemon's per-session SSE endpoint — same URL as REST
// because ACP HTTP sessions still expose SSE for events.
// Open the SESSION-scoped `/acp` stream (GET /acp + Acp-Session-Id), NOT
// REST `/session/:id/events`. This is the resumable §1.8 stream and — the
// reason for this routing — the stream the daemon's `replySession` puts
// session-scoped JSON-RPC *responses* on. Reading it here is what lets a
// `session/prompt` reply resolve its pending request instead of hanging.
const headers: Record<string, string> = {
Accept: 'text/event-stream',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
if (this.connectionId) {
headers['Acp-Connection-Id'] = this.connectionId;
}
headers['Acp-Session-Id'] = sessionId;
if (opts.lastEventId !== undefined) {
headers['Last-Event-ID'] = String(opts.lastEventId);
}
// NOTE: `opts.maxQueued` does NOT apply to this transport. The REST
// `/session/:id/events` surface accepted it as a per-subscription queue
// bound, but the `/acp` session stream is backed by the daemon's
// server-controlled EventBus ring (a fixed `DEFAULT_RING_SIZE`), so there
// is no client-tunable queue to forward it to. It's intentionally ignored
// here rather than silently mis-applied; the field stays on the shared
// `DaemonTransportSubscribeOptions` for the REST transport.
// Connect-phase timeout.
const connectCtrl = new AbortController();
@@ -219,14 +417,12 @@ export class AcpHttpTransport implements DaemonTransport {
? composeAbortSignals([opts.signal, connectCtrl.signal])
: connectCtrl.signal;
let url = `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/events`;
if (opts.maxQueued !== undefined) {
url += `?maxQueued=${encodeURIComponent(String(opts.maxQueued))}`;
}
let res: Response;
try {
res = await this._fetch(url, { headers, signal: fetchSignal });
res = await this._fetch(`${this.baseUrl}/acp`, {
headers,
signal: fetchSignal,
});
} finally {
if (connectTimer !== undefined) clearTimeout(connectTimer);
}
@@ -247,7 +443,7 @@ export class AcpHttpTransport implements DaemonTransport {
body && typeof body === 'object' && 'error' in body
? String((body as { error: unknown }).error)
: `HTTP ${res.status}`;
throw Object.assign(new Error(`GET /session/:id/events: ${detail}`), {
throw Object.assign(new Error(`GET /acp (session stream): ${detail}`), {
status: res.status,
body,
});
@@ -262,7 +458,7 @@ export class AcpHttpTransport implements DaemonTransport {
}
throw Object.assign(
new Error(
`GET /session/:id/events: expected content-type text/event-stream, got "${ct}"`,
`GET /acp (session stream): expected content-type text/event-stream, got "${ct}"`,
),
{ status: res.status, body: ct },
);
@@ -272,7 +468,174 @@ export class AcpHttpTransport implements DaemonTransport {
throw new Error('SSE response has no body');
}
yield* parseSseStream(res.body, opts.signal);
// The `/acp` session stream carries RAW JSON-RPC frames (not REST
// `BridgeEvent` envelopes), so parse them directly and dispatch by shape.
// Each SSE frame may carry an `id:` line — the EventBus cursor we stamp
// onto yielded events so the consumer resumes from the REAL daemon id
// (the denormalizer's synthetic id is not resume-compatible); frames with
// no `id:` (synthetic terminals) yield `id: undefined`, which the consumer
// ignores for Last-Event-ID tracking.
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
const signal = opts.signal;
// `reader.read()` doesn't observe `signal` on its own — race it against an
// abort rejection so dispose()/caller-abort can unblock a hanging read.
// Keep the listener in a named ref and remove it in the `finally`: a
// long-lived signal reused across reconnects (the scenario this resumable
// transport enables) would otherwise accumulate one listener per call.
let onAbort: (() => void) | undefined;
const abortPromise = new Promise<never>((_, reject) => {
if (signal?.aborted) {
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
return;
}
if (signal) {
onAbort = () =>
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
signal.addEventListener('abort', onAbort, { once: true });
}
});
// If `signal` is already aborted at entry, the `while (!signal?.aborted)`
// loop below never runs, so `Promise.race` never consumes this rejection.
// Attach a no-op handler so that early-abort case can't surface as an
// unhandled rejection (Node `UnhandledPromiseRejectionWarning` / browser
// `unhandledrejection`). The race still settles on the same rejection.
abortPromise.catch(() => {});
try {
while (!signal?.aborted) {
const { value, done } = await Promise.race([
reader.read(),
abortPromise,
]);
if (done) break;
buf += decoder.decode(value, { stream: true });
if (buf.length > MAX_SSE_BUF_CHARS) {
throw new Error(
`AcpHttpTransport: unread SSE buffer exceeded ${MAX_SSE_BUF_CHARS} ` +
`bytes without a frame boundary`,
);
}
// Reuse the shared CRLF-aware frame splitter (handles both `\n\n` and
// `\r\n\r\n`) instead of reimplementing it.
const { frames, tail } = consumeFrames(buf);
buf = tail;
for (const rawFrame of frames) {
let busId: number | undefined;
const dataParts: string[] = [];
for (const rawLine of rawFrame.split('\n')) {
// Strip a trailing CR so CRLF line endings don't corrupt JSON.parse.
const line = rawLine.endsWith('\r')
? rawLine.slice(0, -1)
: rawLine;
if (line.startsWith('id:')) {
// Match the server's `parseLastEventId` strictness (pure decimal,
// within MAX_SAFE_INTEGER) so a proxy-mangled `id:` can't seed a
// cursor the daemon would later reject — `Number()` would wave
// through hex / `1e5` / `''`→0.
const raw = line.slice(3).trim();
// A later `id:` line in the same frame overrides an earlier one;
// an INVALID one resets the cursor to undefined rather than
// leaving a stale value from a prior line (so a proxy-mangled
// trailing `id:` can't carry a wrong cursor into the event).
if (/^\d+$/.test(raw)) {
const n = Number.parseInt(raw, 10);
busId =
Number.isFinite(n) && n <= Number.MAX_SAFE_INTEGER
? n
: undefined;
} else {
busId = undefined;
}
} else if (line.startsWith('data:')) {
// Per the SSE spec, multiple `data:` lines in one event join with
// a newline.
dataParts.push(line.slice('data:'.length).replace(/^ /, ''));
}
}
if (dataParts.length === 0) continue;
const dataLine = dataParts.join('\n');
let msg: unknown;
try {
msg = JSON.parse(dataLine);
} catch {
// Non-empty payload that failed to parse ⇒ a corrupt data frame
// (genuine heartbeats/comments carry no `data:` and were filtered
// by the `dataParts.length === 0` guard above). We drop it and move
// on: the SDK has no logger and the package's lint config forbids
// `console`, so there's no in-convention channel to trace it here.
// Surfacing dropped frames is left to a follow-up once the SDK
// grows a logging facility.
continue;
}
if (!isRecord(msg)) continue;
const hasId = 'id' in msg;
const method = (msg as { method?: unknown }).method;
// (1) JSON-RPC response (id, no method) → resolve the pending request.
// THIS is the W2 fix: a `session/prompt` reply routed here by the
// daemon's `replySession` now settles its promise instead of hanging.
if (hasId && typeof method !== 'string') {
const rid = (msg as { id: unknown }).id;
if (typeof rid === 'number') {
const pending = this.pending.get(rid);
if (pending) {
// Defense-in-depth: don't let a reply on this session's stream
// resolve a pending scoped to a different session (mirror of the
// reply-pump guard).
if (
pending.sessionId !== undefined &&
pending.sessionId !== sessionId
) {
continue;
}
this.pending.delete(rid);
pending.resolve(msg as unknown as JsonRpcResponse);
}
}
continue;
}
// (2) Agent→client permission request → surface as an event so the
// consumer can show it. Responding (POSTing the vote) is the §1.7
// permission-coordination follow-up; here we only deliver it.
if (method === 'session/request_permission') {
const ev = permissionRequestToEvent(msg, busId);
if (ev) yield ev;
continue;
}
// (3) Notification → DaemonEvent, stamped with the real bus cursor.
if (typeof method === 'string' && !hasId) {
const ev = denormalizeAcpNotification(
msg as unknown as JsonRpcNotification,
);
if (ev) {
ev.id = busId; // authoritative cursor (or undefined → ignored)
yield ev;
}
continue;
}
// else: unrecognized frame → ignore
}
}
} finally {
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
try {
reader.cancel().catch(() => {});
} catch {
/* already closed */
}
// NOTE: the session-scoped pending sweep lives in the `subscribeEvents`
// WRAPPER's finally, not here. This read-loop finally only runs if the
// pump reached the read loop — a fast failure (fetch reject / non-OK /
// wrong content-type, all BEFORE this try) would skip it and strand the
// pending. The wrapper finally always runs, so the sweep belongs there.
}
}
dispose(): void {
@@ -284,6 +647,12 @@ export class AcpHttpTransport implements DaemonTransport {
this.connStreamAbort?.abort();
this.connStreamAbort = undefined;
// Tear down any background session-reply pumps.
for (const [sid, entry] of this.sessionReplyPumps) {
entry.abort.abort();
this.sessionReplyPumps.delete(sid);
}
// Reject all pending requests.
for (const [id, entry] of this.pending) {
entry.reject(new DaemonTransportClosedError());
@@ -397,15 +766,37 @@ export class AcpHttpTransport implements DaemonTransport {
}
// Fire-and-forget: pump the SSE stream in the background.
void this.pumpConnStream(headers, abort.signal).catch(() => {
// Stream ended or errored — reject any remaining pending requests.
if (!this._disposed) {
for (const [id, entry] of this.pending) {
entry.reject(new Error('Connection SSE stream closed unexpectedly'));
this.pending.delete(id);
void this.pumpConnStream(headers, abort.signal)
.catch((err) => {
// Stream ended or errored — reject remaining CONNECTION-scoped pendings
// only. Session-scoped replies ride the session stream / its reply pump,
// which owns their lifecycle; sweeping them here would reject a
// `session/prompt` the session stream is about to resolve (§W2 race).
// Carry the pump's real error (HTTP 401/503, network drop) as the reject
// reason — mirroring `ensureSessionReplyPump` — so a caller can tell an
// auth failure from a generic close instead of a one-size-fits-all msg.
if (!this._disposed) {
const reason =
err instanceof Error
? err
: new Error('Connection SSE stream closed unexpectedly');
for (const [id, entry] of this.pending) {
if (entry.sessionId !== undefined) continue;
entry.reject(reason);
this.pending.delete(id);
}
}
}
});
})
.finally(() => {
// The pump has settled (clean close, error, or abort). Clear the
// controller so the NEXT connection-scoped request reopens the stream
// via `ensureConnStream` — without this, a stream that 500s, serves no
// body, or closes leaves `connStreamAbort` non-null forever and every
// later 202 request hangs with no pump to deliver its reply. Guard on
// identity so a newer `openConnStream` that already replaced the
// controller isn't clobbered.
if (this.connStreamAbort === abort) this.connStreamAbort = undefined;
});
}
private async pumpConnStream(
@@ -417,7 +808,15 @@ export class AcpHttpTransport implements DaemonTransport {
signal,
});
if (!res.ok || !res.body) return;
// A non-2xx response or a missing body is a HARD failure, not a silent
// no-op: throwing lets `openConnStream`'s catch reject the connection-scoped
// pendings (otherwise a 202 request would hang forever waiting for a reply
// on a stream that never opened).
if (!res.ok || !res.body) {
throw new Error(
`Connection SSE stream failed: HTTP ${res.status} ${res.statusText}`,
);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
@@ -428,18 +827,23 @@ export class AcpHttpTransport implements DaemonTransport {
// pre-built ReadableStream that isn't wired to the signal).
// Race each read against a signal-based rejection so dispose()
// can unblock a hanging `reader.read()`.
// Keep the abort listener in a named ref and remove it in the `finally`:
// on a clean drain that never aborts, an anonymous `{ once: true }` listener
// would otherwise leak on a long-lived signal reused across reconnects
// (mirrors `subscribeEventsInner` / `pumpSessionReplies`).
let onAbort: (() => void) | undefined;
const abortPromise = new Promise<never>((_, reject) => {
if (signal.aborted) {
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
return;
}
signal.addEventListener(
'abort',
() =>
reject(signal.reason ?? new DOMException('Aborted', 'AbortError')),
{ once: true },
);
onAbort = () =>
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
signal.addEventListener('abort', onAbort, { once: true });
});
// No-op catch so a synchronous reject (signal already aborted) can't surface
// as an unhandledrejection if the read loop throws before the race sees it.
abortPromise.catch(() => {});
try {
while (!signal.aborted) {
@@ -449,19 +853,39 @@ export class AcpHttpTransport implements DaemonTransport {
]);
if (done) break;
buf += decoder.decode(value, { stream: true });
// Bound the unread buffer — the same OOM guard the session readers have.
// A server/proxy that never emits a `\n\n` frame boundary would
// otherwise grow this without limit on the connection-scoped stream.
if (buf.length > MAX_SSE_BUF_CHARS) {
throw new Error(
`AcpHttpTransport: unread SSE buffer exceeded ${MAX_SSE_BUF_CHARS} ` +
`bytes without a frame boundary`,
);
}
let idx: number;
while ((idx = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, idx);
buf = buf.slice(idx + 2);
const dataLine = frame
.split('\n')
.find((l) => l.startsWith('data: '));
if (!dataLine) continue;
// Reuse the shared CRLF-aware frame splitter (handles both `\n\n` and
// `\r\n\r\n`) instead of an LF-only `indexOf('\n\n')`. A server/proxy
// emitting `\r\n\r\n` separators never produces a `\n\n` substring, so
// the old scan found no boundary, grew `buf` to the cap, and killed
// this pump — leaving every conn-scoped JSON-RPC reply unresolved. This
// matches the session readers' framing exactly.
const { frames, tail } = consumeFrames(buf);
buf = tail;
for (const rawFrame of frames) {
// Per the SSE spec multiple `data:` lines join with a newline; strip a
// trailing CR so CRLF line endings don't corrupt JSON.parse.
const dataParts: string[] = [];
for (const rawLine of rawFrame.split('\n')) {
const line = rawLine.endsWith('\r')
? rawLine.slice(0, -1)
: rawLine;
if (line.startsWith('data:')) {
dataParts.push(line.slice('data:'.length).replace(/^ /, ''));
}
}
if (dataParts.length === 0) continue;
try {
const parsed = JSON.parse(
dataLine.slice('data: '.length),
) as JsonRpcResponse;
const parsed = JSON.parse(dataParts.join('\n')) as JsonRpcResponse;
if (
typeof parsed === 'object' &&
parsed !== null &&
@@ -469,8 +893,17 @@ export class AcpHttpTransport implements DaemonTransport {
) {
const pending = this.pending.get(parsed.id);
if (pending) {
this.pending.delete(parsed.id);
pending.resolve(parsed);
// Defense-in-depth scope guard, mirroring `subscribeEventsInner`
// and `pumpSessionReplies`: the CONNECTION stream only carries
// conn-scoped replies, so never resolve a SESSION-scoped pending
// here — a daemon routing regression that mis-delivered a
// session reply on the conn stream must not cross session
// boundaries (matches the `openConnStream` error sweep, which
// also skips `entry.sessionId !== undefined`).
if (pending.sessionId === undefined) {
this.pending.delete(parsed.id);
pending.resolve(parsed);
}
}
}
} catch {
@@ -478,9 +911,16 @@ export class AcpHttpTransport implements DaemonTransport {
}
}
}
} catch {
// Abort or read error — fall through to cleanup.
} catch (err) {
// An intentional abort (dispose / reconnect) is a clean shutdown — the
// aborter owns pending cleanup, so swallow it. Any OTHER error (read
// failure, mid-stream socket drop) must PROPAGATE so `openConnStream`'s
// catch rejects the connection-scoped pendings rather than leaving them
// hung on a dead stream.
if (signal.aborted) return;
throw err;
} finally {
if (onAbort) signal.removeEventListener('abort', onAbort);
// Best-effort cancel with a timeout guard — some ReadableStream
// implementations (especially in test environments) can hang on
// cancel() if the underlying source never closes.
@@ -591,16 +1031,44 @@ export class AcpHttpTransport implements DaemonTransport {
return (await res.json()) as JsonRpcResponse;
}
// 202 (ack) — the real response rides the connection-scoped SSE
// stream. Ensure it's open and register the pending request.
// 202 (ack) — the real response rides an SSE stream. Ensure the
// connection-scoped stream is open and register the pending request.
this.ensureConnStream();
// The daemon routes a handful of session methods' replies onto the SESSION
// stream, not the connection stream. If this is one of them and the caller
// isn't already iterating `subscribeEvents` for that session (which would
// route the reply itself), open a background reply pump so the request can
// resolve — otherwise `DaemonClient.prompt()` and friends hang forever.
const sessionId =
typeof params['sessionId'] === 'string'
? (params['sessionId'] as string)
: undefined;
// This reply rides the SESSION stream iff it's one of the session-routed
// methods (regardless of whether the reply pump or an active subscription
// ultimately delivers it). Tag the pending entry with that scope so a
// connection-stream failure can't sweep it (§W2 cross-stream race).
const replyScopeSessionId =
sessionId && SESSION_STREAM_REPLY_METHODS.has(method)
? sessionId
: undefined;
const releaseSessionPump =
replyScopeSessionId &&
!this.activeSessionSubscriptions.has(replyScopeSessionId)
? this.ensureSessionReplyPump(replyScopeSessionId)
: undefined;
const responsePromise = new Promise<JsonRpcResponse>((resolve, reject) => {
this.pending.set(req.id, { resolve, reject });
this.pending.set(req.id, {
resolve,
reject,
sessionId: replyScopeSessionId,
});
});
// Handle abort signal: if the caller aborts, reject the pending
// request and clean up.
let removeAbortHandler: (() => void) | undefined;
if (signal) {
const abortHandler = () => {
const entry = this.pending.get(req.id);
@@ -612,9 +1080,221 @@ export class AcpHttpTransport implements DaemonTransport {
}
};
signal.addEventListener('abort', abortHandler, { once: true });
// `{ once: true }` self-removes only when the signal FIRES. On the happy
// path (request resolves normally) the listener would otherwise stay
// attached, so a long-lived caller signal reused across many requests
// accumulates one closure per call. Remove it explicitly on settle.
removeAbortHandler = () =>
signal.removeEventListener('abort', abortHandler);
}
return responsePromise;
// Release the background reply pump AND drop the abort listener once the
// request settles (resolved by the pump, rejected by abort/stream-close) —
// the pump closes when its last in-flight request drains.
const cleanup = () => {
removeAbortHandler?.();
releaseSessionPump?.();
};
return removeAbortHandler || releaseSessionPump
? responsePromise.finally(cleanup)
: responsePromise;
}
/**
* Ensure a background session-reply pump is running for `sessionId`, returning
* a release callback. Reference-counted: concurrent session requests share one
* pump and it tears down when the last releases. The pump reads the session
* `/acp` stream and routes JSON-RPC *responses* to `pending` (mirroring
* `pumpConnStream`); it ignores events (no consumer) — a consumer that wants
* events uses `subscribeEvents`, which suppresses this pump entirely.
*
* NOTE (daemon-side attach semantics): the pump opens `GET /acp` with
* `Acp-Session-Id` but NO `Last-Event-ID`, so the daemon does a fresh
* (non-resumptive) session attach — content events produced before the pump
* attaches are delivered live, not replayed. A later `subscribeEvents`
* consumer that resumes with a `Last-Event-ID` therefore won't see those
* pre-pump content events replayed (they were live-delivered to a pump that
* only routes JSON-RPC *responses* and drops events). This is fine for the
* pump's job — resolving a no-subscriber `session/prompt` reply — but a
* consumer that needs the full event history should open `subscribeEvents`
* before issuing session RPCs.
*/
private ensureSessionReplyPump(sessionId: string): () => void {
let entry = this.sessionReplyPumps.get(sessionId);
if (!entry) {
const abort = new AbortController();
const created = { abort, refs: 0 };
entry = created;
this.sessionReplyPumps.set(sessionId, created);
// Capture the pump's error (e.g. `HTTP 401`, wrong content-type) so the
// sweep can reject pendings WITH that reason instead of a generic message
// — the caller can then tell an auth failure from a network drop.
let pumpError: Error | undefined;
void this.pumpSessionReplies(sessionId, abort.signal)
.catch((err) => {
pumpError = err instanceof Error ? err : new Error(String(err));
})
.finally(() => {
if (this.sessionReplyPumps.get(sessionId) === created) {
this.sessionReplyPumps.delete(sessionId);
}
// If the pump ended on its own (stream error) rather than via a clean
// release (`abort` fires only when the last ref drops, after each
// request already settled), reject this session's still-live pendings
// so they don't hang. Scoped to THIS sessionId — the mirror of the
// connection-stream sweep (§W2 race).
if (!this._disposed && !abort.signal.aborted) {
const reason =
pumpError ??
new Error('Session SSE reply stream closed unexpectedly');
for (const [id, pending] of this.pending) {
if (pending.sessionId !== sessionId) continue;
pending.reject(reason);
this.pending.delete(id);
}
}
});
}
entry.refs += 1;
let released = false;
const active = entry;
return () => {
if (released) return;
released = true;
active.refs -= 1;
if (active.refs <= 0) {
active.abort.abort();
if (this.sessionReplyPumps.get(sessionId) === active) {
this.sessionReplyPumps.delete(sessionId);
}
}
};
}
/**
* Read a session-scoped `/acp` stream purely to route JSON-RPC *responses*
* (`id`, no `method`) to `pending`. Notifications and agent→client requests
* (`session/request_permission`, which also carry an `id`) are skipped — the
* `method` guard prevents a permission request id from being mis-routed onto
* a pending response slot.
*/
private async pumpSessionReplies(
sessionId: string,
signal: AbortSignal,
): Promise<void> {
const headers: Record<string, string> = { Accept: 'text/event-stream' };
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
if (this.connectionId) headers['Acp-Connection-Id'] = this.connectionId;
headers['Acp-Session-Id'] = sessionId;
const res = await this._fetch(`${this.baseUrl}/acp`, { headers, signal });
// Surface the HTTP status (401 stale token / 404 gone session / 5xx) in the
// thrown error so the caller's sweep + any log can tell them apart. Caught
// by `ensureSessionReplyPump`'s `.catch(() => {})`; the pending rejection
// still happens in its `.finally`.
if (!res.ok || !res.body) {
throw new Error(
`session reply pump: HTTP ${res.status} ${res.statusText}`,
);
}
// Validate the content-type before parsing as SSE — mirror
// `subscribeEventsInner`. A non-SSE 2xx body (an HTML error page / a JSON
// proxy error injected by a CDN) would otherwise be fed to the frame parser,
// which would consume garbage or hang waiting for `data:` lines that never
// come. Don't leave the reply channel with weaker validation than events.
const ct = res.headers.get('content-type') ?? '';
if (!ct.toLowerCase().includes('text/event-stream')) {
try {
await res.body.cancel();
} catch {
/* body already consumed or no body */
}
throw new Error(
`session reply pump: expected content-type text/event-stream, got "${ct}"`,
);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
// Mirror `subscribeEventsInner`: keep the abort listener in a named ref so
// `finally` can remove it (it's `once`, so a clean drain that never aborts
// would otherwise leak it on a reused parent signal), and attach a no-op
// `.catch` so a synchronous reject (signal already aborted) can't surface as
// an unhandledrejection if the read loop throws before the race observes it.
let onAbort: (() => void) | undefined;
const abortPromise = new Promise<never>((_, reject) => {
if (signal.aborted) {
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
return;
}
onAbort = () =>
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
signal.addEventListener('abort', onAbort, { once: true });
});
abortPromise.catch(() => {});
try {
while (!signal.aborted) {
const { value, done } = await Promise.race([
reader.read(),
abortPromise,
]);
if (done) break;
buf += decoder.decode(value, { stream: true });
if (buf.length > MAX_SSE_BUF_CHARS) {
throw new Error('session reply pump: SSE buffer exceeded cap');
}
const { frames, tail } = consumeFrames(buf);
buf = tail;
for (const rawFrame of frames) {
const dataParts: string[] = [];
for (const rawLine of rawFrame.split('\n')) {
const line = rawLine.endsWith('\r')
? rawLine.slice(0, -1)
: rawLine;
if (line.startsWith('data:')) {
dataParts.push(line.slice('data:'.length).replace(/^ /, ''));
}
}
if (dataParts.length === 0) continue;
let msg: unknown;
try {
msg = JSON.parse(dataParts.join('\n'));
} catch {
continue;
}
if (!isRecord(msg)) continue;
// Only JSON-RPC responses (id present, NO method) route to pending —
// skip notifications and permission requests (which carry a method).
if (!('id' in msg) || typeof msg['method'] === 'string') continue;
const rid = msg['id'];
if (typeof rid !== 'number') continue;
const pending = this.pending.get(rid);
if (pending) {
// Defense-in-depth: a reply arriving on THIS session's stream must
// not resolve a pending scoped to a different session. The daemon
// enforces session ownership on `/acp`, but a future routing
// regression must not silently cross-deliver across the SDK boundary.
if (
pending.sessionId !== undefined &&
pending.sessionId !== sessionId
) {
continue;
}
this.pending.delete(rid);
pending.resolve(msg as unknown as JsonRpcResponse);
}
}
}
} finally {
if (onAbort) signal.removeEventListener('abort', onAbort);
try {
reader.cancel().catch(() => {});
} catch {
/* already closed */
}
}
}
}
@@ -163,11 +163,16 @@ export class AcpWsTransport implements DaemonTransport {
// For notifications, send and return 204 immediately.
if (mapping.notification) {
const params = mapping.extractParams(segments, body, httpMethod);
const params = mapping.extractParams(
segments,
body,
httpMethod,
parsedUrl.searchParams,
);
const notifMeta = extractHeaderMeta(init.headers);
if (notifMeta) {
params._meta = {
...(isRecord(params._meta) ? params._meta : {}),
params['_meta'] = {
...(isRecord(params['_meta']) ? params['_meta'] : {}),
...notifMeta,
};
}
@@ -176,15 +181,20 @@ export class AcpWsTransport implements DaemonTransport {
}
// Normal request-response.
const params = mapping.extractParams(segments, body, httpMethod);
const params = mapping.extractParams(
segments,
body,
httpMethod,
parsedUrl.searchParams,
);
// Forward per-request headers as JSON-RPC _meta so the server can
// see X-Qwen-Client-Id and similar metadata that HTTP transports
// carry natively.
const headerMeta = extractHeaderMeta(init.headers);
if (headerMeta) {
params._meta = {
...(isRecord(params._meta) ? params._meta : {}),
params['_meta'] = {
...(isRecord(params['_meta']) ? params['_meta'] : {}),
...headerMeta,
};
}
@@ -16,11 +16,20 @@ import { isRecord } from './acpTransportUtils.js';
export interface RouteMapping {
method: string;
/** Extract JSON-RPC params from URL path segments + request body. */
/**
* Extract JSON-RPC params from URL path segments, request body, and — for the
* REST-style query-backed helpers (`/file?path=…&maxBytes=…`, `/stat`,
* `/list`, `/glob`, `context-usage?detail=…`) — the URL query string. The
* daemon's ACP handlers are strictly typed (e.g. `maxBytes` must be a
* `number`, `detail` must be the boolean `true`), so query values — which
* arrive as strings — are coerced to the expected type here via
* `strParam`/`numParam`/`boolParam`.
*/
extractParams: (
segments: string[],
body: unknown,
httpMethod: string,
query?: URLSearchParams,
) => Record<string, unknown>;
/**
* True for notifications (no response expected). The transport will
@@ -29,6 +38,43 @@ export interface RouteMapping {
notification?: boolean;
}
/** A string query param, omitted when absent. */
function strParam(
q: URLSearchParams | undefined,
name: string,
): Record<string, string> {
const v = q?.get(name);
return v == null ? {} : { [name]: v };
}
/**
* A numeric query param coerced to a `number`, omitted when absent. The daemon's
* ACP handlers require a real number (a query string's `"123"` would be
* rejected). An unparseable value forwards as `NaN`, which the daemon rejects
* the same way it would a malformed REST query.
*
* An empty value (`?maxBytes=`) is treated as ABSENT, not `0`: `Number('')` is
* `0`, a plausible-but-unintended value the handler would otherwise honor.
*/
function numParam(
q: URLSearchParams | undefined,
name: string,
): Record<string, number> {
const v = q?.get(name);
return v == null || v === '' ? {} : { [name]: Number(v) };
}
/** A boolean query param (`?detail=true`), omitted when absent. */
function boolParam(
q: URLSearchParams | undefined,
name: string,
): Record<string, boolean> {
const v = q?.get(name);
// Treat an empty value (`?detail=`) as absent, mirroring `numParam`, so we
// don't forward `{ detail: false }` for a param the caller never set.
return v == null || v === '' ? {} : { [name]: v === 'true' };
}
export interface RouteEntry {
httpMethod: string;
pattern: RegExp;
@@ -269,13 +315,16 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [
extractParams: (segs) => ({ sessionId: segs[0] }),
},
},
// GET /session/:id/context-usage → _qwen/session/context_usage
// GET /session/:id/context-usage?detail=true → _qwen/session/context_usage
{
httpMethod: 'GET',
pattern: /^\/session\/([^/]+)\/context-usage$/,
mapping: {
method: '_qwen/session/context_usage',
extractParams: (segs) => ({ sessionId: segs[0] }),
extractParams: (segs, _b, _m, q) => ({
sessionId: segs[0],
...boolParam(q, 'detail'),
}),
},
},
// GET /session/:id/supported-commands → _qwen/session/supported_commands
@@ -614,49 +663,58 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [
// These map the DaemonClient's file-system helpers to _qwen/file/* RPC
// methods on the ACP daemon.
// GET /file → _qwen/file/read (query params forwarded as RPC params)
// GET /file?path=…&maxBytes=…&line=…&limit=… → _qwen/file/read
{
httpMethod: 'GET',
pattern: /^\/file\/?$/,
mapping: {
method: '_qwen/file/read',
extractParams: () => ({}),
extractParams: (_s, _b, _m, q) => ({
...strParam(q, 'path'),
...numParam(q, 'maxBytes'),
...numParam(q, 'line'),
...numParam(q, 'limit'),
}),
},
},
// GET /file/bytes → _qwen/file/read_bytes
// GET /file/bytes?path=…&offset=…&maxBytes=… → _qwen/file/read_bytes
{
httpMethod: 'GET',
pattern: /^\/file\/bytes\/?$/,
mapping: {
method: '_qwen/file/read_bytes',
extractParams: () => ({}),
extractParams: (_s, _b, _m, q) => ({
...strParam(q, 'path'),
...numParam(q, 'offset'),
...numParam(q, 'maxBytes'),
}),
},
},
// GET /stat → _qwen/file/stat
// GET /stat?path=… → _qwen/file/stat
{
httpMethod: 'GET',
pattern: /^\/stat\/?$/,
mapping: {
method: '_qwen/file/stat',
extractParams: () => ({}),
extractParams: (_s, _b, _m, q) => ({ ...strParam(q, 'path') }),
},
},
// GET /list → _qwen/file/list
// GET /list?path=… → _qwen/file/list
{
httpMethod: 'GET',
pattern: /^\/list\/?$/,
mapping: {
method: '_qwen/file/list',
extractParams: () => ({}),
extractParams: (_s, _b, _m, q) => ({ ...strParam(q, 'path') }),
},
},
// GET /glob → _qwen/file/glob
// GET /glob?pattern=… → _qwen/file/glob
{
httpMethod: 'GET',
pattern: /^\/glob\/?$/,
mapping: {
method: '_qwen/file/glob',
extractParams: () => ({}),
extractParams: (_s, _b, _m, q) => ({ ...strParam(q, 'pattern') }),
},
},
// POST /file/write → _qwen/file/write
+11 -10
View File
@@ -28,16 +28,17 @@ export type {
DaemonTransportType,
} from './DaemonTransport.js';
export type { RestSseTransport } from './RestSseTransport.js';
// negotiateTransport + ACP transport classes live in their own files to
// break the static import chain from this barrel, keeping the browser
// bundle under budget. Monorepo consumers import from source paths:
// import { negotiateTransport } from '../../sdk-typescript/src/daemon/negotiateTransport.js';
// import { AcpWsTransport } from '../../sdk-typescript/src/daemon/AcpWsTransport.js';
// import { AcpHttpTransport } from '../../sdk-typescript/src/daemon/AcpHttpTransport.js';
// import { AutoReconnectTransport } from '../../sdk-typescript/src/daemon/AutoReconnectTransport.js';
// Deep package exports are intentionally omitted: the SDK barrel does not
// re-export these classes, and the package's `files` field ships only
// `dist/` which does not include per-module entry points for them.
// negotiateTransport + the concrete ACP transport classes are intentionally
// NOT re-exported here: a static import chain from this barrel would pull
// their framing/SSE code into the budget-checked browser bundle (see
// `scripts/build.js` MAX_DAEMON_BROWSER_BUNDLE_BYTES). They ship instead
// behind the opt-in `@qwen-code/sdk/daemon/transports` subpath
// (`./transports.ts`), so REST-only consumers stay tree-shaken while
// consumers who want resumable ACP-over-HTTP get a first-class import:
// import { negotiateTransport, AcpHttpTransport }
// from '@qwen-code/sdk/daemon/transports';
// The `NegotiateTransportOptions` *type* stays available from this barrel
// for backward compatibility (type-only, no bundle cost).
export type { NegotiateTransportOptions } from './negotiateTransport.js';
export type { JsonRpcNotification } from './AcpEventDenormalizer.js';
export type { TransportFactory } from './AutoReconnectTransport.js';
@@ -14,6 +14,13 @@ import type { DaemonTransport } from './DaemonTransport.js';
export interface NegotiateTransportOptions {
/** Timeout for the capabilities probe and WS handshake. Default 5000ms. */
probeTimeoutMs?: number;
/**
* `fetch` implementation used for the capabilities probe and threaded
* into the constructed REST / ACP-HTTP transport. Defaults to the
* global `fetch`. Supply this to inject auth headers, a proxy agent, or
* a test double in environments where the global isn't what you want.
*/
fetchFn?: typeof globalThis.fetch;
}
/**
@@ -41,7 +48,7 @@ export async function negotiateTransport(
token?: string,
opts?: NegotiateTransportOptions,
): Promise<DaemonTransport> {
const fetchFn = globalThis.fetch.bind(globalThis);
const fetchFn = opts?.fetchFn ?? globalThis.fetch.bind(globalThis);
const probeTimeoutMs = opts?.probeTimeoutMs ?? 5_000;
// Lazy imports to avoid circular module initialization. These
+7 -1
View File
@@ -186,8 +186,14 @@ export async function* parseSseStream(
/**
* Walk `buf` and pull off every complete frame (either `\n\n` or
* `\r\n\r\n` separator). Returns the frames + the unconsumed tail.
*
* Exported so other SSE readers (e.g. the ACP transport's raw JSON-RPC frame
* parser) reuse this CRLF-aware boundary scan instead of reimplementing it.
*/
function consumeFrames(buf: string): { frames: string[]; tail: string } {
export function consumeFrames(buf: string): {
frames: string[];
tail: string;
} {
const frames: string[] = [];
let cursor = 0;
// BX9_a + BeFHR + BeFId: scan for `\n\n` first; on hit, look for
@@ -0,0 +1,42 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Opt-in transport surface: `@qwen-code/sdk/daemon/transports`.
*
* The default `@qwen-code/sdk/daemon` barrel deliberately ships only the
* `DaemonTransport` interface and the lightweight `RestSseTransport` *type*
* so its browser bundle stays under budget (see `scripts/build.js`
* `MAX_DAEMON_BROWSER_BUNDLE_BYTES`). The concrete ACP transports —
* `AcpHttpTransport` (native `supportsReplay` + `Last-Event-ID` resume),
* `AcpWsTransport`, the `AutoReconnectTransport` wrapper, and the
* `negotiateTransport` factory — pull in their own framing/SSE code, so
* they live behind this separate subpath. Consumers that only need REST
* never pay for them; consumers that want resumable ACP-over-HTTP opt in
* with one import:
*
* ```ts
* import {
* negotiateTransport,
* AcpHttpTransport,
* } from '@qwen-code/sdk/daemon/transports';
*
* const transport = await negotiateTransport(baseUrl, token);
* const client = new DaemonClient({ baseUrl, token, transport });
* ```
*/
export { AcpHttpTransport } from './AcpHttpTransport.js';
export { AcpWsTransport } from './AcpWsTransport.js';
export {
AutoReconnectTransport,
type TransportFactory,
} from './AutoReconnectTransport.js';
export { RestSseTransport } from './RestSseTransport.js';
export {
negotiateTransport,
type NegotiateTransportOptions,
} from './negotiateTransport.js';
@@ -120,6 +120,29 @@ describe('denormalizeAcpNotification', () => {
expect(event!.data).toEqual({ cwd: '/tmp' });
});
it('converts a _qwen/notify tagged with `kind` (as the daemon sends it)', () => {
const notification: JsonRpcNotification = {
jsonrpc: '2.0',
method: '_qwen/notify',
params: {
kind: 'state_resync_required',
data: { reason: 'ring_evicted' },
},
};
const event = denormalizeAcpNotification(notification);
expect(event?.type).toBe('state_resync_required');
});
it('falls back to `kind` when `type` is an empty string, not dropping the event (M3w6i)', () => {
const notification: JsonRpcNotification = {
jsonrpc: '2.0',
method: '_qwen/notify',
params: { type: '', kind: 'replay_complete', data: {} },
};
const event = denormalizeAcpNotification(notification);
expect(event?.type).toBe('replay_complete');
});
it('converts workspace-scoped snake_case methods', () => {
const notification: JsonRpcNotification = {
jsonrpc: '2.0',
@@ -571,3 +571,977 @@ describe('AcpHttpTransport', () => {
});
});
});
// A streamed text/event-stream Response that emits the given raw SSE frames
// then closes, so subscribeEvents' read loop ends and the generator returns.
function sseResponse(frames: string[]): Response {
const enc = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const f of frames) controller.enqueue(enc.encode(f));
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: { 'content-type': 'text/event-stream' },
});
}
describe('AcpHttpTransport — subscribeEvents (session-scoped /acp stream)', () => {
// One SSE frame: optional `id:` (bus cursor) + a `data:` JSON-RPC payload.
function frame(id: number | undefined, msg: unknown): string {
const idLine = id !== undefined ? `id: ${id}\n` : '';
return `${idLine}data: ${JSON.stringify(msg)}\n\n`;
}
function sessionStreamFetch(frames: string[]) {
return initAwareFetch({
connectionIdHeader: 'conn-1',
subsequentReply: (req) =>
req.method === 'GET' && req.headers['acp-session-id']
? sseResponse(frames)
: jsonResponse(200, { jsonrpc: '2.0', id: 1, result: { ok: true } }),
});
}
async function collect(
t: AcpHttpTransport,
sessionId: string,
): Promise<Array<{ id?: number; type: string; data: unknown }>> {
const out: Array<{ id?: number; type: string; data: unknown }> = [];
for await (const e of t.subscribeEvents(sessionId)) {
out.push(e as { id?: number; type: string; data: unknown });
}
return out;
}
it('opens GET /acp with Acp-Session-Id + Acp-Connection-Id (not REST /session/:id/events)', async () => {
const { fetch, calls } = sessionStreamFetch([]);
const t = new AcpHttpTransport('http://d', undefined, fetch);
await collect(t, 'sess-1');
const getCall = calls.find(
(c) => c.method === 'GET' && c.url.endsWith('/acp'),
);
expect(getCall).toBeDefined();
expect(getCall?.headers['acp-session-id']).toBe('sess-1');
expect(getCall?.headers['acp-connection-id']).toBe('conn-1');
// Must NOT use the REST session-events endpoint.
expect(calls.some((c) => c.url.includes('/session/'))).toBe(false);
});
it('yields a session/update notification as a DaemonEvent stamped with the bus id from the `id:` line', async () => {
const { fetch } = sessionStreamFetch([
frame(42, {
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId: 'sess-1',
update: { sessionUpdate: 'agent_message_chunk', text: 'hi' },
},
}),
]);
const t = new AcpHttpTransport('http://d', undefined, fetch);
const events = await collect(t, 'sess-1');
expect(events).toHaveLength(1);
expect(events[0].type).toBe('agent_message_chunk');
expect(events[0].id).toBe(42); // real bus cursor, not the synthetic id
});
it('RESOLVES the matching pending request from a JSON-RPC response frame WITHOUT yielding it as an event — the W2 no-hang dispatch', async () => {
// A response frame (id, no method) must dispatch to pending-RESOLUTION (the
// core W2 fix — a `session/prompt` reply routed onto the session stream by
// the daemon's `replySession` settles its promise instead of hanging), not
// merely be swallowed. The following notification proves the stream keeps
// flowing past it.
const { fetch } = sessionStreamFetch([
frame(undefined, {
jsonrpc: '2.0',
id: 999,
result: { stopReason: 'end_turn' },
}),
frame(7, {
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId: 'sess-1',
update: { sessionUpdate: 'agent_thought_chunk', text: 't' },
},
}),
]);
const t = new AcpHttpTransport('http://d', undefined, fetch);
// Register a pending request with the response's id so we can assert the
// frame RESOLVES it (and clears the entry), not just that it isn't yielded.
const pending = (
t as unknown as {
pending: Map<
number,
{ resolve: (r: unknown) => void; reject: (e: Error) => void }
>;
}
).pending;
let resolved: unknown;
pending.set(999, {
resolve: (r) => {
resolved = r;
},
reject: () => {},
});
const events = await collect(t, 'sess-1');
expect(resolved).toMatchObject({
id: 999,
result: { stopReason: 'end_turn' },
});
expect(pending.has(999)).toBe(false); // deleted on resolve
expect(events).toHaveLength(1);
expect(events[0].type).toBe('agent_thought_chunk');
});
it('resets the bus cursor when a later id: line in the same frame is invalid, rather than keeping the stale earlier one (MselW)', async () => {
// A proxy-mangled trailing `id:` must not let a wrong cursor ride the event.
const raw =
`id: 5\n` +
`id: notanumber\n` +
`data: ${JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId: 'sess-1',
update: { sessionUpdate: 'agent_message_chunk', text: 'x' },
},
})}\n\n`;
const { fetch } = sessionStreamFetch([raw]);
const t = new AcpHttpTransport('http://d', undefined, fetch);
const events = await collect(t, 'sess-1');
expect(events).toHaveLength(1);
// The valid `5` was overridden+reset by the invalid second `id:` line.
expect(events[0].id).toBeUndefined();
});
it('yields a _qwen/notify envelope tagged with `kind` (as the daemon sends it) as a DaemonEvent, not silently dropped (M2bvl)', async () => {
// The daemon's translateEvent stamps session-stream notifies under `kind`
// (state_resync_required / replay_complete / stream_error). Reading only
// `type` would drop them — starving the SDK of the resume signals.
const { fetch } = sessionStreamFetch([
frame(11, {
jsonrpc: '2.0',
method: '_qwen/notify',
params: {
kind: 'state_resync_required',
data: { reason: 'ring_evicted' },
},
}),
]);
const t = new AcpHttpTransport('http://d', undefined, fetch);
const events = await collect(t, 'sess-1');
expect(events).toHaveLength(1);
expect(events[0].type).toBe('state_resync_required');
expect(events[0].id).toBe(11);
});
it('throws when an SSE reader buffer exceeds MAX_SSE_BUF_CHARS without a frame boundary (M3w6g)', async () => {
// A server/proxy that streams bytes without ever emitting a `\n\n` boundary
// would grow the buffer unbounded (OOM). The 16 MiB cap must fire.
const oversized = 'a'.repeat(16 * 1024 * 1024 + 1); // one chunk, no boundary
const { fetch } = sessionStreamFetch([oversized]);
const t = new AcpHttpTransport('http://d', undefined, fetch);
await expect(collect(t, 'sess-1')).rejects.toThrow(/exceeded/i);
t.dispose();
});
it('rejects (not strands) a session-scoped pending when the subscription fails fast before its read loop (M4DWq fast-fail)', async () => {
// The session GET returns 401, so subscribeEventsInner throws at the res.ok
// check — BEFORE its read-loop finally. The pending sweep lives in the
// wrapper finally (which always runs), so the pending must still be rejected.
const fetchImpl = vi.fn(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
const method = init?.method ?? 'GET';
const headers: Record<string, string> = {};
if (init?.headers && typeof init.headers === 'object') {
for (const [k, v] of Object.entries(
init.headers as Record<string, string>,
)) {
headers[k.toLowerCase()] = v;
}
}
const body = typeof init?.body === 'string' ? init.body : null;
if (url.endsWith('/acp') && method === 'POST') {
const parsed = body ? JSON.parse(body) : {};
return jsonResponse(
200,
{ jsonrpc: '2.0', id: parsed.id, result: { v: 1 } },
{ 'acp-connection-id': 'conn-1' },
);
}
// Session GET → 401, throws before the read loop.
if (
url.endsWith('/acp') &&
method === 'GET' &&
headers['acp-session-id']
) {
return new Response('unauthorized', { status: 401 });
}
return jsonResponse(200, {});
},
) as unknown as typeof globalThis.fetch;
const t = new AcpHttpTransport('http://d', undefined, fetchImpl);
const pending = (
t as unknown as {
pending: Map<
number,
{
resolve: (r: unknown) => void;
reject: (e: Error) => void;
sessionId?: string;
}
>;
}
).pending;
const reject = vi.fn();
pending.set(7, { resolve: () => {}, reject, sessionId: 'sess-1' });
await expect(collect(t, 'sess-1')).rejects.toThrow();
expect(reject).toHaveBeenCalledTimes(1); // swept by the wrapper finally
// ...and with the REAL cause (the 401), not a generic message (M4W9a).
const reason = reject.mock.calls[0][0] as Error;
expect(reason.message).toMatch(/401/);
expect(pending.has(7)).toBe(false);
t.dispose();
});
it('does not resolve a pending scoped to a DIFFERENT session — cross-session guard, consumer path (M4W9e)', async () => {
// A reply with id 7 arrives on sess-1's stream, but pending 7 belongs to
// sess-2. The scope guard must not cross-deliver it.
const { fetch } = sessionStreamFetch([
frame(undefined, { jsonrpc: '2.0', id: 7, result: { ok: true } }),
]);
const t = new AcpHttpTransport('http://d', undefined, fetch);
const pending = (
t as unknown as {
pending: Map<
number,
{
resolve: (r: unknown) => void;
reject: (e: Error) => void;
sessionId?: string;
}
>;
}
).pending;
const resolve = vi.fn();
pending.set(7, { resolve, reject: () => {}, sessionId: 'sess-2' });
await collect(t, 'sess-1');
expect(resolve).not.toHaveBeenCalled();
expect(pending.has(7)).toBe(true); // untouched — wrong session
t.dispose();
});
it('pumpSessionReplies does not resolve a pending scoped to a different session — cross-session guard, pump path (M4W9e)', async () => {
const { fetch } = sessionStreamFetch([
frame(undefined, { jsonrpc: '2.0', id: 7, result: { ok: true } }),
]);
const t = new AcpHttpTransport('http://d', undefined, fetch);
const internals = t as unknown as {
pending: Map<
number,
{
resolve: (r: unknown) => void;
reject: (e: Error) => void;
sessionId?: string;
}
>;
pumpSessionReplies: (s: string, sig: AbortSignal) => Promise<void>;
};
const resolve = vi.fn();
internals.pending.set(7, {
resolve,
reject: () => {},
sessionId: 'sess-2',
});
await internals.pumpSessionReplies('sess-1', new AbortController().signal);
expect(resolve).not.toHaveBeenCalled();
expect(internals.pending.has(7)).toBe(true);
t.dispose();
});
it('rejects in-flight session-scoped pendings when the subscription stream closes (M2iHz) — a session/prompt caller does not hang', async () => {
const { fetch } = sessionStreamFetch([]); // stream closes with no frames
const t = new AcpHttpTransport('http://d', undefined, fetch);
const pending = (
t as unknown as {
pending: Map<
number,
{
resolve: (r: unknown) => void;
reject: (e: Error) => void;
sessionId?: string;
}
>;
}
).pending;
const sessionReject = vi.fn();
const connReject = vi.fn();
pending.set(42, {
resolve: () => {},
reject: sessionReject,
sessionId: 'sess-1',
});
// A connection-scoped pending must survive — different scope, different route.
pending.set(43, {
resolve: () => {},
reject: connReject,
sessionId: undefined,
});
// Drain the (empty) session stream to its close; the inner finally sweeps.
await collect(t, 'sess-1');
expect(sessionReject).toHaveBeenCalledTimes(1);
expect(pending.has(42)).toBe(false);
expect(connReject).not.toHaveBeenCalled();
expect(pending.has(43)).toBe(true);
t.dispose();
});
type ReplyPumpInternals = {
sessionReplyPumps: Map<string, { abort: AbortController; refs: number }>;
pending: Map<
number,
{
resolve: (r: unknown) => void;
reject: (e: Error) => void;
sessionId?: string;
}
>;
};
it('a consumer subscription that takes over from a reply pump aborts+removes it synchronously and DELIVERS the reply (M3BYa/M3w6Y happy path)', async () => {
// The session stream is single-reader: a consumer GET makes the daemon
// detach an earlier no-iter reply pump. The consumer now owns delivery — its
// stream carries the prompt reply, which must resolve the pending.
const { fetch } = sessionStreamFetch([
frame(undefined, {
jsonrpc: '2.0',
id: 7,
result: { stopReason: 'end_turn' },
}),
]);
const t = new AcpHttpTransport('http://d', undefined, fetch);
const internals = t as unknown as ReplyPumpInternals;
const pumpAbort = new AbortController();
internals.sessionReplyPumps.set('sess-1', { abort: pumpAbort, refs: 1 });
let resolved: unknown;
internals.pending.set(7, {
resolve: (r) => {
resolved = r;
},
reject: () => {},
sessionId: 'sess-1',
});
await collect(t, 'sess-1');
expect(pumpAbort.signal.aborted).toBe(true); // pump aborted...
expect(internals.sessionReplyPumps.has('sess-1')).toBe(false); // ...AND removed synchronously
expect(resolved).toMatchObject({
id: 7,
result: { stopReason: 'end_turn' },
});
expect(internals.pending.has(7)).toBe(false);
t.dispose();
});
it('a consumer that exits WITHOUT delivering rejects the taken-over pending instead of stranding it (M3w6Y)', async () => {
// The async-cleanup race: if the pump entry were only aborted (not deleted
// synchronously), the consumer sweep would defer to the still-present entry
// while the pump's own sweep skips on abort — stranding the pending forever.
// Synchronous removal makes the consumer sweep reject it.
const { fetch } = sessionStreamFetch([]); // consumer stream opens then closes empty
const t = new AcpHttpTransport('http://d', undefined, fetch);
const internals = t as unknown as ReplyPumpInternals;
const pumpAbort = new AbortController();
internals.sessionReplyPumps.set('sess-1', { abort: pumpAbort, refs: 1 });
const reject = vi.fn();
internals.pending.set(7, {
resolve: () => {},
reject,
sessionId: 'sess-1',
});
await collect(t, 'sess-1');
expect(pumpAbort.signal.aborted).toBe(true);
expect(internals.sessionReplyPumps.has('sess-1')).toBe(false);
expect(reject).toHaveBeenCalledTimes(1); // rejected, NOT stranded
expect(internals.pending.has(7)).toBe(false);
t.dispose();
});
it('surfaces a session/request_permission request as a permission_request event', async () => {
const { fetch } = sessionStreamFetch([
frame(9, {
jsonrpc: '2.0',
id: 5,
method: 'session/request_permission',
params: {
sessionId: 'sess-1',
toolCall: { name: 'write_file' },
options: [{ optionId: 'allow' }],
_meta: { qwen: { requestId: 'req-1' } },
},
}),
]);
const t = new AcpHttpTransport('http://d', undefined, fetch);
const events = await collect(t, 'sess-1');
expect(events).toHaveLength(1);
expect(events[0].type).toBe('permission_request');
expect((events[0].data as { requestId: string }).requestId).toBe('req-1');
expect(events[0].id).toBe(9);
});
it('writes the Last-Event-ID header on the outbound GET when resuming', async () => {
// The entire resume value proposition depends on this cursor reaching the
// wire; without this assertion a regression would silently degrade to
// live-only with no test failing.
const { fetch, calls } = sessionStreamFetch([]);
const t = new AcpHttpTransport('http://d', undefined, fetch);
for await (const _e of t.subscribeEvents('sess-1', { lastEventId: 42 })) {
// drain (empty stream)
}
const getCall = calls.find(
(c) => c.method === 'GET' && c.url.endsWith('/acp'),
);
expect(getCall).toBeDefined();
expect(getCall?.headers['last-event-id']).toBe('42');
});
it('omits the Last-Event-ID header on a first (non-resume) connect', async () => {
const { fetch, calls } = sessionStreamFetch([]);
const t = new AcpHttpTransport('http://d', undefined, fetch);
await collect(t, 'sess-1');
const getCall = calls.find(
(c) => c.method === 'GET' && c.url.endsWith('/acp'),
);
expect(getCall?.headers['last-event-id']).toBeUndefined();
});
it('does not raise an unhandled rejection when the signal is already aborted at entry', async () => {
// The mock fetch ignores the signal, so the read loop is reached with
// `signal.aborted === true`: the loop never enters and `Promise.race`
// never consumes the abort rejection. The no-op `.catch` on abortPromise
// keeps that from surfacing as an unhandled rejection (which vitest would
// fail the run on).
const { fetch } = sessionStreamFetch([
frame(1, {
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId: 'sess-1',
update: { sessionUpdate: 'agent_message_chunk', text: 'x' },
},
}),
]);
const t = new AcpHttpTransport('http://d', undefined, fetch);
const ctrl = new AbortController();
ctrl.abort();
const out: unknown[] = [];
for await (const e of t.subscribeEvents('sess-1', {
signal: ctrl.signal,
})) {
out.push(e);
}
expect(out).toEqual([]); // aborted at entry → no frames consumed
// Give any stray rejection a tick to surface before the test ends.
await new Promise((r) => setTimeout(r, 0));
});
});
describe('AcpHttpTransport — session-reply pump (no-subscriber session RPC)', () => {
it('resolves a session/prompt whose reply rides the session stream WITHOUT the caller iterating subscribeEvents', async () => {
// wenshao MsOpi: the daemon answers POST /session/:id/prompt with 202 and
// routes the final JSON-RPC result onto the SESSION stream. A DaemonClient
// that never opens subscribeEvents would hang. The transport now opens a
// background reply pump for session-reply methods, so fetch() resolves.
let promptId: number | undefined;
const fetchImpl = vi.fn(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
const method = init?.method ?? 'GET';
const headers: Record<string, string> = {};
if (init?.headers && typeof init.headers === 'object') {
for (const [k, v] of Object.entries(
init.headers as Record<string, string>,
)) {
headers[k.toLowerCase()] = v;
}
}
const body = typeof init?.body === 'string' ? init.body : null;
if (url.endsWith('/acp') && method === 'POST') {
const parsed = body ? JSON.parse(body) : {};
if (parsed.method === 'initialize') {
return jsonResponse(
200,
{ jsonrpc: '2.0', id: parsed.id, result: { v: 1 } },
{ 'acp-connection-id': 'conn-1' },
);
}
if (parsed.method === 'session/prompt') {
promptId = parsed.id as number;
return new Response(null, { status: 202 });
}
return jsonResponse(200, {
jsonrpc: '2.0',
id: parsed.id,
result: { ok: true },
});
}
// Background session-reply pump: GET /acp + Acp-Session-Id → serve the
// prompt's JSON-RPC result (id captured from the POST above).
if (
url.endsWith('/acp') &&
method === 'GET' &&
headers['acp-session-id']
) {
return sseResponse([
`data: ${JSON.stringify({
jsonrpc: '2.0',
id: promptId,
result: { stopReason: 'end_turn' },
})}\n\n`,
]);
}
// Connection-scoped stream (no Acp-Session-Id) → nothing to deliver.
if (url.endsWith('/acp') && method === 'GET') {
return sseResponse([]);
}
return jsonResponse(200, {});
},
) as unknown as typeof globalThis.fetch;
const t = new AcpHttpTransport('http://d', undefined, fetchImpl);
const res = await t.fetch('http://d/session/sess-1/prompt', {
method: 'POST',
body: JSON.stringify({ prompt: [{ type: 'text', text: 'hi' }] }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ stopReason: 'end_turn' });
t.dispose();
});
it('two concurrent session-scoped requests SHARE one reply pump (ref-counted): one GET /acp, both resolve, pump tears down only after both settle', async () => {
// doudouOUC: the ref-counting contract (entry.refs++ per request, abort only
// on the LAST release) is the core correctness property — a double-release
// would abort the pump while a second request is still in-flight. Fire two
// session/prompt POSTs concurrently and assert they share ONE GET /acp.
const promptIds: number[] = [];
let sessionGetCalls = 0;
const enc = new TextEncoder();
const fetchImpl = vi.fn(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
const method = init?.method ?? 'GET';
const headers: Record<string, string> = {};
if (init?.headers && typeof init.headers === 'object') {
for (const [k, v] of Object.entries(
init.headers as Record<string, string>,
)) {
headers[k.toLowerCase()] = v;
}
}
const body = typeof init?.body === 'string' ? init.body : null;
if (url.endsWith('/acp') && method === 'POST') {
const parsed = body ? JSON.parse(body) : {};
if (parsed.method === 'initialize') {
return jsonResponse(
200,
{ jsonrpc: '2.0', id: parsed.id, result: { v: 1 } },
{ 'acp-connection-id': 'conn-1' },
);
}
if (parsed.method === 'session/prompt') {
promptIds.push(parsed.id as number);
return new Response(null, { status: 202 });
}
return jsonResponse(200, {
jsonrpc: '2.0',
id: parsed.id,
result: { ok: true },
});
}
// The shared session reply pump: ONE GET /acp with Acp-Session-Id serves
// BOTH prompt replies. Enqueue on a macrotask so both POSTs have
// registered their ids first.
if (
url.endsWith('/acp') &&
method === 'GET' &&
headers['acp-session-id']
) {
sessionGetCalls++;
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
setTimeout(() => {
for (const id of promptIds) {
controller.enqueue(
enc.encode(
`data: ${JSON.stringify({
jsonrpc: '2.0',
id,
result: { stopReason: 'end_turn' },
})}\n\n`,
),
);
}
controller.close();
}, 0);
},
}),
{ status: 200, headers: { 'content-type': 'text/event-stream' } },
);
}
if (url.endsWith('/acp') && method === 'GET') return sseResponse([]);
return jsonResponse(200, {});
},
) as unknown as typeof globalThis.fetch;
const t = new AcpHttpTransport('http://d', undefined, fetchImpl);
const mkPrompt = () =>
t
.fetch('http://d/session/sess-1/prompt', {
method: 'POST',
body: JSON.stringify({ prompt: [{ type: 'text', text: 'hi' }] }),
})
.then((r) => r.json());
const [a, b] = await Promise.all([mkPrompt(), mkPrompt()]);
expect(a).toEqual({ stopReason: 'end_turn' });
expect(b).toEqual({ stopReason: 'end_turn' });
// Both shared a single reply pump (one session-scoped GET), not one each.
expect(sessionGetCalls).toBe(1);
// The pump tore down after the last release.
expect(
(
t as unknown as { sessionReplyPumps: Map<string, unknown> }
).sessionReplyPumps.has('sess-1'),
).toBe(false);
t.dispose();
});
it('rejects (does not parse garbage / hang) a no-subscriber session/prompt when the reply pump GET returns a non-SSE content-type (M3pAM)', async () => {
const fetchImpl = vi.fn(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
const method = init?.method ?? 'GET';
const headers: Record<string, string> = {};
if (init?.headers && typeof init.headers === 'object') {
for (const [k, v] of Object.entries(
init.headers as Record<string, string>,
)) {
headers[k.toLowerCase()] = v;
}
}
const body = typeof init?.body === 'string' ? init.body : null;
if (url.endsWith('/acp') && method === 'POST') {
const parsed = body ? JSON.parse(body) : {};
if (parsed.method === 'initialize') {
return jsonResponse(
200,
{ jsonrpc: '2.0', id: parsed.id, result: { v: 1 } },
{ 'acp-connection-id': 'conn-1' },
);
}
if (parsed.method === 'session/prompt') {
return new Response(null, { status: 202 });
}
return jsonResponse(200, {
jsonrpc: '2.0',
id: parsed.id,
result: { ok: true },
});
}
// Reply pump GET → 200 but a non-SSE HTML body (CDN/proxy error page).
// Must be rejected by the content-type guard, not fed to the parser.
if (
url.endsWith('/acp') &&
method === 'GET' &&
headers['acp-session-id']
) {
return new Response('<html>error</html>', {
status: 200,
headers: { 'content-type': 'text/html' },
});
}
if (url.endsWith('/acp') && method === 'GET') {
return sseResponse([]);
}
return jsonResponse(200, {});
},
) as unknown as typeof globalThis.fetch;
const t = new AcpHttpTransport('http://d', undefined, fetchImpl);
// The rejection must carry the pump's actual cause (content-type guard),
// not a generic "closed unexpectedly" — proves pumpError reaches the caller.
await expect(
t.fetch('http://d/session/sess-1/prompt', {
method: 'POST',
body: JSON.stringify({ prompt: [{ type: 'text', text: 'hi' }] }),
}),
).rejects.toThrow(/expected content-type/i);
t.dispose();
});
it('a connection-stream failure rejects only CONNECTION-scoped pendings, never a session-scoped reply the session stream will deliver (MselM)', async () => {
// The two stream pumps share one `pending` map. A connection-stream error
// must sweep only its own scope — rejecting a session-scoped `session/prompt`
// here would spuriously fail a request the session stream is about to
// resolve (the non-deterministic cross-stream race MselM reports).
const fetchImpl = vi.fn(async () => {
// Any GET /acp (the conn stream pump) fails → triggers the catch sweep.
throw new Error('conn stream boom');
}) as unknown as typeof globalThis.fetch;
const t = new AcpHttpTransport('http://d', undefined, fetchImpl);
const pending = (
t as unknown as {
pending: Map<
number,
{
resolve: (r: unknown) => void;
reject: (e: Error) => void;
sessionId?: string;
}
>;
}
).pending;
const sessionReject = vi.fn();
const connReject = vi.fn();
pending.set(1, {
resolve: () => {},
reject: sessionReject,
sessionId: 'sess-1',
});
pending.set(2, {
resolve: () => {},
reject: connReject,
sessionId: undefined,
});
// Open the connection stream; its pump rejects → the catch runs the sweep.
(t as unknown as { openConnStream: () => void }).openConnStream();
// Let the rejected pump's microtasks settle.
await new Promise((r) => setTimeout(r, 0));
// Connection-scoped pending swept; session-scoped one preserved.
expect(connReject).toHaveBeenCalledTimes(1);
expect(pending.has(2)).toBe(false);
expect(sessionReject).not.toHaveBeenCalled();
expect(pending.has(1)).toBe(true);
t.dispose();
});
it('resolves a conn-scoped reply delivered with CRLF (`\\r\\n\\r\\n`) frame separators, not just LF', async () => {
// The conn-stream pump must use the same CRLF-aware framing as the session
// readers. A server/proxy emitting `\r\n\r\n` separators produces no `\n\n`
// substring, so the old LF-only `indexOf('\n\n')` never found a boundary —
// the buffer grew to the cap and the pump died, leaving every conn-scoped
// reply unresolved.
const crlfFrame = `data: ${JSON.stringify({
jsonrpc: '2.0',
id: 7,
result: { ok: true },
})}\r\n\r\n`;
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.endsWith('/acp')) return sseResponse([crlfFrame]);
return jsonResponse(200, { ok: true });
}) as unknown as typeof globalThis.fetch;
const t = new AcpHttpTransport('http://d', undefined, fetchImpl);
const pending = (
t as unknown as {
pending: Map<
number,
{
resolve: (r: unknown) => void;
reject: (e: Error) => void;
sessionId?: string;
}
>;
}
).pending;
let resolved: unknown;
pending.set(7, {
resolve: (r) => {
resolved = r;
},
reject: () => {},
sessionId: undefined,
});
(t as unknown as { openConnStream: () => void }).openConnStream();
// Let the pump read the body and dispatch.
await new Promise((r) => setTimeout(r, 0));
expect(resolved).toMatchObject({ id: 7, result: { ok: true } });
expect(pending.has(7)).toBe(false);
t.dispose();
});
it('rejects conn-scoped pendings and reopens the stream when GET /acp returns 500 (no silent hang)', async () => {
// A non-2xx conn stream must NOT resolve the pump silently: that would
// leave connection-scoped pendings hung forever AND leave `connStreamAbort`
// non-null so the next request never reopens the stream.
let getCalls = 0;
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.endsWith('/acp')) {
getCalls++;
return new Response('nope', {
status: 500,
statusText: 'Server Error',
});
}
return jsonResponse(200, { ok: true });
}) as unknown as typeof globalThis.fetch;
const t = new AcpHttpTransport('http://d', undefined, fetchImpl);
const internals = t as unknown as {
pending: Map<
number,
{
resolve: (r: unknown) => void;
reject: (e: Error) => void;
sessionId?: string;
}
>;
connStreamAbort: AbortController | undefined;
openConnStream: () => void;
ensureConnStream: () => void;
};
const connReject = vi.fn();
const sessionReject = vi.fn();
internals.pending.set(1, {
resolve: () => {},
reject: connReject,
sessionId: undefined,
});
internals.pending.set(2, {
resolve: () => {},
reject: sessionReject,
sessionId: 'sess-1',
});
internals.openConnStream();
await new Promise((r) => setTimeout(r, 0));
// Conn-scoped pending rejected; session-scoped one left for its own stream.
expect(connReject).toHaveBeenCalledTimes(1);
expect(internals.pending.has(1)).toBe(false);
expect(sessionReject).not.toHaveBeenCalled();
expect(internals.pending.has(2)).toBe(true);
// `connStreamAbort` cleared → the next request reopens rather than hanging.
expect(internals.connStreamAbort).toBeUndefined();
internals.ensureConnStream();
await new Promise((r) => setTimeout(r, 0));
expect(getCalls).toBe(2);
t.dispose();
});
it('the conn-stream pump never resolves a SESSION-scoped pending (cross-stream scope guard, mirrors the session readers)', async () => {
// doudouOUC: the connection stream carries only conn-scoped replies. A reply
// frame whose id collides with a session-scoped pending (a daemon routing
// regression) must NOT be cross-delivered here — the same guard the session
// readers and the openConnStream error sweep already enforce.
const replyFrame = `data: ${JSON.stringify({
jsonrpc: '2.0',
id: 5,
result: { leaked: true },
})}\n\n`;
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.endsWith('/acp')) return sseResponse([replyFrame]);
return jsonResponse(200, { ok: true });
}) as unknown as typeof globalThis.fetch;
const t = new AcpHttpTransport('http://d', undefined, fetchImpl);
const pending = (
t as unknown as {
pending: Map<
number,
{
resolve: (r: unknown) => void;
reject: (e: Error) => void;
sessionId?: string;
}
>;
}
).pending;
const resolve = vi.fn();
pending.set(5, { resolve, reject: () => {}, sessionId: 'sess-1' });
(t as unknown as { openConnStream: () => void }).openConnStream();
await new Promise((r) => setTimeout(r, 0));
// Session-scoped pending left untouched for its OWN session stream.
expect(resolve).not.toHaveBeenCalled();
expect(pending.has(5)).toBe(true);
t.dispose();
});
});
@@ -670,3 +670,97 @@ describe('acpRouteTable – matchRoute', () => {
expect(result!.segments[0]).toBe('has space');
});
});
// ---------------------------------------------------------------------------
// Query-backed routes coerce searchParams into typed JSON-RPC params
// ---------------------------------------------------------------------------
describe('acpRouteTable – query param coercion', () => {
function extract(path: string, method: string) {
const url = new URL(`http://d${path}`);
const m = matchRoute(url.pathname, method)!;
expect(m).not.toBeNull();
return {
method: m.mapping.method,
params: m.mapping.extractParams(
m.segments,
undefined,
method,
url.searchParams,
),
};
}
it('GET /file forwards path (string) + maxBytes/line/limit as NUMBERS', () => {
const { method, params } = extract(
'/file?path=src%2Fa.ts&maxBytes=123&line=4&limit=10',
'GET',
);
expect(method).toBe('_qwen/file/read');
expect(params).toEqual({
path: 'src/a.ts',
maxBytes: 123,
line: 4,
limit: 10,
});
// The daemon requires real numbers — a regression to strings would break it.
expect(typeof params['maxBytes']).toBe('number');
});
it('GET /file omits absent optional params', () => {
const { params } = extract('/file?path=a.ts', 'GET');
expect(params).toEqual({ path: 'a.ts' });
});
it('GET /file treats an EMPTY numeric param as absent, not 0 (M3BYd)', () => {
// `?maxBytes=` is present-but-empty; `Number('')` is 0 — must be omitted so
// the daemon doesn't honor an unintended 0.
const { params } = extract('/file?path=a.ts&maxBytes=', 'GET');
expect(params).toEqual({ path: 'a.ts' });
expect('maxBytes' in params).toBe(false);
});
it('GET /file/bytes forwards path + offset/maxBytes as numbers', () => {
const { method, params } = extract(
'/file/bytes?path=a.bin&offset=8&maxBytes=64',
'GET',
);
expect(method).toBe('_qwen/file/read_bytes');
expect(params).toEqual({ path: 'a.bin', offset: 8, maxBytes: 64 });
});
it('GET /stat, /list forward path', () => {
expect(extract('/stat?path=a.ts', 'GET').params).toEqual({ path: 'a.ts' });
expect(extract('/list?path=dir', 'GET').params).toEqual({ path: 'dir' });
});
it('GET /glob forwards pattern', () => {
expect(extract('/glob?pattern=**%2F*.ts', 'GET').params).toEqual({
pattern: '**/*.ts',
});
});
it('GET context-usage coerces detail to the boolean true', () => {
const { method, params } = extract(
'/session/s1/context-usage?detail=true',
'GET',
);
expect(method).toBe('_qwen/session/context_usage');
expect(params).toEqual({ sessionId: 's1', detail: true });
expect(params['detail']).toBe(true); // not the string 'true'
});
it('GET context-usage without detail omits it (sessionId only)', () => {
expect(extract('/session/s1/context-usage', 'GET').params).toEqual({
sessionId: 's1',
});
});
it('GET context-usage with present-but-empty detail (`?detail=`) omits it, not `false`', () => {
// Mirror the numeric `?maxBytes=` rule: an empty value is "not set", so we
// must not forward `{ detail: false }` for a param the caller never set.
const { params } = extract('/session/s1/context-usage?detail=', 'GET');
expect(params).toEqual({ sessionId: 's1' });
expect('detail' in params).toBe(false);
});
});
@@ -0,0 +1,69 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, expectTypeOf } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import * as Transports from '../../src/daemon/transports.js';
import type {
NegotiateTransportOptions,
TransportFactory,
} from '../../src/daemon/transports.js';
const here = dirname(fileURLToPath(import.meta.url));
const pkgPath = join(here, '..', '..', 'package.json');
describe('@qwen-code/sdk/daemon/transports — opt-in transport surface', () => {
it('exports the concrete ACP transports + negotiateTransport at runtime', () => {
// Locks the consumer-facing contract that lets agent-web (and any
// external SDK consumer) get resumable ACP-over-HTTP without forking
// or reaching into source paths. These deliberately live OFF the
// default `./daemon` barrel to keep its browser bundle under budget;
// if a future barrel reshuffle drops them here, this fails loudly.
expect(typeof Transports.AcpHttpTransport).toBe('function');
expect(typeof Transports.AcpWsTransport).toBe('function');
expect(typeof Transports.AutoReconnectTransport).toBe('function');
expect(typeof Transports.RestSseTransport).toBe('function');
expect(typeof Transports.negotiateTransport).toBe('function');
});
it('AcpHttpTransport advertises native replay (supportsReplay)', () => {
// The whole reason to expose this transport: it natively sends
// Last-Event-ID on reconnect, which is what closes the §1.8
// mid-turn content-loss gap against the resumable daemon stream.
const t = new Transports.AcpHttpTransport(
'http://localhost:0',
undefined,
globalThis.fetch.bind(globalThis),
);
expect(t.type).toBe('acp-http');
expect(t.supportsReplay).toBe(true);
});
it('exposes the transport option types at the subpath (compile-time)', () => {
expectTypeOf<NegotiateTransportOptions>().not.toBeNever();
expectTypeOf<TransportFactory>().not.toBeNever();
// The fetchFn injection point must stay on the negotiate options.
expectTypeOf<
NonNullable<NegotiateTransportOptions['fetchFn']>
>().toEqualTypeOf<typeof globalThis.fetch>();
});
it('declares the ./daemon/transports subpath in package.json exports', () => {
// The runtime imports above resolve via the bundler's source mapping;
// this pins the *published* contract so the subpath actually ships.
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {
exports: Record<string, Record<string, string>>;
};
const entry = pkg.exports['./daemon/transports'];
expect(entry).toBeDefined();
// Bracket access: `entry` is typed via an index signature.
expect(entry['types']).toBe('./dist/daemon/transports.d.ts');
expect(entry['import']).toBe('./dist/daemon/transports.js');
expect(entry['require']).toBe('./dist/daemon/transports.cjs');
});
});