mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-22 07:04:58 +00:00
* 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>
245 lines
7.7 KiB
TypeScript
245 lines
7.7 KiB
TypeScript
/**
|
||
* @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[];
|
||
midTurnState?: 'submitting' | 'queued';
|
||
midTurnMessageId?: string;
|
||
}
|
||
|
||
let nextId = 1;
|
||
const q = (text: string, images?: unknown[]): Item => {
|
||
const id = nextId++;
|
||
return {
|
||
id,
|
||
text,
|
||
midTurnState: 'queued',
|
||
midTurnMessageId: `mid-${id}`,
|
||
...(images ? { images } : {}),
|
||
};
|
||
};
|
||
const batch = (
|
||
sessionId: string,
|
||
...messages: string[]
|
||
): MidTurnInjectedBatch => ({
|
||
sessionId,
|
||
messages,
|
||
});
|
||
const batchFrom = (
|
||
sessionId: string,
|
||
originatorClientId: string,
|
||
...messages: string[]
|
||
): MidTurnInjectedBatch => ({ sessionId, originatorClientId, messages });
|
||
|
||
const batchWithIds = (
|
||
sessionId: string,
|
||
messages: string[],
|
||
messageIds: string[],
|
||
): MidTurnInjectedBatch => ({ sessionId, messages, messageIds });
|
||
|
||
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('does not remove an ordinary queued prompt with the same text', () => {
|
||
const ordinary = { ...q('same'), midTurnState: undefined };
|
||
const inserted = q('same');
|
||
const next = removeInjectedFromQueue(
|
||
[ordinary, inserted],
|
||
[batch('s', 'same')],
|
||
's',
|
||
);
|
||
expect(next).toEqual([ordinary]);
|
||
});
|
||
|
||
it('uses message ids instead of text when the daemon provides them', () => {
|
||
const first = q('same');
|
||
const second = q('same');
|
||
const next = removeInjectedFromQueue(
|
||
[first, second],
|
||
[batchWithIds('s', ['same'], [second.midTurnMessageId!])],
|
||
's',
|
||
);
|
||
expect(next).toEqual([first]);
|
||
});
|
||
|
||
it('matches a submitting prompt before its admission response provides the id', () => {
|
||
const submitting = {
|
||
...q('early injection'),
|
||
midTurnState: 'submitting' as const,
|
||
midTurnMessageId: undefined,
|
||
};
|
||
const next = removeInjectedFromQueue(
|
||
[submitting],
|
||
[batchWithIds('s', ['early injection'], ['mid-early'])],
|
||
's',
|
||
);
|
||
expect(next).toEqual([]);
|
||
});
|
||
|
||
it('removes the id-matched row, not an earlier same-text row still submitting', () => {
|
||
// Two same-text sends: the first is still awaiting its admission id, the
|
||
// second was admitted and queued with an id. The injection frame names the
|
||
// second's id, so it must be removed — an array-position text match on the
|
||
// earlier row would silently drop it and leave the queued one to be resent
|
||
// at idle (double delivery).
|
||
const submitting = {
|
||
...q('x'),
|
||
midTurnState: 'submitting' as const,
|
||
midTurnMessageId: undefined,
|
||
};
|
||
const queued = q('x');
|
||
const next = removeInjectedFromQueue(
|
||
[submitting, queued],
|
||
[batchWithIds('s', ['x'], [queued.midTurnMessageId!])],
|
||
's',
|
||
);
|
||
expect(next).toEqual([submitting]);
|
||
});
|
||
|
||
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 peer’s 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();
|
||
});
|
||
});
|
||
});
|