Skip to content

feat(daemon): deliver web-shell mid-turn messages into the running turn - #5175

Merged
wenshao merged 8 commits into
QwenLM:mainfrom
wenshao:feat/web-shell-mid-turn-drain
Jun 16, 2026
Merged

feat(daemon): deliver web-shell mid-turn messages into the running turn#5175
wenshao merged 8 commits into
QwenLM:mainfrom
wenshao:feat/web-shell-mid-turn-drain

Conversation

@wenshao

@wenshao wenshao commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Lets the web-shell hand a message the user types while a turn is still running to that turn, instead of holding it until the turn finishes. When a turn is in flight and the user submits, the message is offered to the running turn and drained into it between tool batches; if the turn has already ended (or the message carries images) it falls back to the existing next-turn queue. A message is delivered exactly once — never both mid-turn and as a next-turn prompt.

Mechanically: the spawned qwen --acp child already asks the client for queued messages between tool batches via the craft/drainMidTurnQueue ext-method (this is how the desktop app does mid-turn injection). In qwen serve the client side is the daemon's BridgeClient, which did not implement extMethod, so the child got JSON-RPC -32601 and permanently latched the drain off for the session. This PR implements that path end-to-end.

Why it's needed

On the desktop app a message typed mid-turn is injected into the running turn so the model sees it before the turn ends. On the web-shell the same keystrokes were always held until the next turn, because the daemon never answered the drain. This brings the web-shell to parity: follow-up context ("also check the tests", "actually use X") reaches the model during the turn instead of a turn late.

Reviewer Test Plan

How to verify

Server + SDK (automated):

  • cd packages/acp-bridge && npx vitest run → 464 pass, incl. new BridgeClient.extMethod drain (5) and enqueueMidTurnMessage accept-gate / idle-clear (4).
  • cd packages/sdk-typescript && npx vitest run test/unit/DaemonClient.test.ts test/unit/daemonEvents.test.ts → pass, incl. new POST /session/:id/mid-turn-message contract (4) and mid_turn_message_injected validation (1).
  • cd packages/webui && npx vitest run src/daemon → 129 pass, incl. new sidechannel parse / pub-sub (4).
  • Typecheck: npx tsc --noEmit clean in acp-bridge / sdk-typescript / webui (web-shell has 2 unrelated pre-existing fixture errors).

