qwen-code/packages/web-shell/client/midTurnDedup.test.ts
Shaojin Wen 24a13632c7
feat(daemon): deliver web-shell mid-turn messages into the running turn (#5175)
* feat(daemon): deliver web-shell mid-turn messages into the running turn

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.

* fix(daemon): address mid-turn drain review — exactly-once + hardening

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.

* fix(daemon): address Copilot review — trim consistency + doc accuracy

- 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.

* fix(daemon): address review — client-ownership gate + per-originator 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).

* fix(daemon): address review round 2 — mid-turn races, route tests, observability

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).

* fix(daemon): scope mid-turn consume to reconciled batches; correct originator 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.

* fix(daemon): mid-turn robustness — fetch timeout, observability, docs, 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.
2026-06-16 16:16:47 +08:00

176 lines
5.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import {
removeInjectedFromQueue,
type MidTurnInjectedBatch,
} from './midTurnDedup';
interface Item {
id: number;
text: string;
images?: unknown[];
}
let nextId = 1;
const q = (text: string, images?: unknown[]): Item => ({
id: nextId++,
text,
...(images ? { images } : {}),
});
const batch = (
sessionId: string,
...messages: string[]
): MidTurnInjectedBatch => ({
sessionId,
messages,
});
const batchFrom = (
sessionId: string,
originatorClientId: string,
...messages: string[]
): MidTurnInjectedBatch => ({ sessionId, originatorClientId, messages });
describe('removeInjectedFromQueue', () => {
it('removes the matching text-only entry for a single batch', () => {
const prompts = [q('keep'), q('also check tests'), q('keep2')];
const next = removeInjectedFromQueue(
prompts,
[batch('s', 'also check tests')],
's',
);
expect(next?.map((p) => p.text)).toEqual(['keep', 'keep2']);
});
it('reconciles ACROSS multiple accumulated batches (the #439 regression)', () => {
// A multi-batch turn publishes one frame per batch; both must be removed.
const prompts = [q('first'), q('second'), q('stay')];
const next = removeInjectedFromQueue(
prompts,
[batch('s', 'first'), batch('s', 'second')],
's',
);
expect(next?.map((p) => p.text)).toEqual(['stay']);
});
it('is count-based: removes one queued entry per injected occurrence', () => {
const prompts = [q('dup'), q('dup'), q('other')];
// one injection -> one removal
expect(
removeInjectedFromQueue(prompts, [batch('s', 'dup')], 's')?.map(
(p) => p.text,
),
).toEqual(['dup', 'other']);
// two injections (across batches) -> both removed
expect(
removeInjectedFromQueue(
prompts,
[batch('s', 'dup'), batch('s', 'dup')],
's',
)?.map((p) => p.text),
).toEqual(['other']);
});
it('never matches an image-bearing entry (images are not pushed mid-turn)', () => {
const prompts = [q('with image', [{ data: 'x' }]), q('with image')];
const next = removeInjectedFromQueue(
prompts,
[batch('s', 'with image')],
's',
);
// The text-only one is removed; the image-bearing one stays.
expect(next).not.toBeNull();
expect(next).toHaveLength(1);
expect(next?.[0].images).toEqual([{ data: 'x' }]);
});
it('skips batches for a different session', () => {
const prompts = [q('x')];
expect(
removeInjectedFromQueue(prompts, [batch('other', 'x')], 's'),
).toBeNull();
});
it('returns null (no new array) when nothing matched', () => {
const prompts = [q('a'), q('b')];
expect(
removeInjectedFromQueue(prompts, [batch('s', 'missing')], 's'),
).toBeNull();
expect(removeInjectedFromQueue(prompts, [], 's')).toBeNull();
});
it('returns a new array, leaving the input untouched, when changed', () => {
const prompts = [q('drop'), q('keep')];
const next = removeInjectedFromQueue(prompts, [batch('s', 'drop')], 's');
expect(next).not.toBe(prompts);
expect(prompts).toHaveLength(2); // input not mutated
expect(next).toHaveLength(1);
});
// The daemon stamps each drained frame with the originator's client id and
// broadcasts it to every client on the session. Only the originator should
// dedupe its own queue; a peer with a coincidentally-equal entry must keep it.
describe('originator (clientId) filtering', () => {
it('dedupes a batch whose originator matches our client id', () => {
const prompts = [q('mine'), q('keep')];
const next = removeInjectedFromQueue(
prompts,
[batchFrom('s', 'me', 'mine')],
's',
'me',
);
expect(next?.map((p) => p.text)).toEqual(['keep']);
});
it('skips a batch originated by a DIFFERENT client (no spurious dedupe)', () => {
// A peer pushed 'shared'; our identical queue entry was never injected on
// our side, so it must survive to be sent as our own next turn.
const prompts = [q('shared')];
expect(
removeInjectedFromQueue(
prompts,
[batchFrom('s', 'peer', 'shared')],
's',
'me',
),
).toBeNull();
});
it('dedupes an anonymous batch (no originator) regardless of our client id', () => {
const prompts = [q('anon'), q('keep')];
const next = removeInjectedFromQueue(
prompts,
[batch('s', 'anon')],
's',
'me',
);
expect(next?.map((p) => p.text)).toEqual(['keep']);
});
it('routes a mixed-originator set: ours dedupes, the peers is skipped', () => {
const prompts = [q('mine'), q('theirs'), q('keep')];
const next = removeInjectedFromQueue(
prompts,
[batchFrom('s', 'me', 'mine'), batchFrom('s', 'peer', 'theirs')],
's',
'me',
);
expect(next?.map((p) => p.text)).toEqual(['theirs', 'keep']);
});
it('skips our OWN-tagged batch when no client id is supplied (regression guard)', () => {
// If the caller forgets to pass its client id, an originator-tagged batch
// must NOT be force-deduped — but it also won't be reconciled, surfacing
// the wiring gap rather than silently double-delivering. (The web-shell
// always passes connection.clientId; this pins the helper's contract.)
const prompts = [q('mine')];
expect(
removeInjectedFromQueue(prompts, [batchFrom('s', 'me', 'mine')], 's'),
).toBeNull();
});
});
});