qwen-code/packages/web-shell/client/midTurnDedup.ts
ytahdn 554c5e44ba
feat(web-shell): support mutable default mid-turn messages (#8229)
* feat(web-shell): support mutable default mid-turn messages

* fix(serve): register mid-turn removal telemetry route

* test(serve): update telemetry route totals

* fix(test): add session_mid_turn_message_mutation to expected features list

* fix(webui): forward clientId on cross-session mid-turn removal (#8229)

- Forward the session clientId in the cross-session removeMidTurnMessage
  branch so the bridge's exact-originator match can succeed; without it the
  removal resolved to an undefined originator and could never remove the
  message stamped at enqueue.
- Strip a misaligned/malformed messageIds from mid_turn_message_injected in
  asKnownDaemonEvent instead of rejecting the whole event, mirroring the
  sidechannel parser so a buggy daemon can't silently lose the injection
  signal.
- Log a mid-turn removal miss in the bridge like the enqueue/pending-removal
  siblings, to make removal races diagnosable from daemon logs.

* fix(web-shell): exclude annotations from mid-turn path and harden idle cleanup (#8229)

* fix(web-shell): add container-type to .queuedPrompts so @container query applies (#8229)

* fix(web-shell): harden mid-turn dedupe and capability gate per review (#8229)

- removeInjectedFromQueue now matches by id first (position-independent)
  and falls back to text only when no id match exists, so two same-text
  sends can't remove the wrong row and double-deliver.
- Thread canMutateMidTurn into useQueuedPrompts and gate the mid-turn
  delete/edit mutation on it, so the keyboard path can't hit a DELETE
  route the daemon doesn't advertise.
- asMidTurnMessageInjectedData omits a malformed messageIds key instead
  of leaving a present undefined, matching the sidechannel parser.
- Narrow MidTurnQueueItem.midTurnState, document the load-bearing effect
  order, and make clearQueuedPrompts return false on a no-op clear.

* fix: harden mid-turn removal per review (log escape, cross-session client id) (#8229)

- Escape the caller-controlled messageId (and sessionId) in the mid-turn
  removal-miss stderr line to prevent log injection (CWE-117).
- Forward the target session's persisted client id on cross-session mid-turn
  removal so the bridge's exact-originator match no longer rejects valid
  removals after a session switch with per-session client ids.
- Strengthen tests: distinct-id independence for two queued messages, deferred
  removal proving the composer waits for daemon removal, and the active-turn
  delete failed-action flag.

---------

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
2026-08-01 10:41:29 +00:00

101 lines
3.9 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export interface MidTurnQueueItem {
text: string;
images?: unknown[];
midTurnState?: 'submitting' | 'queued';
midTurnMessageId?: string;
}
export interface MidTurnInjectedBatch {
sessionId: string;
messages: readonly string[];
messageIds?: readonly string[];
/** Trusted client id that queued the messages (from the SSE envelope). */
originatorClientId?: string;
}
/**
* Reconcile injected mid-turn messages against the local pending queue: remove
* the entry matching each injected message for `sessionId`, across ALL `batches`
* (a multi-batch turn drains once per tool batch, so the consumer must process
* every accumulated batch, not just the latest).
*
* Each injected message is matched in two passes. The first pass is a strict
* `midTurnMessageId` match (the daemon mints an id at admission and echoes it on
* injection); it wins regardless of array position, so two same-text sends can't
* steal each other's removal when their admission responses arrive out of order.
* Only when no id match exists does the second pass fall back to the first
* text-only entry with matching text — any mid-turn row when the batch carries
* no ids (older daemon), or a still-`submitting` row that hasn't received its id
* yet. Matching stays count-based — one removal per injected message — so a
* queue that holds the same text twice loses one entry per matching injection.
* Entries carrying images are never matched: image messages aren't pushed
* mid-turn (the drain channel carries plain strings), so they stay queued for
* the next turn. An entry that already fell back to the ordinary path
* (`midTurnState === undefined`) is never matched.
*
* Skips a batch whose `originatorClientId` is some OTHER client: the daemon
* broadcasts the injection frame to every client on the session, but only the
* client that queued the message should drop it — a peer with a coincidentally
* equal text must keep its own entry. Batches with no originator (anonymous
* push) are reconciled regardless.
*
* Returns a NEW array when something was removed, or `null` when nothing matched
* (so the caller can skip a redundant state update).
*/
export function removeInjectedFromQueue<T extends MidTurnQueueItem>(
prompts: readonly T[],
batches: readonly MidTurnInjectedBatch[],
sessionId: string,
clientId?: string,
): T[] | null {
const remaining = [...prompts];
const isTextOnly = (prompt: T) =>
!prompt.images || prompt.images.length === 0;
let changed = false;
for (const batch of batches) {
if (batch.sessionId !== sessionId) continue;
if (
batch.originatorClientId !== undefined &&
batch.originatorClientId !== clientId
) {
continue;
}
for (const [messageIndex, message] of batch.messages.entries()) {
const messageId = batch.messageIds?.[messageIndex];
// A strict id match wins regardless of position; the text fallback below
// only runs for rows the id can't reach (no ids in the batch, or a row
// still awaiting its admission id).
let index =
messageId !== undefined
? remaining.findIndex(
(prompt) =>
prompt.midTurnState !== undefined &&
prompt.midTurnMessageId === messageId &&
isTextOnly(prompt),
)
: -1;
if (index < 0) {
index = remaining.findIndex(
(prompt) =>
prompt.midTurnState !== undefined &&
(messageId === undefined ||
(prompt.midTurnState === 'submitting' &&
prompt.midTurnMessageId === undefined)) &&
prompt.text === message &&
isTextOnly(prompt),
);
}
if (index >= 0) {
remaining.splice(index, 1);
changed = true;
}
}
}
return changed ? remaining : null;
}