Manual (browser): open the web-shell, start a tool-heavy turn, type a follow-up and submit while tools are running. Expected: the daemon drains it into the turn (the model's subsequent output reflects it) and it is not resent as a separate next-turn prompt. With an idle session, or a message containing an image, it is queued for the next turn as before.

Evidence (Before & After)

Before: a mid-turn submit was held in the browser's local queue and sent as a brand-new turn after the current one ended; BridgeClient answered the child's drain with -32601, latching it off.

After: a mid-turn submit (text-only, turn live) goes to the daemon queue, is drained into the running turn, emits a mid_turn_message_injected SSE frame, and is removed from the browser queue.

Live daemon run — verified end-to-end against a real qwen serve (this PR's code) over the HTTP API, model DeepSeek/deepseek-v4-pro, on a turn running a shell tool (sleep 10). The endpoint accepted the mid-turn message, the bridge drained it and published the injection frame, and the child recorded it once:

POST /session/:id/mid-turn-message   ->   {"accepted":true}

event: mid_turn_message_injected
data: {"type":"mid_turn_message_injected","data":{"sessionId":"5349a127-…","messages":["also check the tests too"]}}

transcript ~/.qwen/projects/<ws>/chats/<sid>.jsonl :
  {"type":"user","subtype":"mid_turn_user_message",
   "message":{"role":"user","parts":[{"text":"\n[User message received during tool execution]: also check the tests too"}]}}

Exactly-once: the transcript holds exactly 1 mid_turn_user_message and 1 user-role record in total — no duplicate next-turn delivery (the injected text appears a second time only inside the model's own reasoning). The model also acted on it mid-turn: the prompt only asked to sleep 10 then reply FINISHED, yet the model's reasoning reads "…and also check the tests" and it answered "5 test files. FINISHED", confirming the message entered the model's context during the running turn.

Scope: this exercises the server/protocol path (the core drain). The browser-side dedupe of queuedPrompts in the web-shell UI is covered by typecheck + the sidechannel/event unit tests, not a live browser session.

Tested on

OS Status
🍏 macOS ✅ live daemon run (DeepSeek/deepseek-v4-pro) + unit tests + typecheck + package builds
🪟 Windows ⚠️ not tested (CI)
🐧 Linux ⚠️ not tested (CI)

Environment (optional)

Unit tests + tsc --noEmit + npm run build (sdk-typescript, webui) on macOS. No live npm run dev browser session.

Risk & Scope

  • Main risk or tradeoff: exactly-once delivery across the browser/daemon split relies on SSE frame ordering (the injection frame precedes the turn-complete frame) and on the dedupe effect being defined before the next-turn drain effect; both are documented in code comments, and the bridge keeps a server-side accept-gate + idle-clear as defense in depth. The browser daemon-SDK bundle budget was bumped 118KB→119KB for the added surface (there is a hard size gate).
  • Not validated / out of scope: live "sent" rendering of an injected message in the transcript — the queued bubble is removed on injection and the message reappears on reload; in-place live rendering is deferred because injecting a user delta mid-assistant-stream risks transcript block-ordering issues. Also out of scope: mid-turn ingestion over the non-REST ACP transports (acpRouteTable + AcpDispatcher); the web-shell uses the REST transport, which is wired.
  • Breaking changes / migration notes: none. The new event type and endpoint are additive — an older client ignores the mid_turn_message_injected frame, and an older daemon returns 404 to the new endpoint so the browser keeps its next-turn fallback.

Linked Issues

N/A

中文说明

这个 PR 做了什么

让 web-shell 把用户在一个 turn 还在运行时输入的消息交给那个 turn,而不是攒到 turn 结束才发。当一个 turn 正在进行、用户提交时,消息会被提供给运行中的 turn,并在两批 tool 调用之间被排空注入;如果该 turn 已经结束(或消息带图片),则退回现有的"下一轮"队列。每条消息恰好交付一次——绝不会既 mid-turn 注入又作为下一轮 prompt 重发。

机制上:被 spawn 的 qwen --acp 子进程本来就会在每批 tool 之间通过 craft/drainMidTurnQueue ext-method 向 client 索取排队消息(desktop 应用就是这样做 mid-turn 注入的)。在 qwen serve 里 client 侧是 daemon 的 BridgeClient,而它没有实现 extMethod,所以子进程收到 JSON-RPC -32601,该 session 后续永久关闭 drain。本 PR 把这条链路端到端补齐。

为什么需要

在 desktop 应用里,mid-turn 输入的消息会被注入运行中的 turn,模型在 turn 结束前就能看到。而在 web-shell,同样的输入一直被攒到下一轮,因为 daemon 从不应答 drain。本 PR 让 web-shell 对齐:后续上下文("也看下测试""其实用 X")能在 turn 进行中抵达模型,而不是晚一整轮。

审阅者测试计划

如何验证

服务端 + SDK(自动化):

  • cd packages/acp-bridge && npx vitest run → 464 通过,含新增 BridgeClient.extMethod drain(5)与 enqueueMidTurnMessage 接受门控 / idle 清空(4)。
  • cd packages/sdk-typescript && npx vitest run test/unit/DaemonClient.test.ts test/unit/daemonEvents.test.ts → 通过,含新增 POST /session/:id/mid-turn-message 契约(4)与 mid_turn_message_injected 校验(1)。
  • cd packages/webui && npx vitest run src/daemon → 129 通过,含新增 sidechannel parse / pub-sub(4)。
  • 类型检查:npx tsc --noEmitacp-bridge / sdk-typescript / webui 干净(web-shell 有 2 个与本次无关的预存 fixture 报错)。

手动(浏览器):打开 web-shell,开一个 tool 密集的 turn,在 tool 运行时输入后续消息并提交。预期:daemon 把它排空注入该 turn(模型后续输出体现它),且不会再作为单独的下一轮 prompt 重发。若 session 空闲、或消息含图片,则像以前一样进入下一轮队列。

证据(前后对比)

之前:mid-turn 提交被攒在浏览器本地队列,等当前 turn 结束后作为全新一轮发出;BridgeClient-32601 应答子进程的 drain,将其 latch 关闭。

之后:mid-turn 提交(纯文本、turn 进行中)进入 daemon 队列,被排空注入运行中的 turn,发出 mid_turn_message_injected SSE 帧,并从浏览器队列移除。

真机 daemon 验证——对真实运行的 qwen serve(本 PR 代码)经 HTTP API 端到端验证,模型 DeepSeek/deepseek-v4-pro,在一个跑 shell tool(sleep 10)的 turn 上注入。端点接受了 mid-turn 消息,bridge 排空并发布了注入帧,子进程只记录了一次:

POST /session/:id/mid-turn-message   ->   {"accepted":true}

event: mid_turn_message_injected
data: {"type":"mid_turn_message_injected","data":{"sessionId":"5349a127-…","messages":["also check the tests too"]}}

转录 ~/.qwen/projects/<ws>/chats/<sid>.jsonl :
  {"type":"user","subtype":"mid_turn_user_message",
   "message":{"role":"user","parts":[{"text":"\n[User message received during tool execution]: also check the tests too"}]}}

Exactly-once:转录中恰好 1mid_turn_user_message、user 角色记录共 1 条——没有第二次的 next-turn 投递(注入文本第二次出现仅在模型自身的推理里)。模型也在 turn 中途据此行动:原 prompt 只要求 sleep 10 后回 FINISHED,而模型推理写道"…and also check the tests"、最终答复"5 test files. FINISHED",证明该消息在 turn 进行中进入了模型上下文。

范围:这验证的是服务端/协议路径(核心 drain)。web-shell UI 里 queuedPrompts 的浏览器侧去重由类型检查 + sidechannel/事件单测覆盖,非 live 浏览器会话。

测试平台

OS 状态
🍏 macOS ✅ 真机 daemon 验证(DeepSeek/deepseek-v4-pro)+ 单测 + 类型检查 + 打包
🪟 Windows ⚠️ 未测(CI)
🐧 Linux ⚠️ 未测(CI)

环境(可选)

macOS 上的单测 + tsc --noEmit + npm run build(sdk-typescriptwebui)。未做 live npm run dev 浏览器会话。

风险与范围

  • 主要风险/权衡:跨浏览器/daemon 的 exactly-once 依赖 SSE 帧顺序(注入帧先于 turn-complete 帧)以及去重 effect 定义在下一轮 drain effect 之前;两者都在代码注释中说明,且 bridge 端保留"仅活跃时接受 + idle 清空"作纵深防御。浏览器 daemon-SDK bundle 预算因新增面从 118KB 上调到 119KB(有硬性体积门禁)。
  • 未验证 / 范围外:注入消息在 transcript 中的 live "已发"渲染——注入时移除排队气泡,消息在 reload 后重新显示;就地 live 渲染暂缓,因为在 assistant 流式中插入用户 delta 有 transcript 块排序风险。另:非 REST 的 ACP 传输(acpRouteTable + AcpDispatcher)的 mid-turn 摄入也在范围外;web-shell 用 REST 传输,已接通。
  • 破坏性变更 / 迁移说明:无。新事件类型与端点都是增量的——旧 client 忽略 mid_turn_message_injected 帧,旧 daemon 对新端点返回 404,浏览器据此保留下一轮兜底。

关联 Issue

N/A

Let the web-shell hand a message typed while a turn is running to that turn instead of holding it until the next turn. The daemon now answers the ACP child's `craft/drainMidTurnQueue` ext-method from a per-session queue the browser feeds; previously BridgeClient had no `extMethod`, so the child got -32601 and latched the drain off for the session.

Server: `SessionEntry` gains a mid-turn queue; `bridge.enqueueMidTurnMessage` accepts only while a turn is active and the queue is emptied at the idle boundary; `BridgeClient.extMethod` drains it and publishes a `mid_turn_message_injected` SSE frame. A new `POST /session/:id/mid-turn-message` endpoint plus DaemonClient/DaemonSessionClient methods feed the queue.

Browser: `enqueuePrompt` also pushes text-only messages to the daemon; a sidechannel hook drops the matching entries from the local queue when the injection frame arrives. A message is therefore delivered exactly once — mid-turn when a turn is live, or via the existing next-turn queue otherwise — and never both. Exactly-once rests on the injection frame arriving in order ahead of the turn-complete frame, plus the dedupe effect running before the next-turn drain.

Out of scope: live "sent" rendering of an injected message (it shows on reload); ACP-transport (non-REST) ingestion parity.

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

This PR implements mid-turn message delivery for the web-shell, bringing it to parity with the desktop app. The implementation is well-designed with careful attention to exactly-once delivery semantics across the browser/daemon split.

Strengths

  1. Solid extMethod implementation in BridgeClient with defensive programming: sessionId validation, entry resolution, and try-catch for closed event bus
  2. Proper accept-gate and idle-clear in enqueueMidTurnMessage — rejects empty messages and idle-session messages to prevent double delivery, with idle-clear in the settle handler to prevent stale injection
  3. Well-documented race condition handling — comments clearly explain the SSE frame ordering dependency and the defense-in-depth strategy
  4. Clean sidechannel implementation following the established followupSidechannel pattern with proper message filtering and fresh-object publishing for useSyncExternalStore
  5. Comprehensive test coverage (464 bridge tests, 129 webui tests) covering the new functionality
  6. Backward compatibility — new event type and endpoint are additive; older clients gracefully degrade

Observations

The implementation correctly handles the key challenge: ensuring exactly-once delivery across the browser/daemon split. The accept-gate (rejecting idle messages) combined with the idle-clear (dropping undrained messages when the session goes idle) prevents the double-delivery scenario where both the daemon and browser would inject the same message.

The browser bundle budget bump from 118KB to 119KB is reasonable for the added surface area.

Verification

  • CI: All checks passing (no failures)
  • Presubmit: No downgrade required, no overlap with existing comments

Assessment

The implementation is solid and well-tested. The code is well-documented with clear explanations of the non-trivial delivery semantics. The implementation correctly handles the race conditions inherent in cross-process message delivery.

Verdict: COMMENT (downgraded from APPROVE due to pending CI)

The implementation looks ready to merge once CI passes.

— claude-opus-4-6 via Qwen Code /review

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully: Qwen review aborted with an API error before posting comments. See workflow logs.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Downgraded from Request changes to Comment: self-PR; CI failing (review-pr). The substantive feedback — one correctness issue (duplicate delivery) plus three hardening/test suggestions — is in the inline comments. — claude-opus-4-8[1m] via Qwen Code /qreview

Comment thread packages/webui/src/daemon/midTurnInjectedSidechannel.ts Outdated
Comment thread packages/cli/src/serve/server.ts Outdated
Comment thread packages/acp-bridge/src/bridgeClient.ts Outdated
Comment thread packages/acp-bridge/src/bridge.ts
@wenshao

wenshao commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

Review follow-ups on the web-shell mid-turn drain.

[Critical] The injected-message sidechannel was single-slot (latest-wins), so two drain frames landing back-to-back — a multi-batch turn, or a backgrounded tab flushing buffered SSE — coalesced: the first batch's messages were never removed from the browser queue and got resent next turn = double delivery, the exact failure this feature prevents. The sidechannel now ACCUMULATES batches; the consumer reconciles every batch and then clears. The queue-dedup is extracted into a pure `removeInjectedFromQueue` helper and unit-tested (the App.tsx path had no test, so this regressed silently).

Hardening and tests:
- Cap mid-turn message length (server, 16 KB) and per-session queue depth (bridge, 20), matching the bounds on the sibling /btw and /prompt; over-cap returns `{accepted:false}` and the browser keeps the message for its next-turn queue.
- Drop the dead try/catch around `EventBus.publish` (never-throws contract — "don't wrap publish()"); check the return value and emit one diagnostic line per non-empty drain.
- Assert the settle-clear: a new test seam exposes the agent-side connection so a test can drive `extMethod('craft/drainMidTurnQueue')` after settle and assert the leftover was cleared (not re-drained next turn), plus the back-to-back FIFO survival case.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds end-to-end support for mid-turn user message injection in the web-shell flow: text-only messages submitted while a daemon session is actively running can be queued on the daemon, drained by the running ACP child between tool batches, and echoed back to the browser via an SSE event so the browser can dedupe its local “next-turn” queue.

Changes:

  • Add a new daemon HTTP API (POST /session/:id/mid-turn-message) and SDK/client helpers to enqueue mid-turn messages.
  • Implement the ACP client-side extMethod handler in the daemon bridge (craft/drainMidTurnQueue) and publish a mid_turn_message_injected SSE signal on successful drains.
  • Add webui/web-shell sidechannel + hook + queue reconciliation logic to ensure exactly-once delivery (mid-turn injection vs next-turn resend), plus unit tests.

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/webui/src/daemon/useDaemonMidTurnInjected.ts New hook to consume accumulated mid_turn_message_injected batches via useSyncExternalStore.
packages/webui/src/daemon/session/types.ts Adds enqueueMidTurnMessage to the daemon session actions interface.
packages/webui/src/daemon/session/DaemonSessionProvider.tsx Parses/publishes mid_turn_message_injected SSE frames into a sidechannel.
packages/webui/src/daemon/session/actions.ts Adds best-effort enqueueMidTurnMessage action that resolves {accepted:false} on failure.
packages/webui/src/daemon/midTurnInjectedSidechannel.ts New accumulating sidechannel store + parser for injected batches.
packages/webui/src/daemon/midTurnInjectedSidechannel.test.ts Tests for parsing and accumulating/clearing semantics.
packages/webui/src/daemon/index.ts Re-exports useDaemonMidTurnInjected.
packages/webui/src/daemon-react-sdk.ts Re-exports useDaemonMidTurnInjected from the React SDK entrypoint.
packages/web-shell/client/midTurnDedup.ts New dedupe helper to remove injected messages from local queued prompts.
packages/web-shell/client/midTurnDedup.test.ts Unit tests for queue reconciliation behavior (multi-batch, count-based, images).
packages/web-shell/client/App.tsx Enqueues mid-turn messages best-effort and consumes injected batches to dedupe local queue.
packages/sdk-typescript/test/unit/daemonEvents.test.ts Adds schema validation coverage for mid_turn_message_injected.
packages/sdk-typescript/test/unit/DaemonClient.test.ts Adds contract tests for enqueueMidTurnMessage POST endpoint behavior.
packages/sdk-typescript/src/daemon/types.ts Adds DaemonMidTurnMessageResult type for the new endpoint.
packages/sdk-typescript/src/daemon/index.ts Re-exports new event/result types.
packages/sdk-typescript/src/daemon/events.ts Registers new known event type + guard and reducer passthrough.
packages/sdk-typescript/src/daemon/DaemonSessionClient.ts Adds session-scoped enqueueMidTurnMessage wrapper.
packages/sdk-typescript/src/daemon/DaemonClient.ts Adds REST transport implementation for POST /session/:id/mid-turn-message.
packages/sdk-typescript/scripts/build.js Bumps browser bundle size budget for added daemon surface.
packages/cli/src/serve/server.ts Adds the new REST endpoint for mid-turn message enqueueing.
packages/acp-bridge/src/internal/testUtils.ts Keeps AgentSideConnection handle for driving ext-methods in tests.
packages/acp-bridge/src/bridgeTypes.ts Extends bridge interface with enqueueMidTurnMessage.
packages/acp-bridge/src/bridgeClient.ts Implements extMethod for craft/drainMidTurnQueue + SSE echo event.
packages/acp-bridge/src/bridgeClient.test.ts Adds unit tests for drain behavior and methodNotFound handling.
packages/acp-bridge/src/bridge.ts Adds per-session mid-turn queue, accept gating, depth cap, and settle-time clearing.
packages/acp-bridge/src/bridge.test.ts End-to-end tests for accept gating, draining, and idle-boundary clearing semantics.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/cli/src/serve/server.ts
Comment thread packages/sdk-typescript/src/daemon/events.ts
@wenshao

wenshao commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

Code review

Read the full diff twice and cross-checked the concurrency-critical paths against the source on this branch (sendPrompt settle / accept-gate in bridge.ts, the drain in Session.ts, and the relative ordering of the dedup effect vs. the idle-drain effect in App.tsx).

Core correctness — exactly-once holds ✅

This is the load-bearing claim, so I traced it specifically. The three assumptions all hold:

  • Injection frame precedes the turn-complete frame — the drain is called by the child during prompt execution (before result resolves) and publishes synchronously; turn-complete publishes in result.then. A single SSE stream preserves order, so the browser sees the injection frame first.
  • Dedup effect runs before the resend effect — the dedup effect is defined at App.tsx:1312, the idle-drain effect at App.tsx:2409; React flushes passive effects in definition order within a commit, and popNextQueuedPrompt() reads queuedPromptsRef.current (which dedup mutated synchronously), not the queuedPrompts state snapshot.
  • Server-side settle-clear is gated on pendingPromptCount === 0 — already drained ⇒ queue empty ⇒ clear is a no-op; not drained ⇒ cleared at settle and the browser resends from its own queue. The accept-gate rejects idle messages with the same predicate.

I worked through the "enqueue interleaves with settle-clear in the same tick" and "back-to-back FIFO across prompts" races — both enqueueMidTurnMessage and the settle finally are fully synchronous with no await, so under a single-threaded runtime every interleaving converges to exactly-once.

Main finding — the new endpoint skips the per-session client-ownership check its siblings enforce ⚠️

/prompt and /btw both run parseClientIdHeader(req, res) and forward the client-declared id to the bridge, which validates it via resolveTrustedClientId(entry, clientId) (bridge.ts:1133) — rejecting with InvalidClientIdError when the id isn't a member of the session. The new route skips this entirely (server.ts:2716):

app.post('/session/:id/mid-turn-message', mutate(), (req, res) => {
  ...
  const result = bridge.enqueueMidTurnMessage(sessionId, message); // no context

Consequences:

  • Authorization surfacemutate() only gates on the bearer token, not the session↔client binding. A token-holding client attached to session A can push a mid-turn message into session B's running turn — exactly what resolveTrustedClientId blocks on /prompt / /btw.
  • Inconsistency — the SDK's DaemonClient.enqueueMidTurnMessage already sends x-qwen-client-id; the server drops it on the floor.

Severity: low for the single-user web-shell deployment, medium as a defense-in-depth gap on a multi-client daemon. The fix is cheap and structurally identical to the siblings:

// route
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
const result = bridge.enqueueMidTurnMessage(
  sessionId, message, clientId !== undefined ? { clientId } : undefined);

// bridge.enqueueMidTurnMessage, at the top
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
resolveTrustedClientId(entry, context?.clientId); // throws on non-member; sendBridgeError handles it

Minor notes

  • bus closed degrades to at-most-twice — if publish returns falsy because the bus is closed, the child already has the message (model saw it) but the browser never gets the echo and may resend next turn. Already logged, and only happens during session teardown (when the browser is likely gone), so acceptable — but the description's "exactly-once" would be more accurate as "exactly-once on the normal path, degrading in the teardown window."
  • craft/drainMidTurnQueue is defined twice (bridgeClient.ts and Session.ts), held together by a "Must match" comment — fine, but a shared constant would be sturdier if there's a common package.
  • Count-based text dedup in removeInjectedFromQueue can in principle remove a same-text, never-sent queue entry (image-bearing entries are explicitly protected). Acceptable heuristic, worth being aware of.

Test coverage

Solid across all three layers. clears undrained messages at settle and keeps the queue across a back-to-back prompt FIFO pin the hardest invariants directly. The one thing not covered by automation is the browser-side ordering (dedup-effect before resend) — the PR is honest about this; it rests on React effect ordering + synchronous ref writes, sound but without a regression test.

Verdict

Clean architecture, well-argued invariants, good tests — mergeable. I'd land the client-ownership check before merge (align with the sibling routes, and use the header the SDK already sends); the rest are optional polish.

中文

代码评审

把整个 diff 通读两遍,并对照本分支真实源码核了并发关键路径(bridge.tssendPrompt settle / accept-gate、Session.ts 的 drain、App.tsx 里 dedup effect 与 idle-drain effect 的相对顺序)。

核心正确性 —— exactly-once 成立 ✅

这是命门,专门追了一遍,三个 load-bearing 假设都站得住:

  • 注入帧先于 turn-complete 帧 —— drain 在 prompt 执行期间(result resolve 之前)由子进程调用并同步 publish;turn-complete 在 result.then 里 publish。同一条 SSE 流保序,浏览器先收到注入帧。
  • dedup effect 先于 resend effect —— dedup effect 定义在 App.tsx:1312,idle-drain effect 在 App.tsx:2409;React 同一次 commit 内按定义顺序 flush passive effect,且 popNextQueuedPrompt() 读的是 queuedPromptsRef.current(dedup 已同步改过的 ref),不是 queuedPrompts state 快照。
  • 服务端 settle-clear 以 pendingPromptCount === 0 为门 —— 已 drain ⇒ 队列空 ⇒ clear 是 no-op;未 drain ⇒ settle 时清掉,浏览器用自己的队列重发。accept-gate 用同一谓词拒绝 idle 消息。

推演了「enqueue 与 settle-clear 同一 tick 交错」「back-to-back FIFO 跨 prompt」两种竞态 —— enqueueMidTurnMessage 与 settle finally 全同步、无 await,单线程下每种排列都收敛到恰好一次。

主要发现 —— 新端点漏掉了 sibling 路由强制的 session↔client 归属校验 ⚠️

/prompt/btw 都走 parseClientIdHeader(req, res) 把 client 自报的 id 透传给 bridge,bridge 用 resolveTrustedClientId(entry, clientId)(bridge.ts:1133)校验 —— id 不属于该 session 时抛 InvalidClientIdError 新路由完全跳过(server.ts:2716):

app.post('/session/:id/mid-turn-message', mutate(), (req, res) => {
  ...
  const result = bridge.enqueueMidTurnMessage(sessionId, message); // 无 context

后果:

  • 越权面 —— mutate() 只做 bearer 令牌门禁,不做 session↔client 绑定。持令牌、attach 在 session A 的 client 可以往 session B 的运行 turn 塞 mid-turn 消息 —— 这正是 resolveTrustedClientId/prompt / /btw 上拦住的。
  • 不一致 —— SDK 的 DaemonClient.enqueueMidTurnMessage 已经在发 x-qwen-client-id,服务端却把它丢了。

严重度:web-shell 单用户部署偏低;多 client 共享 daemon 时是 medium 的纵深防御缺口。修复极廉价、与 sibling 完全同构:

// 路由
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
const result = bridge.enqueueMidTurnMessage(
  sessionId, message, clientId !== undefined ? { clientId } : undefined);

// bridge.enqueueMidTurnMessage 开头
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
resolveTrustedClientId(entry, context?.clientId); // 非成员即抛;sendBridgeError 已处理

次要

  • bus closed 退化成 at-most-twice —— publish 因总线关闭返回 falsy 时,消息已交给子进程(模型已看到)但浏览器收不到回声,可能下一轮重发。已 log,且仅发生在 session 拆除时(此时浏览器多半已断开),可接受 —— 但描述里的「exactly-once」更准确的说法是「正常路径 exactly-once,拆除窗口降级」。
  • craft/drainMidTurnQueue 常量定义两份(bridgeClient.tsSession.ts),靠「Must match」注释维系 —— 可以,但若有共享包,提一个常量更稳。
  • 基于文本计数的去重:removeInjectedFromQueue 原则上可能删掉一条同字面、从未 mid-turn 发过的队列条目(带图条目已显式保护)。可接受的启发式,知道即可。

测试覆盖

三层都扎实。clears undrained messages at settlekeeps the queue across a back-to-back prompt FIFO 直接锁住了最难的不变量。唯一没被自动化覆盖的是浏览器侧时序(dedup-effect 先于 resend)—— PR 对此很坦诚;它依赖 React effect 顺序 + 同步 ref 写,逻辑成立但无回归测试兜底。

结论

架构干净、不变量论证扎实、测试到位 —— 可合并。建议合并前补上 client 归属校验(与 sibling 路由对齐,用上 SDK 已经在发的头);其余为可选打磨。

- POST /session/:id/mid-turn-message now length-checks and enqueues the TRIMMED message (it was checking the raw `message.length` while the bridge stores the trimmed value), so whitespace-padded input whose real content fits is no longer rejected.
- Correct the `mid_turn_message_injected` docs (events.ts payload + bridgeClient.ts): it is a transient dedupe signal, not a transcript render — the message reaches the model mid-turn and the persisted transcript shows it on reload.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

整体设计扎实,exactly-once 不变量在服务端(accept-gate + settle-clear)和浏览器端(按 batch reconcile)双侧均显式表达,并被新增测试锁死。951e4a4c 已正确响应自评 4 条,f8e801d7 又消化了 Copilot 的 trim/length 顺序与 docstring 两条——目前没有阻断项。

仅留 3 条 hardening/讨论建议,均不阻断合入:

  1. 多客户端下 SSE 帧缺少 originatorClientId 隔离(详见 inline)。Web-shell 主流是单 client,先合入 + follow-up issue 即可;最便宜的修法是把 originatorClientId 一并写进帧 data,与同文件 prompt-suggestion 等路径一致。
  2. MAX_MID_TURN_QUEUE_DEPTH = 20 硬编码(详见 inline),sibling maxPendingPromptsPerSession 是可配置项。当前默认值合理,仅建议在注释里点出后续可配化的口子。
  3. events.publish 返回 falsy(teardown / closed bus)的降级分支无测试(详见 inline)。该路径会触发"child 已注入、浏览器仍重发"的双发降级,stderr 日志已经埋点,建议补一个 unit 用例锁定契约。

Strengths

  • MID_TURN_QUEUE_DRAIN_METHOD = 'craft/drainMidTurnQueue' 与 desktop / cli Session.ts 严格一致,避免 child latched-off。
  • accept-gate(pendingPromptCount > 0) + settle-clear(pendingPromptCount === 0)的"真正 idle"语义被 back-to-back FIFO 测试锁死。
  • 累积式 sidechannel + useSyncExternalStore 的 EMPTY 引用稳定 + clear 空操作短路,多 batch turn 已用回归测试覆盖。
  • 16KB 单条 / 20 条深度上限,超限即 reject、浏览器走 next-turn 兜底,无丢消息风险。
  • enqueueMidTurnMessageactions.ts 内全部归约为 { accepted: false },silent 不弹 user-facing notice,符合"优化路径而非用户动作"的语义。
  • BridgeClient.extMethod 对未知方法 throw RequestError.methodNotFound(method) 返回 -32601,让 child 的 drain caller 正确 latch off。

Comment thread packages/acp-bridge/src/bridgeClient.ts Outdated
Comment thread packages/acp-bridge/src/bridgeClient.ts Outdated
Comment thread packages/acp-bridge/src/bridge.ts
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully: Qwen review aborted with an API error before posting comments. See workflow logs.

Comment thread packages/cli/src/serve/server.ts
Comment thread packages/web-shell/client/App.tsx Outdated
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/webui/src/daemon/midTurnInjectedSidechannel.ts

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Downgraded from Request changes to Comment: self-PR; CI failing (Post Coverage Comment, review-pr).

Summary: 1 critical correctness issue (useEffect + consume() race can lose a batch between render and effect → double delivery) plus 3 suggestions (telemetry route missing, cross-package string duplication, no rejection logs). Test coverage gaps also noted (queue depth overflow, trimming, bus-closed path, error suppression wrapper — the HTTP endpoint test gap overlaps with the prior review's comment).

— qwen3.7-max via Qwen Code /review

Comment thread packages/webui/src/daemon/useDaemonMidTurnInjected.ts Outdated
Comment thread packages/cli/src/serve/server.ts
Comment thread packages/acp-bridge/src/bridgeClient.ts Outdated
Comment thread packages/acp-bridge/src/bridge.ts Outdated
wenshao added 2 commits June 16, 2026 11:40
…mid-turn dedupe

Addresses the /review main finding and doudouOUC's inline comments on the
web-shell mid-turn drain.

- Authorize the mid-turn endpoint per session (review main finding): the
  route now forwards the client id via `parseClientIdHeader` and
  `enqueueMidTurnMessage` runs `resolveTrustedClientId` before queuing —
  mirrors `/prompt` and `/btw`, so a token-holding client bound to another
  session can no longer push into this turn (throws `InvalidClientIdError`).

- Route the drain's SSE echo per originator (doudouOUC #3417739340): the
  trusted client id is recorded on each queue entry and the drain publishes
  one `mid_turn_message_injected` frame per originator carrying
  `originatorClientId`, so a peer on the same session can't dedupe a
  coincidentally-equal entry it never queued.

- Wire the web-shell consumer to its own client id: the daemon now stamps
  every drained frame, and the web-shell always sends a client id, so
  `removeInjectedFromQueue` must filter on it. Plumbed `clientId` onto
  `DaemonConnectionState` (set from the bound session) and passed
  `connection.clientId` into the dedupe — without this the new filter would
  skip every batch, leaving our own messages to be resent next turn (the
  exact double-delivery this feature prevents).

- Tests (doudouOUC #3417739347 + coverage for the above): per-originator
  publishing, the `published === false` (bus-closed) degradation, the
  endpoint ownership gate, end-to-end originator stamping, and the
  web-shell originator-filtering matrix (match / peer-skip / anonymous /
  mixed / missing-id regression guard).

- Comment-only: point `MAX_MID_TURN_QUEUE_DEPTH` at
  `maxPendingPromptsPerSession` as the promotion model (doudouOUC #3417739352).
…servability

Addresses the qwen3.7-max /review pass on the web-shell mid-turn drain (3
criticals + 6 suggestions).

Criticals
- consume() race (sidechannel): the buffer is read during render but reconciled
  in an async effect, so a frame appended in that window was wiped by an
  unconditional clear → resent next turn (double delivery). `consume` now does a
  compare-and-swap — it only clears if the buffer still holds the exact snapshot
  it reconciled; a newly-arrived batch survives to the next reconcile.
- Late-arriving enqueue (web-shell): the fire-and-forget mid-turn POST is now
  scoped to a per-turn AbortController, aborted when the turn settles, so a slow
  push can't land during a SUBSEQUENT turn and be injected twice. An aborted
  push resolves `{ accepted: false }`, so the message just follows its normal
  next-turn path.
- HTTP route had zero tests: add a `POST /session/:id/mid-turn-message` suite
  (accept, reject, missing/empty/oversized body, unknown session, malformed
  client id) plus the fakeBridge wiring it needed.

Suggestions
- Telemetry: register `mid-turn-message` in the daemon route regex so the
  endpoint gets a route label / spans / latency like its siblings.
- Single source of truth for `craft/drainMidTurnQueue`: export
  `MID_TURN_QUEUE_DRAIN_METHOD` from acp-bridge and import it in both the
  answerer (BridgeClient) and the caller (Session.ts), so a rename can't desync
  them into a silent -32601 latch.
- Observability: `enqueueMidTurnMessage` now logs idle/empty/full rejects and
  the drop-at-settle path (the drain already logged); rejects are low-volume
  (the browser only pushes when it believes a turn is live).
- Buffer safety cap (sidechannel): bound the accumulating buffer and evict
  oldest so an orphaned consumer can't grow it without limit.
- Tests: depth-cap overflow + trimming (bridge), compare-and-swap + cap
  (sidechannel).
Comment thread packages/web-shell/client/App.tsx Outdated
Comment thread packages/sdk-typescript/src/daemon/events.ts
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully: Qwen review aborted with an API error before posting comments. See workflow logs.

…iginator doc

Addresses the follow-up /qreview pass.

- Cross-session wipe (web-shell): the dedupe reconcile is session-scoped, but
  `consume()` cleared the whole accumulating buffer. The buffer is a
  cross-session singleton, so a late `mid_turn_message_injected` frame for the
  PREVIOUS session (e.g. after an in-place `/resume` switch) was wiped
  un-reconciled and lost on switch-back → resent next turn = double delivery.
  Replace the blanket clear with identity-removal: `consumeSidechannelMidTurnInjected(handled)`
  drops only the batches actually reconciled (the active session's). Batches for
  other sessions — and frames that arrived after the render snapshot (the
  render→effect race the prior compare-and-swap covered) — are not in `handled`
  and stay buffered for their own reconcile. So this subsumes the race fix and
  adds multi-session correctness. `consume` is now stable (no per-render churn).

- originatorClientId doc (SDK): the field is declared on
  `DaemonMidTurnMessageInjectedData` (the `data` shape) but, unlike the sibling
  permission events, this event is not reduced and the daemon never merges the
  id into `data` — it rides the SSE envelope (`event.originatorClientId`) and is
  lifted into `data` only by the web-shell's own parser. Document that so an SDK
  consumer doesn't read an always-undefined `data.originatorClientId` and treat
  every batch as anonymous (dedupe-for-all foot-gun).

- Tests: identity-removal across the race, the cross-session leave-behind, and
  the already-evicted no-op.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ CI is failing on 5 checks (Post Coverage Comment, review-pr, Test on ubuntu/macos/windows). Review findings below. — DeepSeek/deepseek-v4-pro via Qwen Code /review

Comment thread packages/webui/src/daemon/session/actions.ts Outdated
Comment thread packages/acp-bridge/src/bridgeClient.ts Outdated
Comment thread packages/sdk-typescript/src/daemon/DaemonClient.ts Outdated
Comment thread packages/web-shell/client/App.tsx
Comment thread packages/webui/src/daemon/midTurnInjectedSidechannel.ts
Comment thread packages/web-shell/client/App.tsx Outdated
Comment thread packages/acp-bridge/src/bridgeClient.ts
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/webui/src/daemon/session/DaemonSessionProvider.tsx

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Downgraded from Request changes to Comment: self-PR; CI failing (review-pr).

Summary: All Critical findings from the previous round have been properly addressed — compare-and-swap clear, AbortController scoping, originator routing, telemetry route, shared drain constant, rejection logging, and extensive test coverage. The incremental changes are well-structured. Three remaining Suggestions focus on test gaps and browser-side observability.

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/serve/server.test.ts
Comment thread packages/web-shell/client/App.tsx
Comment thread packages/webui/src/daemon/midTurnInjectedSidechannel.ts Outdated
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully: Qwen review aborted with an API error before posting comments. See workflow logs.

wenshao added 2 commits June 16, 2026 15:01
…rn-drain

# Conflicts:
#	packages/acp-bridge/src/bridge.ts
#	packages/acp-bridge/src/bridgeTypes.ts
…, tests

Addresses the latest /review pass (DeepSeek + qwen3.7-max). Stale duplicates of
already-shipped fixes (shared drain-method constant, depth-cap test, consume
cross-session/race) are answered inline; the substantive new items:

- DaemonClient.enqueueMidTurnMessage now routes through `fetchWithTimeout` like
  every other method, so a hung daemon can't wedge the void-ed caller in
  actions.ts forever. The helper composes the caller's signal with its timeout,
  so the turn-settle abort still propagates. (+ propagation and timeout tests.)

- Browser-side observability (mirrors the server-side writeStderrLine added
  earlier): the actions catch logs non-abort failures at debug (an abort is the
  designed settle cancel, kept silent); the settle-abort and sidechannel buffer
  eviction each get a `console.debug`; and a debug warns when stamped batches
  arrive but `connection.clientId` is undefined (dedupe would skip them).

- Docs: spell out the `originatorClientId` CONTRACT — a consumer that dedupes
  MUST compare it against its own client id (the daemon broadcasts, it does not
  route), or it drops another client's coincidentally-equal message.

- Tests: `POST /session/:id/mid-turn-message` InvalidClientIdError → 400; the
  SSE event-pump routing of `mid_turn_message_injected` to the sidechannel (not
  the transcript); DaemonClient signal propagation + hung-daemon timeout.
@wenshao

wenshao commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review — all 3 comments resolved

#1 originatorClientId isolation (bridgeClient.ts:577)

Resolved. The fix exceeds the suggestion:

  • MidTurnQueueEntry carries originatorClientId (from resolveTrustedClientId).
  • BridgeClient.extMethod groups drained messages per originator and publishes one frame per group with the id on the envelope.
  • Client-side removeInjectedFromQueue now accepts clientId and skips batches from other clients.
  • DaemonMidTurnMessageInjectedData has an explicit CONTRACT JSDoc warning consumers MUST compare the id.
  • enqueueMidTurnMessage validates clientId against the session (throws InvalidClientIdError), same as /prompt and /btw.

#2 published === false branch untested (bridgeClient.ts:581)

Resolved. New test: "still returns the drained messages to the child when the echo frame is dropped (bus closed)" — asserts (a) messages returned, (b) queue emptied, (c) echo frame dropped logged to stderr.

The writeStderrLine vs daemonLog point: accepted the author's rationale that acp-bridge has no daemonLog/debugLogger (it uses writeStderrLine — 16 existing uses). Keeping it consistent within the package is the right call; future logger migration would be package-wide.

#3 MAX_MID_TURN_QUEUE_DEPTH hardcoded (bridge.ts:671)

Resolved. Comment now reads:

Intentionally a fixed const for now; if this ever needs tuning, promote it to a BridgeOptions knob the same way maxPendingPromptsPerSession (the analogous bound /prompt enforces, default 5) is wired.

Plus a boundary test (21st message rejected).


Other improvements since last review

The author also addressed the qwen3.7-max round-3/4 findings:

Fix Commit
Cross-turn double delivery (per-turn AbortController, abort on settle) f794006 + ea0a87f
useEffect race / blanket consume → identity-removal consume (session-scoped) 6b0a892
Server route tests (POST /session/:id/mid-turn-message, 7 cases) f794006
DaemonClient.enqueueMidTurnMessage routes through fetchWithTimeout ea0a87f
DaemonSessionProvider integration test (parse → publish → continue) ea0a87f
Sidechannel buffer eviction cap (MAX_PENDING_BATCHES = 64) 6b0a892
Client-side observability (console.debug on abort/eviction/clientId-skip) ea0a87f
MID_TURN_QUEUE_DRAIN_METHOD shared constant exported from bridgeTypes f794006

Remaining known gap

App-level AbortController lifecycle (create ref → share → abort on settle) has no test — author acknowledges this explicitly and proposes a follow-up RTL test. The signal contract is covered at the SDK layer. Acceptable as-is.


Verdict: Ship it. No blocking issues remain. The exactly-once contract is well-defended at every layer (accept-gate, settle-clear, per-originator SSE, identity-removal consume, per-turn abort), and each invariant has at least one test that fails when the relevant line is deleted.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR! This is a well-scoped feature that brings web-shell to parity with the desktop app for mid-turn message injection.

Template looks good ✓

Direction: Solid. The desktop app already injects mid-turn messages; the web-shell lagging behind is a genuine parity gap. Claude Code's CHANGELOG has multiple entries about mid-turn messages being lost or dropped (--bare mode discarding mid-turn messages, --resume losing mid-turn input, messages typed while working not persisted) — this is clearly a real user concern, not a theoretical one. The feature solves a concrete problem: follow-up context typed during a tool-heavy turn reaching the model before the turn ends.

Approach: The PR wires the drain end-to-end across five packages (acp-bridge, cli, sdk-typescript, webui, web-shell), which matches the scope of the feature. The exactly-once delivery design is thorough — accept-gate (busy only), idle-clear at settle, per-originator SSE echo frames, browser-side dedupe by text+session, abort controller for turn-settle race. Each race condition has a corresponding test. The 16KB per-message cap and queue depth limit (20) are reasonable DoS guards.

One minor note: there's a small unrelated formatting change in server.test.ts (reformatting of the createServeApp call in the "requires bearer auth" test). Cosmetic and harmless, but worth splitting out in a future PR to keep diffs focused.

Nothing in the diff is unnecessary for the stated goal. Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

方向: 合理。桌面应用已经支持 mid-turn 消息注入,web-shell 在这方面落后是真实的对齐缺口。Claude Code 的 CHANGELOG 多次出现 mid-turn 消息丢失的修复(--bare 模式丢弃 mid-turn 消息、--resume 丢失 mid-turn 输入、工作期间输入的消息未持久化)——说明这是真实的用户痛点,不是理论问题。该功能解决了一个具体问题:在 tool 密集的 turn 期间输入的后续上下文能在 turn 结束前抵达模型。

方案: PR 在五个包(acp-bridge、cli、sdk-typescript、webui、web-shell)之间端到端地接通了 drain,与功能范围匹配。exactly-once 交付设计很周全——接受门控(仅 busy 时)、idle 时清空、按 originator 分组的 SSE 回显帧、浏览器侧按文本+session 去重、turn-settle 竞态的 abort controller。每个竞态条件都有对应测试。16KB 单条消息上限和队列深度限制(20)是合理的 DoS 防护。

一个小问题:server.test.ts 中有一处无关的格式化改动(createServeApp 调用的重排)。无伤大雅,但建议未来拆成单独 PR 以保持 diff 聚焦。

diff 中没有超出目标所需的改动。进入代码审查 🔍

Qwen Code · qwen3.7-max

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All 17 inline comments resolved — replies posted above.

The exactly-once delivery contract is now defended at every layer:

  • Server: accept-gate (pendingPromptCount > 0) + settle-clear + per-originator queue entry + clientId authorization + depth/length caps + logging
  • Child: craft/drainMidTurnQueue ext-method shared constant from bridgeTypes + -32601 latch-off for unsupported clients
  • SSE: per-originator frame grouping with originatorClientId on envelope + never-throws publish with fallback logging
  • Browser: identity-removal consume (no blanket-clear) + per-turn AbortController aborted at settle + removeInjectedFromQueue with originator filtering + MAX_PENDING_BATCHES eviction cap
  • SDK: fetchWithTimeout integration + signal propagation + DaemonSessionClient forwarding

Each invariant has at least one test that fails when the relevant line is deleted.

Only known gap: App-level AbortController lifecycle has no RTL test (acknowledged as follow-up). Non-blocking — SDK signal contract is covered.

Ship it.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Read the full diff across all 29 files. The implementation is clean — no critical blockers, no security holes, no AGENTS.md violations.

The architecture follows the existing followupSidechannel pattern: the mid_turn_message_injected event flows through a dedicated side-channel (pub/sub + useSyncExternalStore) rather than polluting the transcript reducer, which is correct since it's a transient dedupe signal, not a rendered transcript item.

The concurrency handling is where this PR earns its complexity. Five distinct race conditions are each handled with a specific mechanism:

  • Accept-gate (busy-only) prevents idle messages from being stranded for the next turn's drain
  • Idle-clear at settle drops undrained messages so the browser's resend is the single delivery path
  • AbortController on turn-settle cancels in-flight pushes that could land in a subsequent turn
  • Per-originator SSE frames ensure peers don't dedupe each other's queues
  • Identity-based consume in the side-channel preserves late-arrived and cross-session batches

One observation: the resolveTrustedClientId authorization in enqueueMidTurnMessage mirrors the pattern used by /prompt and /btw — good consistency. The DoS bounds (20 messages per queue, 16KB per message) are reasonable.

Unit tests: All pass — bridge (282), bridgeClient (23), server (441), SDK (253), webui (84), sidechannel (new). Total: 1,083+ tests green across affected packages.

Real-Scenario Testing

Drove the PR's daemon with curl against the new POST /session/:id/mid-turn-message endpoint. Full mid-turn drain requires a live model + browser session (not reproducible headlessly), but the server-side HTTP contract was verified end-to-end.

runner@runnervm1li68:~/work/qwen-code/qwen-code/.qwen/worktrees/triage$ node scripts/dev.js serve --port 18768 --token test-token --workspace /tmp &>/tmp/daemon-bg.log &
[1] 5582
runner@runnervm1li68:~/work/qwen-code/qwen-code/.qwen/worktrees/triage$ echo '=== Test 1: Unknown session (expect 404) ===' && curl -s -w '\nHTTP_CODE:%{http_code}' -X POST http://127.0.0.1:18768/session/nonexistent/mid-turn-message -H 'Authorization: Bearer test-token' -H 'Content-Type: application/json' -d '{"message":"test"}' && echo '' && echo '=== Test 2: Missing auth (expect 401) ===' && curl -s -o /dev/null -w 'HTTP_CODE:%{http_code}' -X POST http://127.0.0.1:18768/session/test/mid-turn-message -H 'Content-Type: application/json' -d '{"message":"test"}' && echo '' && echo '=== Test 3: Empty message (expect 400) ===' && curl -s -w '\nHTTP_CODE:%{http_code}' -X POST http://127.0.0.1:18768/session/test/mid-turn-message -H 'Authorization: Bearer test-token' -H 'Content-Type: application/json' -d '{}' && echo '' && echo '=== Test 4: Whitespace-only (expect 400) ===' && curl -s -w '\nHTTP_CODE:%{http_code}' -X POST http://127.0.0.1:18768/session/test/mid-turn-message -H 'Authorization: Bearer test-token' -H 'Content-Type: application/json' -d '{"message":"   "}' && echo '' && echo '=== ALL TESTS DONE ===' && kill %1 2>/dev/null
=== Test 1: Unknown session (expect 404) ===
{"error":"No session with id \"nonexistent\"","sessionId":"nonexistent"}
HTTP_CODE:404
=== Test 2: Missing auth (expect 401) ===
HTTP_CODE:401
=== Test 3: Empty message (expect 400) ===
{"error":"`message` is required and must be a non-empty string"}
HTTP_CODE:400
=== Test 4: Whitespace-only (expect 400) ===
{"error":"`message` is required and must be a non-empty string"}
HTTP_CODE:400
=== ALL TESTS DONE ===

All four HTTP contract tests behave as expected: 404 for unknown sessions, 401 for missing auth, 400 for empty/whitespace-only messages. The daemon starts cleanly, the route is registered, and error responses match the sibling /btw and /prompt routes.

Note: the full mid-turn drain (message → bridge queue → ACP child drain → model injection → SSE echo → browser dedupe) requires a live model session and was validated by the PR author against DeepSeek/deepseek-v4-pro with evidence in the PR description. The server-side HTTP contract verified here is the externally-facing half of that chain.

中文说明

代码审查

完整阅读了 29 个文件的 diff。实现干净——无关键阻断问题、无安全漏洞、无 AGENTS.md 违规。

架构遵循了现有的 followupSidechannel 模式:mid_turn_message_injected 事件通过专用 side-channel(pub/sub + useSyncExternalStore)流转,不污染 transcript reducer——这是正确的,因为它是临时去重信号,不是渲染的 transcript 项。

并发处理是这个 PR 复杂度的价值所在。五种不同的竞态条件各有专门机制:

  • 接受门控(仅 busy 时)防止 idle 消息被滞留到下一轮的 drain
  • idle 时清空丢弃未 drain 的消息,使浏览器的重发成为唯一交付路径
  • turn-settle 时的 AbortController取消可能在后续 turn 中落地的 in-flight push
  • 按 originator 分组的 SSE 帧确保 peer 不会去重对方的队列
  • 基于 identity 的 consume保留迟到达和跨 session 的批次

一个观察:enqueueMidTurnMessage 中的 resolveTrustedClientId 授权与 /prompt/btw 使用相同模式——良好的一致性。DoS 限制(每队列 20 条消息,每条 16KB)合理。

单元测试: 全部通过——bridge (282)、bridgeClient (23)、server (441)、SDK (253)、webui (84)、sidechannel(新增)。受影响包共计 1,083+ 测试绿色。

真实场景测试

用 curl 对 PR 的 daemon 新端点 POST /session/:id/mid-turn-message 进行了验证。完整的 mid-turn drain 需要真实模型 + 浏览器会话(无法在无头环境复现),但服务端 HTTP 契约已端到端验证。

所有四个 HTTP 契约测试表现如预期:未知 session 返回 404、缺少 auth 返回 401、空消息/纯空白消息返回 400。daemon 正常启动、路由已注册、错误响应与 /btw/prompt 同级路由一致。

注意:完整的 mid-turn drain(消息 → bridge 队列 → ACP 子进程 drain → 模型注入 → SSE 回显 → 浏览器去重)需要真实模型会话,PR 作者已在 DeepSeek/deepseek-v4-pro 上验证并提供了 PR 描述中的证据。此处验证的服务端 HTTP 契约是该链路的外部接口部分。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Reflection

What this PR does well:

  • Concurrency correctness is genuinely hard here, and the solution is clean. Five race conditions, five targeted mechanisms — no over-engineering, no catch-all mutex. Each guard addresses a specific failure mode that was identified (accept-gate, idle-clear, AbortController, per-originator echo, identity-based consume). The test suite validates each one independently.
  • Architecture follows existing patterns. The side-channel mirrors followupSidechannel; the daemon endpoint mirrors /prompt and /btw; the SDK method mirrors sendFollowupMessage. This means a maintainer already familiar with those patterns can review this PR without learning new abstractions.
  • Test coverage is proportional to complexity. The bridge queue logic alone has 11 new tests covering multi-batch, cross-session, originator filtering, idle-clear, and queue-depth limits. The new midTurnDedup.ts and midTurnInjectedSidechannel.ts each have dedicated test files.

What could be improved (minor, non-blocking):

  • One unrelated formatting change in server.test.ts (whitespace-only diff line). Cosmetic, but makes git blame noisier for that file.
  • The bundle budget bump (118KB → 119KB) is a 1KB increase for 5 new exported symbols — reasonable, but worth a quick sanity check that tree-shaking isn't pulling in unused exports.

Risk assessment:

  • Rollback safety: The new daemon endpoint is additive. Rolling back the PR removes the endpoint and the browser-side drain logic without affecting existing flows. No database migrations, no breaking API changes.
  • Cross-package coupling: MID_TURN_QUEUE_DRAIN_METHOD is now a shared constant in bridgeTypes.ts imported by Session.ts — good. Previously it was a local string in Session.ts, which would have silently desynced. The refactor to a single source of truth is a minor improvement.
  • Browser-only feature: Desktop/CLI clients don't call enqueueMidTurnMessage, so the change is invisible to non-web-shell users. No behavioral change for existing clients.

Verdict

APPROVE

All three gate checks pass:

Check Result
Template & direction (Stage 1) ✅ Valid template, aligned direction
Code review (Stage 2) ✅ Clean, no blockers
Tests: 1,083+ unit tests ✅ All passing
Real-scenario: HTTP contract ✅ 4/4 endpoint tests correct
Typecheck: acp-bridge, sdk-typescript ✅ Clean

The implementation is well-designed for a genuinely complex concurrency problem. Test coverage is thorough and the architecture is consistent with the rest of the codebase. Approving.

中文说明

反思

PR 做得好的方面:

  • 并发正确性在这里确实很难,而解决方案很干净。 五种竞态条件,五种针对性机制——没有过度工程化,没有万能的 mutex。每个 guard 解决一个已被识别的特定失败模式(接受门控、idle 清空、AbortController、按 originator 回显、基于 identity 的 consume)。测试套件独立验证了每一个。
  • 架构遵循现有模式。 side-channel 镜像 followupSidechannel;daemon 端点镜像 /prompt/btw;SDK 方法镜像 sendFollowupMessage。这意味着已经熟悉这些模式的维护者可以在不学习新抽象的情况下审查此 PR。
  • 测试覆盖率与复杂度成正比。 仅 bridge 队列逻辑就有 11 个新测试,覆盖多批次、跨会话、originator 过滤、idle 清空和队列深度限制。新的 midTurnDedup.tsmidTurnInjectedSidechannel.ts 各有专门的测试文件。

可以改进的方面(次要,不阻断):

  • server.test.ts 中一处无关的格式变更。装饰性的,但会使该文件的 git blame 更嘈杂。
  • bundle 预算增加(118KB → 119KB)对应 5 个新导出符号——合理,但值得快速检查 tree-shaking 是否拉入了未使用的导出。

风险评估:

  • 回滚安全: 新 daemon 端点是增量添加。回滚 PR 移除端点和浏览器端 drain 逻辑,不影响现有流程。无数据库迁移、无破坏性 API 变更。
  • 跨包耦合: MID_TURN_QUEUE_DRAIN_METHOD 现在是 bridgeTypes.ts 中的共享常量,被 Session.ts 导入——很好。以前它是 Session.ts 中的本地字符串,会默默失同步。重构为单一事实来源是一个小改进。
  • 仅浏览器功能: Desktop/CLI 客户端不调用 enqueueMidTurnMessage,因此变更对非 web-shell 用户不可见。对现有客户端无行为变更。

结论

批准

所有三项检查均通过:模板与方向有效、代码审查干净无阻断、1,083+ 单元测试全部通过、HTTP 端点真实场景测试正确、类型检查干净。

该实现针对一个真正复杂的并发问题设计良好。测试覆盖充分,架构与代码库的其余部分一致。批准合并。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. Code review clean, 1083+ unit tests pass, HTTP contract verified via tmux real-scenario testing, typechecks clean. See Stage 1–3 comments for full analysis.

@wenshao
wenshao merged commit 24a1363 into QwenLM:main Jun 16, 2026
318 checks passed
Comment thread packages/acp-bridge/src/bridgeClient.ts
Comment thread packages/acp-bridge/src/bridgeClient.ts
wenshao added a commit that referenced this pull request Jun 18, 2026
…rains (#5266)

* fix(daemon): centralize mid-turn event constant + recover timed-out drains

Follow-up to #5175 addressing two post-merge /review suggestions.

Centralize the `mid_turn_message_injected` SSE event `type`: it was a bare
literal in the daemon publisher (acp-bridge), the SDK validator/reducer, and the
browser consumer (webui), so a rename in one could silently break browser-side
dedup. It now lives once in acp-bridge's dependency-free `daemonEventTypes`
module (lightweight like `mcpTimeouts`, so the SDK re-exports it via its
build-time devDep without dragging acp-bridge's type graph into the SDK bundle),
and bridgeClient / the SDK / webui all import the single binding.

Close the drain-timeout message-loss window: the daemon splices + SSE-publishes
(browser dedupes) before the ACP child's response lands, so if the child's 2s
drain timeout fires first, the late response was discarded — losing the messages
from both queues (silent, one-turn loss). The child now recovers that late
response and injects it on the next batch instead of dropping it.

Tests: drain-timeout recovery (Session), and a rename-safety assertion pinning
the shared event constant to the wire literal.

* fix(daemon): address review — log drain recovery + rename buildMidTurnParts

- Emit a `debugLogger.debug` line when a timed-out drain is recovered (session
  id + count), guarded on a non-empty payload, so the recovery path is
  correlatable in production logs.
- Rename `#formatMidTurnParts` → `#buildMidTurnParts`: the method records to the
  chat transcript, so a "format" verb understated its side effect.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants