From e2b688b1b84623b60336d2173f3581d466009581 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 15 Aug 2026 05:20:52 +0000 Subject: [PATCH 01/12] fix(sdk): route unrecognized diagnostics onto a bounded transcript sidechannel Normalizer-classified unrecognized_event / unrecognized_session_update debug events no longer enter transcript blocks[]: they are mirrored onto a capped unrecognizedDiagnostics sidechannel instead. This stops them from finalizing a streaming assistant/thought block (which dropped a following assistant.usage frame) and from consuming the maxBlocks budget (which let repeated noise evict real conversation content). malformed_payload diagnostics and client-dispatched debug events keep their existing block semantics. --- packages/sdk-typescript/scripts/build.js | 4 +- .../sdk-typescript/src/daemon/ui/index.ts | 4 + .../src/daemon/ui/transcript.ts | 77 +++++++++ .../sdk-typescript/src/daemon/ui/types.ts | 39 +++++ .../test/daemon-ui-transcript.test.ts | 152 ++++++++++++++++++ .../sdk-typescript/test/unit/daemonUi.test.ts | 14 +- 6 files changed, 283 insertions(+), 7 deletions(-) diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index c75a4e3a651..01ab03f5f2a 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -85,7 +85,9 @@ const rootDir = join(__dirname, '..'); // (`uploadWorkspaceFile` + XHR progress) on both daemon client classes. // Bumped from 188KB to 189KB for the session reasoning-effort config option // APIs merged in from main. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 189 * 1024; +// Bumped from 189KB to 190KB for the unrecognized-diagnostic sidechannel +// (`unrecognizedDiagnostics` routing + selector, #8823). +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 190 * 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 diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index 7e5d8ab7b45..248a3c75c34 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -25,6 +25,8 @@ export { selectToolProgress, selectTranscriptBlocks, selectTranscriptBlocksOrderedByEventId, + selectUnrecognizedDiagnostics, + UNRECOGNIZED_DIAGNOSTICS_LIMIT, } from './transcript.js'; export { createDaemonTranscriptStore } from './store.js'; export { DAEMON_GOAL_STATUS_SENTINEL_PREFIX } from './sentinels.js'; @@ -81,6 +83,8 @@ export type { DaemonTranscriptSidechannelState, DaemonTranscriptState, DaemonTranscriptStore, + DaemonUnrecognizedDiagnostic, + DaemonUnrecognizedDiagnosticReason, // Chat-stream events DaemonUiAssistantDoneEvent, DaemonUiDebugReason, diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 6a615916d49..a78b18cbf06 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -15,7 +15,10 @@ import type { DaemonTranscriptReducerOptions, DaemonTranscriptState, DaemonUiEvent, + DaemonUiStatusEvent, DaemonUiTextEvent, + DaemonUnrecognizedDiagnostic, + DaemonUnrecognizedDiagnosticReason, DaemonUserShellTranscriptBlock, } from './types.js'; import { DAEMON_PLAN_TOOL_CALL_ID } from './types.js'; @@ -23,6 +26,11 @@ import { createDaemonToolPreview } from './toolPreview.js'; import { isRecord } from './utils.js'; const DEFAULT_MAX_BLOCKS = 1_000; +/** + * Cap for the `unrecognizedDiagnostics` sidechannel. Forward-compat noise + * must stay inspectable without growing unboundedly in long sessions. + */ +export const UNRECOGNIZED_DIAGNOSTICS_LIMIT = 50; const TRIMMED_TOOL_BLOCK_ID = '__trimmed_tool_block__'; const TRIMMED_PERMISSION_BLOCK_ID = '__trimmed_permission_block__'; const MAX_TEXT_BLOCK_LENGTH = 100_000; @@ -53,6 +61,7 @@ export function createDaemonTranscriptState( activeThoughtBlockByParent: createIndex(), // PR-E sidechannel: track current tool / approval mode / progress toolProgress: createIndex(), + unrecognizedDiagnostics: [], awaitingResync: false, resyncRequiredCount: 0, nextOrdinal: 1, @@ -341,6 +350,10 @@ function applyDaemonTranscriptEvent( break; case 'status': case 'debug': + if (isUnrecognizedDiagnostic(event)) { + appendUnrecognizedDiagnostic(next, event); + break; + } appendStatusBlock(next, event.type, event.text, event, { clearActiveText: event.clearActiveText, }); @@ -1211,6 +1224,54 @@ function resolvePermissionBlock( clearActiveText(state); } +type UnrecognizedDiagnosticEvent = DaemonUiStatusEvent & { + type: 'debug'; + debugReason: DaemonUnrecognizedDiagnosticReason; +}; + +function isUnrecognizedDiagnostic( + event: DaemonUiStatusEvent, +): event is UnrecognizedDiagnosticEvent { + return ( + event.type === 'debug' && + (event.debugReason === 'unrecognized_event' || + event.debugReason === 'unrecognized_session_update') + ); +} + +/** + * Route forward-compatibility noise to the bounded `unrecognizedDiagnostics` + * sidechannel instead of `blocks[]`. Appending it as a status block would + * run the default `clearActiveText`, finalizing the streaming assistant/ + * thought block so a following `assistant.usage` frame is dropped, and each + * block would consume the `maxBlocks` budget — repeated noise then evicts + * real conversation content in `trimTranscriptState`. Renderer-side + * filtering runs strictly after these mutations, so hiding the block later + * cannot prevent either symptom. `malformed_payload` diagnostics and + * client-dispatched debug events keep their block semantics. + */ +function appendUnrecognizedDiagnostic( + state: DaemonTranscriptState, + event: UnrecognizedDiagnosticEvent, +): void { + const diagnostic: DaemonUnrecognizedDiagnostic = { + debugReason: event.debugReason, + text: event.text, + receivedAt: state.now, + ...(event.source !== undefined ? { source: event.source } : {}), + ...(event.data !== undefined ? { data: event.data } : {}), + ...(event.eventId !== undefined ? { eventId: event.eventId } : {}), + ...(event.serverTimestamp !== undefined + ? { serverTimestamp: event.serverTimestamp } + : {}), + }; + const diagnostics = [...state.unrecognizedDiagnostics, diagnostic]; + state.unrecognizedDiagnostics = + diagnostics.length > UNRECOGNIZED_DIAGNOSTICS_LIMIT + ? diagnostics.slice(-UNRECOGNIZED_DIAGNOSTICS_LIMIT) + : diagnostics; +} + function appendStatusBlock( state: DaemonTranscriptState, kind: 'status' | 'error' | 'debug', @@ -1366,6 +1427,9 @@ function cloneTranscriptState( // (e.g. `useDaemonFollowupSuggestion`) skip re-renders for events // that don't touch the suggestion. lastFollowupSuggestion: state.lastFollowupSuggestion, + // Same reference-stability contract: the reducer replaces the whole + // array when appending, never mutates it in-place. + unrecognizedDiagnostics: state.unrecognizedDiagnostics, }; const onTruncation = opts.onTruncation ?? truncationCallbacks.get(state); if (onTruncation) truncationCallbacks.set(next, onTruncation); @@ -1823,6 +1887,19 @@ export function selectLastFollowupSuggestion( return state.lastFollowupSuggestion; } +/** + * Forward-compatibility diagnostics mirrored from normalizer-classified + * `unrecognized_event` / `unrecognized_session_update` debug events. These + * live outside `blocks[]` (see `appendUnrecognizedDiagnostic`), so developer + * tooling can still inspect them after renderers hide them. Bounded by + * `UNRECOGNIZED_DIAGNOSTICS_LIMIT`, newest last. + */ +export function selectUnrecognizedDiagnostics( + state: DaemonTranscriptState, +): readonly DaemonUnrecognizedDiagnostic[] { + return state.unrecognizedDiagnostics; +} + /** * Per-tool progress query. Returns `undefined` if no progress has been * recorded for the given toolCallId. The shape `{ ratio?, step? }` matches diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 5472701372a..9c87959561e 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -294,6 +294,35 @@ export const DAEMON_UI_DEBUG_REASONS = [ export type DaemonUiDebugReason = (typeof DAEMON_UI_DEBUG_REASONS)[number]; +/** + * Debug reasons that classify forward-compatibility noise — frames this + * normalizer has no case for. These diagnostics are routed to the bounded + * `unrecognizedDiagnostics` sidechannel instead of `blocks[]`; `malformed_*` + * diagnostics stay in the transcript because they signal an actual defect. + */ +export type DaemonUnrecognizedDiagnosticReason = Extract< + DaemonUiDebugReason, + 'unrecognized_event' | 'unrecognized_session_update' +>; + +/** + * One forward-compatibility diagnostic mirrored onto the transcript + * sidechannel. Carries the normalizer classification plus the original + * event envelope fields a developer console needs, without ever entering + * `blocks[]` (so it cannot finalize a streaming assistant/thought block or + * consume the `maxBlocks` budget). + */ +export interface DaemonUnrecognizedDiagnostic { + debugReason: DaemonUnrecognizedDiagnosticReason; + text: string; + source?: string; + data?: unknown; + eventId?: number; + serverTimestamp?: number; + /** Reducer receive time (`state.now` at dispatch). */ + receivedAt: number; +} + export interface DaemonUiStatusEvent extends DaemonUiEventBase { type: 'status' | 'debug'; text: string; @@ -1006,6 +1035,16 @@ export interface DaemonTranscriptSidechannelState { suggestion: string; promptId: string; }; + /** + * Bounded sidechannel for forward-compatibility diagnostics + * (`unrecognized_event` / `unrecognized_session_update`). These never + * enter `blocks[]`, so they cannot finalize a streaming assistant/thought + * block (orphaning a subsequent `assistant.usage` frame) and cannot + * consume the `maxBlocks` budget that real conversation content relies + * on. Newest entries are kept; the array is capped at + * `UNRECOGNIZED_DIAGNOSTICS_LIMIT`. + */ + unrecognizedDiagnostics: readonly DaemonUnrecognizedDiagnostic[]; pendingUserShellCommand?: { command: string; cwd?: string; diff --git a/packages/sdk-typescript/test/daemon-ui-transcript.test.ts b/packages/sdk-typescript/test/daemon-ui-transcript.test.ts index 839cf5057e6..d0b984fda0a 100644 --- a/packages/sdk-typescript/test/daemon-ui-transcript.test.ts +++ b/packages/sdk-typescript/test/daemon-ui-transcript.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { createDaemonTranscriptState, reduceDaemonTranscriptEvents, + UNRECOGNIZED_DIAGNOSTICS_LIMIT, } from '../src/daemon/ui/transcript.js'; import type { DaemonUiEvent } from '../src/daemon/ui/types.js'; @@ -150,3 +151,154 @@ describe('status event while a thought block is streaming', () => { expect(thought.text).toBe('thinking more'); }); }); + +describe('unrecognized diagnostics stay out of the chat transcript', () => { + it('preserves the active assistant block and usage across an unrecognized diagnostic', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { type: 'user.text.delta', text: 'question' }, + { type: 'assistant.text.delta', text: 'hello' }, + { + type: 'debug', + text: 'language_changed (unrecognized daemon event): {"language":"en"}', + debugReason: 'unrecognized_event', + }, + { + type: 'assistant.usage', + usage: { inputTokens: 10, outputTokens: 5 }, + }, + { type: 'assistant.done' }, + ], + { now: 1 }, + ); + + expect(state.blocks.map((block) => block.kind)).toEqual([ + 'user', + 'assistant', + ]); + const assistant = state.blocks[1]; + if (assistant.kind !== 'assistant') throw new Error('expected assistant'); + expect(assistant.text).toBe('hello'); + expect(assistant.usage).toEqual({ + inputTokens: 10, + outputTokens: 5, + cachedTokens: 0, + }); + + expect(state.unrecognizedDiagnostics).toHaveLength(1); + expect(state.unrecognizedDiagnostics[0]?.debugReason).toBe( + 'unrecognized_event', + ); + expect(state.unrecognizedDiagnostics[0]?.text).toBe( + 'language_changed (unrecognized daemon event): {"language":"en"}', + ); + }); + + it('does not let hidden diagnostics evict conversation blocks through maxBlocks', () => { + let state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { type: 'user.text.delta', text: 'question' }, + { type: 'assistant.text.delta', text: 'answer' }, + { type: 'assistant.done' }, + ], + { now: 1, maxBlocks: 2 }, + ); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'debug', + text: 'some_future_event (unrecognized daemon event): {"a":1}', + debugReason: 'unrecognized_event', + }, + { + type: 'debug', + text: 'some_future_update (unrecognized session update): {"b":2}', + debugReason: 'unrecognized_session_update', + }, + ], + { now: 1, maxBlocks: 2 }, + ); + + expect(state.blocks.map((block) => block.kind)).toEqual([ + 'user', + 'assistant', + ]); + expect( + state.blocks.map((block) => ('text' in block ? block.text : '')), + ).toEqual(['question', 'answer']); + expect( + state.unrecognizedDiagnostics.map((entry) => entry.debugReason), + ).toEqual(['unrecognized_event', 'unrecognized_session_update']); + }); + + it('keeps malformed-payload diagnostics in the transcript', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { type: 'assistant.text.delta', text: 'answering' }, + { + type: 'debug', + text: 'session_rewound (malformed payload): {"oops":true}', + debugReason: 'malformed_payload', + }, + ], + { now: 1 }, + ); + + expect(state.blocks.map((block) => block.kind)).toEqual([ + 'assistant', + 'debug', + ]); + expect(state.unrecognizedDiagnostics).toHaveLength(0); + expect(state.activeAssistantBlockId).toBeUndefined(); + }); + + it('keeps client-dispatched debug events in the transcript', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { type: 'assistant.text.delta', text: 'answering' }, + { type: 'debug', text: 'Model switched: qwen3-max' }, + ], + { now: 1 }, + ); + + expect(state.blocks.map((block) => block.kind)).toEqual([ + 'assistant', + 'debug', + ]); + expect(state.unrecognizedDiagnostics).toHaveLength(0); + expect(state.activeAssistantBlockId).toBeUndefined(); + }); + + it('bounds the unrecognized diagnostics sidechannel', () => { + const events: DaemonUiEvent[] = []; + for (let index = 0; index < UNRECOGNIZED_DIAGNOSTICS_LIMIT + 5; index++) { + events.push({ + type: 'debug', + text: `event_${index} (unrecognized daemon event): {}`, + debugReason: 'unrecognized_event', + }); + } + + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + events, + { now: 1 }, + ); + + expect(state.unrecognizedDiagnostics).toHaveLength( + UNRECOGNIZED_DIAGNOSTICS_LIMIT, + ); + expect(state.unrecognizedDiagnostics.at(-1)?.text).toBe( + `event_${UNRECOGNIZED_DIAGNOSTICS_LIMIT + 4} (unrecognized daemon event): {}`, + ); + expect(state.unrecognizedDiagnostics[0]?.text).toBe( + 'event_5 (unrecognized daemon event): {}', + ); + }); +}); diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index ffa42b564fb..f6fca864f2c 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -2633,12 +2633,14 @@ describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => { ]); }); - it('carries debugReason through the reducer onto the transcript block', () => { + it('routes unrecognized diagnostics to the sidechannel with classification intact', () => { // The normalizer tests above inspect events directly and the Web Shell // adapter tests construct blocks by hand, so neither would notice if the - // reducer dropped the field on the way across. Production blocks would - // then lose their classification and Web Shell would render raw JSON - // again with both suites still green. + // reducer dropped the field on the way across. Unrecognized diagnostics + // are routed to `unrecognizedDiagnostics` instead of `blocks[]` (they + // must not finalize a streaming assistant block or consume the + // `maxBlocks` budget), so the classification must survive onto the + // sidechannel entry. const state = reduceDaemonTranscriptEvents( createDaemonTranscriptState({ now: 1 }), normalizeDaemonEvent( @@ -2646,9 +2648,9 @@ describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => { ), ); - expect(state.blocks).toEqual([ + expect(state.blocks).toEqual([]); + expect(state.unrecognizedDiagnostics).toEqual([ expect.objectContaining({ - kind: 'debug', debugReason: 'unrecognized_event', }), ]); From 767e1c8878370ffc14c905e345c8197b3805e12a Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 15 Aug 2026 22:03:53 +0800 Subject: [PATCH 02/12] fix(sdk): align browser bundle budget --- packages/sdk-typescript/scripts/build.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index a6b5502c6a3..2178a7cac6a 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -85,10 +85,10 @@ const rootDir = join(__dirname, '..'); // (`uploadWorkspaceFile` + XHR progress) on both daemon client classes. // Bumped from 188KB to 189KB for the session reasoning-effort config option // APIs merged in from main. -// Bumped from 189KB to 190KB for historical branch sessions plus the +// Bumped from 189KB to 191KB for historical branch sessions plus the // unrecognized-diagnostic sidechannel (`unrecognizedDiagnostics` routing + // selector, #8823). -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 190 * 1024; +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 191 * 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 From b2a83e0bb17d0b9e648c8c63725ed9400c02ce05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Sat, 15 Aug 2026 17:14:03 +0000 Subject: [PATCH 03/12] fix(sdk): close the sidechannel review round (#8823) - export the sidechannel API through the daemon barrel (selectUnrecognizedDiagnostics, UNRECOGNIZED_DIAGNOSTICS_LIMIT, DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS + types) and pin the reachability in daemon-public-surface.test.ts - restore the MAX_TEXT_BLOCK_LENGTH cap on sidechannel text, mirroring truncateText exactly (suffix fits within the cap) - ship the unrecognized reason subset as a runtime const array and route by membership, so a new reason cannot fall through to appendStatusBlock - copy the correlation fields createBase stamps (promptId, sourceRecordIds, branchRecordId, originatorClientId) onto sidechannel entries; drop the dead source/data switches - un-fuse the budget-history comment chain in scripts/build.js - update docs/developers/daemon-ui for the split routing - tests: full entry shape, text cap, block-path debugReason counterpart, and a webui malformed_payload interleave sibling so the #7012 flush-before-guard keeps a discriminating stimulus --- docs/developers/daemon-ui/README.md | 20 +++- packages/sdk-typescript/scripts/build.js | 6 +- packages/sdk-typescript/src/daemon/index.ts | 5 + .../sdk-typescript/src/daemon/ui/index.ts | 6 +- .../src/daemon/ui/transcript.ts | 49 ++++++++-- .../sdk-typescript/src/daemon/ui/types.ts | 28 ++++-- .../test/unit/daemon-public-surface.test.ts | 38 +++++++- .../sdk-typescript/test/unit/daemonUi.test.ts | 92 +++++++++++++++++++ .../session/DaemonSessionProvider.test.tsx | 74 +++++++++++++++ 9 files changed, 291 insertions(+), 27 deletions(-) mode change 100755 => 100644 packages/sdk-typescript/scripts/build.js diff --git a/docs/developers/daemon-ui/README.md b/docs/developers/daemon-ui/README.md index 564dce222b8..ddd77797e21 100644 --- a/docs/developers/daemon-ui/README.md +++ b/docs/developers/daemon-ui/README.md @@ -370,8 +370,7 @@ when daemon doesn't explicitly stamp provenance, MCP tools are detectable. ## Debug reason categorization `DaemonUiStatusEvent.debugReason` is a closed-enum the normalizer stamps -when it projects a `debug` block instead of a typed event (mirrored onto -`DaemonStatusTranscriptBlock` for transcript consumers): +when it projects a `debug` event instead of a typed event: ```ts import type { DaemonUiDebugReason } from '@qwen-code/sdk/daemon'; @@ -385,8 +384,18 @@ diagnostics rather than conversation content. `malformed_*` means a frame the SDK _does_ know arrived with an unusable payload — a real defect signal. -Renderers should branch on `debugReason`, not the debug text — the text -prefix is diagnostic wording and changes without notice: +**Routing differs by category.** `unrecognized_*` diagnostics are routed +to the bounded `unrecognizedDiagnostics` sidechannel and never enter +`blocks[]` (so they cannot finalize a streaming assistant/thought block or +consume the `maxBlocks` budget). Read them with +`selectUnrecognizedDiagnostics`; the cap is `UNRECOGNIZED_DIAGNOSTICS_LIMIT` +and the routed subset is `DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS`. +`malformed_*` diagnostics — and legacy blocks persisted before this split — +stay in the transcript as `DaemonStatusTranscriptBlock`s, so block-level +`debugReason` handling now applies to those only. + +Renderers filtering blocks should branch on `debugReason`, not the debug +text — the text prefix is diagnostic wording and changes without notice: ```ts function hideDebugBlock(reason?: DaemonUiDebugReason): boolean { @@ -407,7 +416,8 @@ Every layer in the daemon UI SDK follows the **forward-compat principle**: unknown values do NOT throw; they degrade gracefully. - Unknown daemon event types → `debug` event with the raw type name, - stamped with an `unrecognized_*` `debugReason` (see above) + stamped with an `unrecognized_*` `debugReason` and routed to the bounded + `unrecognizedDiagnostics` sidechannel (see above) - Unknown tool status → `currentToolCallId` left untouched (no clear) - Unknown error kind → `errorKind` undefined (renderer falls back to text) - Missing serverTimestamp → falls back to `clientReceivedAt` diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js old mode 100755 new mode 100644 index 2178a7cac6a..ac7c22e2013 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -85,9 +85,9 @@ const rootDir = join(__dirname, '..'); // (`uploadWorkspaceFile` + XHR progress) on both daemon client classes. // Bumped from 188KB to 189KB for the session reasoning-effort config option // APIs merged in from main. -// Bumped from 189KB to 191KB for historical branch sessions plus the -// unrecognized-diagnostic sidechannel (`unrecognizedDiagnostics` routing + -// selector, #8823). +// Bumped from 189KB to 190KB for historical branch sessions and transcript branch-point projection merged with the upload and reasoning APIs. +// Bumped from 190KB to 191KB for the unrecognized-diagnostic sidechannel +// (`unrecognizedDiagnostics` routing + selector, #8823). const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 191 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index b6775df914a..4cbb0b2fc72 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -94,6 +94,7 @@ export { DAEMON_GOAL_STATUS_SENTINEL_PREFIX, DAEMON_PLAN_TOOL_CALL_ID, DAEMON_UI_DEBUG_REASONS, + DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS, daemonBlockToHtml, daemonBlockToMarkdown, daemonBlockToPlainText, @@ -120,9 +121,11 @@ export { selectToolProgress, selectTranscriptBlocks, selectTranscriptBlocksOrderedByEventId, + selectUnrecognizedDiagnostics, stringifyJson as stringifyDaemonUiJson, stripOscSequences as stripDaemonOscSequences, transcriptBlockToTerminalText, + UNRECOGNIZED_DIAGNOSTICS_LIMIT, DAEMON_UI_CONFORMANCE_FIXTURES, } from './ui/index.js'; export type { @@ -194,6 +197,8 @@ export type { DaemonUiWorkspaceInitializedEvent, DaemonUiWorkspaceMemoryChangedEvent, DaemonUiWorkspaceToolToggledEvent, + DaemonUnrecognizedDiagnostic, + DaemonUnrecognizedDiagnosticReason, NormalizeDaemonEventOptions, } from './ui/index.js'; export { diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index 248a3c75c34..6b093bbec65 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -61,7 +61,11 @@ export { stringifyJson, stripOscSequences, } from './utils.js'; -export { DAEMON_PLAN_TOOL_CALL_ID, DAEMON_UI_DEBUG_REASONS } from './types.js'; +export { + DAEMON_PLAN_TOOL_CALL_ID, + DAEMON_UI_DEBUG_REASONS, + DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS, +} from './types.js'; export type { DaemonUiContentPart } from './utils.js'; export type { DaemonShellTranscriptBlock, diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 031233818d8..cc9a71978db 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -21,7 +21,10 @@ import type { DaemonUnrecognizedDiagnosticReason, DaemonUserShellTranscriptBlock, } from './types.js'; -import { DAEMON_PLAN_TOOL_CALL_ID } from './types.js'; +import { + DAEMON_PLAN_TOOL_CALL_ID, + DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS, +} from './types.js'; import { createDaemonToolPreview } from './toolPreview.js'; import { isRecord } from './utils.js'; @@ -1284,14 +1287,23 @@ type UnrecognizedDiagnosticEvent = DaemonUiStatusEvent & { debugReason: DaemonUnrecognizedDiagnosticReason; }; +/** Membership over the runtime reason array, so a reason added there is + * routed here without a second hand-edited literal list (#8823 review). */ +function isUnrecognizedReason( + reason: DaemonUiStatusEvent['debugReason'], +): reason is DaemonUnrecognizedDiagnosticReason { + return ( + reason !== undefined && + (DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS as readonly string[]).includes( + reason, + ) + ); +} + function isUnrecognizedDiagnostic( event: DaemonUiStatusEvent, ): event is UnrecognizedDiagnosticEvent { - return ( - event.type === 'debug' && - (event.debugReason === 'unrecognized_event' || - event.debugReason === 'unrecognized_session_update') - ); + return event.type === 'debug' && isUnrecognizedReason(event.debugReason); } /** @@ -1309,12 +1321,31 @@ function appendUnrecognizedDiagnostic( state: DaemonTranscriptState, event: UnrecognizedDiagnosticEvent, ): void { + // The replaced `appendStatusBlock` path capped exactly these diagnostics at + // `MAX_TEXT_BLOCK_LENGTH`; a single SSE frame can carry ~16M code units and + // up to `UNRECOGNIZED_DIAGNOSTICS_LIMIT` entries persist, so the cap stays. + // Mirrors `truncateText` exactly (suffix fits WITHIN the cap; the block + // variant also reports truncation, which has no block id to report under). const diagnostic: DaemonUnrecognizedDiagnostic = { debugReason: event.debugReason, - text: event.text, + text: + event.text.length <= MAX_TEXT_BLOCK_LENGTH + ? event.text + : event.text.slice( + 0, + Math.max(0, MAX_TEXT_BLOCK_LENGTH - TEXT_TRUNCATED_SUFFIX.length), + ) + TEXT_TRUNCATED_SUFFIX, receivedAt: state.now, - ...(event.source !== undefined ? { source: event.source } : {}), - ...(event.data !== undefined ? { data: event.data } : {}), + ...(event.promptId !== undefined ? { promptId: event.promptId } : {}), + ...(event.sourceRecordIds !== undefined + ? { sourceRecordIds: event.sourceRecordIds } + : {}), + ...(event.branchRecordId !== undefined + ? { branchRecordId: event.branchRecordId } + : {}), + ...(event.originatorClientId !== undefined + ? { originatorClientId: event.originatorClientId } + : {}), ...(event.eventId !== undefined ? { eventId: event.eventId } : {}), ...(event.serverTimestamp !== undefined ? { serverTimestamp: event.serverTimestamp } diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 9310503dc77..d8103df924c 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -303,24 +303,36 @@ export type DaemonUiDebugReason = (typeof DAEMON_UI_DEBUG_REASONS)[number]; * normalizer has no case for. These diagnostics are routed to the bounded * `unrecognizedDiagnostics` sidechannel instead of `blocks[]`; `malformed_*` * diagnostics stay in the transcript because they signal an actual defect. + * + * A runtime const array (the package's established pattern for reason + * unions, see `DAEMON_UI_DEBUG_REASONS`): type-only exports are erased by + * esbuild, so a type-level subset gives the router nothing to test against, + * and a third reason added only to the type would compile cleanly while + * falling through to `appendStatusBlock` (#8823 review). */ -export type DaemonUnrecognizedDiagnosticReason = Extract< - DaemonUiDebugReason, - 'unrecognized_event' | 'unrecognized_session_update' ->; +export const DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS = [ + 'unrecognized_event', + 'unrecognized_session_update', +] as const satisfies readonly DaemonUiDebugReason[]; + +export type DaemonUnrecognizedDiagnosticReason = + (typeof DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS)[number]; /** * One forward-compatibility diagnostic mirrored onto the transcript - * sidechannel. Carries the normalizer classification plus the original - * event envelope fields a developer console needs, without ever entering + * sidechannel. Carries the normalizer classification, the correlation + * fields `createBase` stamps onto every normalized projection, and the SSE + * envelope coordinates a developer console needs — without ever entering * `blocks[]` (so it cannot finalize a streaming assistant/thought block or * consume the `maxBlocks` budget). */ export interface DaemonUnrecognizedDiagnostic { debugReason: DaemonUnrecognizedDiagnosticReason; text: string; - source?: string; - data?: unknown; + promptId?: string; + sourceRecordIds?: readonly string[]; + branchRecordId?: string; + originatorClientId?: string; eventId?: number; serverTimestamp?: number; /** Reducer receive time (`state.now` at dispatch). */ diff --git a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts index 90dd843f4ee..dc4178f4a75 100644 --- a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts +++ b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts @@ -134,12 +134,19 @@ import type { DaemonWorkspaceVoiceUpdate, KnownDaemonEvent, } from '../../src/index.js'; -import { DAEMON_UI_DEBUG_REASONS } from '../../src/daemon/index.js'; +import { + DAEMON_UI_DEBUG_REASONS, + DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS, + selectUnrecognizedDiagnostics, + UNRECOGNIZED_DIAGNOSTICS_LIMIT, +} from '../../src/daemon/index.js'; import type { DaemonChannelStartupAttemptFailure as DaemonEntryChannelStartupAttemptFailure, DaemonChannelStartupFailure as DaemonEntryChannelStartupFailure, DaemonChannelWorkerStartErrorResponse as DaemonEntryChannelWorkerStartErrorResponse, DaemonUiDebugReason as DaemonEntryUiDebugReason, + DaemonUnrecognizedDiagnostic as DaemonEntryUnrecognizedDiagnostic, + DaemonUnrecognizedDiagnosticReason as DaemonEntryUnrecognizedDiagnosticReason, } from '../../src/daemon/index.js'; describe('public SDK entry — typed daemon event surface (#4217)', () => { @@ -489,3 +496,32 @@ describe('daemon UI debug-reason public surface', () => { >(); }); }); + +describe('unrecognized-diagnostic sidechannel public surface (#8823)', () => { + it('pins the routed reason subset as a runtime value', () => { + // Same esbuild-erasure hazard as DAEMON_UI_DEBUG_REASONS: the router + // keys on this array at runtime, so it must ship as a value, and the + // subset must stay inside the parent union. + expect(DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS).toEqual([ + 'unrecognized_event', + 'unrecognized_session_update', + ]); + for (const reason of DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS) { + expect(DAEMON_UI_DEBUG_REASONS).toContain(reason); + } + expectTypeOf().toEqualTypeOf< + 'unrecognized_event' | 'unrecognized_session_update' + >(); + }); + + it('reaches the selector and the cap through the daemon entry', () => { + // The PR's Risk & Scope points adapters at `@qwen-code/sdk/daemon`; an + // export missing from the barrel is a compile error for every consumer, + // so pin reachability the way DAEMON_UI_DEBUG_REASONS is pinned. + expect(typeof selectUnrecognizedDiagnostics).toBe('function'); + expect(UNRECOGNIZED_DIAGNOSTICS_LIMIT).toBe(50); + expectTypeOf().toHaveProperty( + 'debugReason', + ); + }); +}); diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 2a21fe82db6..51786c3ccdf 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -2707,6 +2707,98 @@ describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => { ]); }); + it('carries the envelope coordinates and correlation fields onto sidechannel entries (#8823)', () => { + // Every field the type promises must actually land: a mutation deleting + // any one spread (or swapping `receivedAt` for a constant) used to leave + // the whole suite green because only `debugReason` was asserted. `now` + // is distinct from `serverTimestamp` and `eventId` so `receivedAt` + // discriminates. + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState(), + normalizeDaemonEvent({ + id: 42, + v: 1, + type: 'some_future_event', + promptId: 'prompt-1', + originatorClientId: 'client-9', + serverTimestamp: 1234, + data: { + update: { + sessionUpdate: 'mystery_kind', + _meta: { + qwenTranscript: { + sourceRecordIds: ['rec-1', 'rec-2'], + branchRecordId: 'branch-1', + }, + }, + }, + }, + } as never), + { now: 5 }, + ); + + expect(state.unrecognizedDiagnostics).toEqual([ + { + debugReason: 'unrecognized_event', + text: expect.any(String), + promptId: 'prompt-1', + sourceRecordIds: ['rec-1', 'rec-2'], + branchRecordId: 'branch-1', + originatorClientId: 'client-9', + eventId: 42, + serverTimestamp: 1234, + receivedAt: 5, + }, + ]); + }); + + it('caps sidechannel text at the block-length limit (#8823)', () => { + // The replaced `appendStatusBlock` path truncated exactly these + // diagnostics; the sidechannel must not admit unbounded strings. + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { + type: 'debug', + debugReason: 'unrecognized_event', + text: 'x'.repeat(110_000), + }, + ], + ); + + const entry = state.unrecognizedDiagnostics[0]; + expect(entry).toBeDefined(); + expect(entry?.text.endsWith('\n[truncated]\n')).toBe(true); + // Same total bound as the block path's `truncateText`: the suffix fits + // within the cap, not on top of it. + expect(entry?.text.length).toBeLessThanOrEqual(100_000); + }); + + it('still stamps debugReason on the block path for malformed payloads (#8823)', () => { + // Block-level `debugReason` stays load-bearing for the events that still + // take the block path (and for legacy persisted blocks): dropping the + // stamp in `appendStatusBlock` must not ship green. + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { + type: 'debug', + debugReason: 'malformed_payload', + text: 'broken frame payload', + }, + ], + ); + + expect(state.unrecognizedDiagnostics).toEqual([]); + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toEqual( + expect.objectContaining({ + kind: 'debug', + debugReason: 'malformed_payload', + }), + ); + }); + it('leaves client-dispatched debug blocks without a debugReason', () => { // The mirror of the test above, and the invariant that keeps Web Shell's // model-switch summary visible. Without it, defaulting the field in diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 6b93cabdefb..809a519da18 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -3288,6 +3288,80 @@ describe('DaemonSessionProvider', () => { expect(blocks.some((b) => b.kind === 'debug')).toBe(false); }); + it('keeps the burst in one block when a malformed_payload debug event interleaves', async () => { + // Sibling of the test above for the stimulus that STILL takes the block + // path: unrecognized_* diagnostics now route to the sidechannel without + // `clearActiveText`, so they can no longer discriminate the + // flush-before-guard fix (#7012) — deleting the guard leaves that test + // green. `malformed_payload` still appends a status block (with + // `clearActiveText`), so this interleaved frame splits the assistant + // burst unless the guard flushes first (#8823 review). + const burstDrained = createDeferred(); + const session = createMockSession({ + events: async function* observerMalformedBurst( + opts: { signal?: AbortSignal } = {}, + ) { + yield { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'first ' }, + }, + }, + }; + // An unusable session_update discriminator normalizes to a `debug` + // UI event with `debugReason: 'malformed_payload'`. + yield { + id: 2, + v: 1, + type: 'session_update', + data: { update: { sessionUpdate: 42 } }, + }; + yield { + id: 3, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'second' }, + }, + }, + }; + burstDrained.resolve(); + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + }, + }); + sdkMocks.sessions.push(session); + let blocks: readonly DaemonTranscriptBlock[] = []; + function Harness() { + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + await act(async () => { + await burstDrained.promise; + await flushPromises(); + await flushTranscriptDispatch(); + }); + + const assistantBlocks = blocks.filter((b) => b.kind === 'assistant'); + expect(assistantBlocks).toHaveLength(1); + expect((assistantBlocks[0] as { text?: string }).text).toBe('first second'); + }); + it('does not insert abort errors from shell commands into the transcript', async () => { const session = createMockSession({ events: createIdleEvents(), From 16f7fb1dd5132ae05b6ddff8627a372c26081400 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 15 Aug 2026 18:46:33 +0000 Subject: [PATCH 04/12] fix(sdk): address round-2 sidechannel review for #8823 - build.js: bump daemon browser bundle budget 191KB -> 192KB (195,591 bytes measured > 195,584 cap; build failed at head) - webui: narrow the observer-mode debug guard so unrecognized_* diagnostics reach the reducer sidechannel; only block-path debug events are dropped - webui: merge history-store unrecognizedDiagnostics in applyTranscriptHistory so paged-back sessions keep diagnostics - transcript: extract truncateTextAtLimit shared by the block and sidechannel truncation paths - transcript: reset unrecognizedDiagnostics on rewind alongside the sibling per-turn state resets - types: rename DaemonUnrecognizedDiagnostic.receivedAt to clientReceivedAt (matches the sibling block projection) - tests: reason-prefix conformance pin, rewind reset, narrowed guard, history pagination merge --- packages/sdk-typescript/scripts/build.js | 4 +- .../src/daemon/ui/transcript.ts | 32 +++--- .../sdk-typescript/src/daemon/ui/types.ts | 2 +- .../test/daemon-ui-transcript.test.ts | 43 ++++++++ .../test/unit/daemon-public-surface.test.ts | 14 +++ .../sdk-typescript/test/unit/daemonUi.test.ts | 10 +- .../session/DaemonSessionProvider.test.tsx | 103 ++++++++++++++++++ .../daemon/session/DaemonSessionProvider.tsx | 27 ++++- 8 files changed, 212 insertions(+), 23 deletions(-) diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index ac7c22e2013..89c266f8090 100644 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -88,7 +88,9 @@ const rootDir = join(__dirname, '..'); // Bumped from 189KB to 190KB for historical branch sessions and transcript branch-point projection merged with the upload and reasoning APIs. // Bumped from 190KB to 191KB for the unrecognized-diagnostic sidechannel // (`unrecognizedDiagnostics` routing + selector, #8823). -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 191 * 1024; +// Bumped from 191KB to 192KB because that same sidechannel change grew the +// daemon barrel 7 bytes past the 191KB cap (195,591 bytes measured, #8823). +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 192 * 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 diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index cc9a71978db..a61eaf7cd77 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -1324,18 +1324,12 @@ function appendUnrecognizedDiagnostic( // The replaced `appendStatusBlock` path capped exactly these diagnostics at // `MAX_TEXT_BLOCK_LENGTH`; a single SSE frame can carry ~16M code units and // up to `UNRECOGNIZED_DIAGNOSTICS_LIMIT` entries persist, so the cap stays. - // Mirrors `truncateText` exactly (suffix fits WITHIN the cap; the block - // variant also reports truncation, which has no block id to report under). + // Shares `truncateTextAtLimit` with the block path; the only delta is the + // truncation report, which has no block id to report under. const diagnostic: DaemonUnrecognizedDiagnostic = { debugReason: event.debugReason, - text: - event.text.length <= MAX_TEXT_BLOCK_LENGTH - ? event.text - : event.text.slice( - 0, - Math.max(0, MAX_TEXT_BLOCK_LENGTH - TEXT_TRUNCATED_SUFFIX.length), - ) + TEXT_TRUNCATED_SUFFIX, - receivedAt: state.now, + text: truncateTextAtLimit(event.text), + clientReceivedAt: state.now, ...(event.promptId !== undefined ? { promptId: event.promptId } : {}), ...(event.sourceRecordIds !== undefined ? { sourceRecordIds: event.sourceRecordIds } @@ -1684,6 +1678,9 @@ function rebuildTranscriptIndexes(state: DaemonTranscriptState): void { state.currentToolCallId = undefined; state.pendingUserShellCommand = undefined; state.lastFollowupSuggestion = undefined; + // Rewind erases the turns these diagnostics were routed from; keep the + // sidechannel aligned with the sibling per-turn state reset above. + state.unrecognizedDiagnostics = []; const liveToolCallIds = new Set(); for (const block of state.blocks) { @@ -1784,6 +1781,15 @@ function appendBoundedText( return truncateText(state, block.id, block.sourceRecordIds, existing + text); } +function truncateTextAtLimit(text: string): string { + if (text.length <= MAX_TEXT_BLOCK_LENGTH) return text; + const keepLength = Math.max( + 0, + MAX_TEXT_BLOCK_LENGTH - TEXT_TRUNCATED_SUFFIX.length, + ); + return `${text.slice(0, keepLength)}${TEXT_TRUNCATED_SUFFIX}`; +} + function truncateText( state: DaemonTranscriptState, blockId: string, @@ -1792,11 +1798,7 @@ function truncateText( ): string { if (text.length <= MAX_TEXT_BLOCK_LENGTH) return text; reportTextTruncation(state, blockId, sourceRecordIds); - const keepLength = Math.max( - 0, - MAX_TEXT_BLOCK_LENGTH - TEXT_TRUNCATED_SUFFIX.length, - ); - return `${text.slice(0, keepLength)}${TEXT_TRUNCATED_SUFFIX}`; + return truncateTextAtLimit(text); } function reportTextTruncation( diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index d8103df924c..1b2e085e7e6 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -336,7 +336,7 @@ export interface DaemonUnrecognizedDiagnostic { eventId?: number; serverTimestamp?: number; /** Reducer receive time (`state.now` at dispatch). */ - receivedAt: number; + clientReceivedAt: number; } export interface DaemonUiStatusEvent extends DaemonUiEventBase { diff --git a/packages/sdk-typescript/test/daemon-ui-transcript.test.ts b/packages/sdk-typescript/test/daemon-ui-transcript.test.ts index 8bcb74ffc88..afeee2f975e 100644 --- a/packages/sdk-typescript/test/daemon-ui-transcript.test.ts +++ b/packages/sdk-typescript/test/daemon-ui-transcript.test.ts @@ -40,6 +40,49 @@ describe('daemon transcript rewind', () => { expect(state.activeAssistantBlockId).toBeUndefined(); }); + it('resets the unrecognized diagnostics sidechannel on rewind (#8823)', () => { + // The rewind drops the turn these diagnostics were routed from; the + // sidechannel must follow the sibling per-turn state resets instead of + // reporting diagnostics for turns that no longer exist. + const turnEvents: DaemonUiEvent[] = [ + { type: 'user.text.delta', text: 'first' }, + { type: 'assistant.text.delta', text: 'first answer' }, + { type: 'assistant.done' }, + { type: 'user.text.delta', text: 'second' }, + { + type: 'debug', + debugReason: 'unrecognized_event', + text: 'future frame during the erased turn', + }, + ]; + + const beforeRewind = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + turnEvents, + { now: 1 }, + ); + expect(beforeRewind.unrecognizedDiagnostics).toHaveLength(1); + + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + ...turnEvents, + { + type: 'session.rewound', + promptId: 'session########1', + targetTurnIndex: 1, + }, + ], + { now: 1 }, + ); + + expect(state.blocks.map((block) => block.kind)).toEqual([ + 'user', + 'assistant', + ]); + expect(state.unrecognizedDiagnostics).toEqual([]); + }); + it('attaches a completed-turn branch anchor to the active Assistant block', () => { const state = reduceDaemonTranscriptEvents( createDaemonTranscriptState({ now: 1 }), diff --git a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts index dc4178f4a75..47260675a29 100644 --- a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts +++ b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts @@ -514,6 +514,20 @@ describe('unrecognized-diagnostic sidechannel public surface (#8823)', () => { >(); }); + it('routes every unrecognized_*-prefixed debug reason (#8823)', () => { + // Membership is what the router tests, but Web Shell hides debug blocks + // by the `unrecognized_` prefix — so any reason added to + // DAEMON_UI_DEBUG_REASONS under that prefix must join the routed + // subset, otherwise those frames fall through to `appendStatusBlock` + // (finalizing the streaming block, consuming the maxBlocks budget) + // while renderers hide the resulting block: the #8823 symptoms, + // invisible until usage-loss reports arrive. + for (const reason of DAEMON_UI_DEBUG_REASONS) { + if (!reason.startsWith('unrecognized_')) continue; + expect(DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS).toContain(reason); + } + }); + it('reaches the selector and the cap through the daemon entry', () => { // The PR's Risk & Scope points adapters at `@qwen-code/sdk/daemon`; an // export missing from the barrel is a compile error for every consumer, diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 51786c3ccdf..9c06ad98912 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -2709,10 +2709,10 @@ describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => { it('carries the envelope coordinates and correlation fields onto sidechannel entries (#8823)', () => { // Every field the type promises must actually land: a mutation deleting - // any one spread (or swapping `receivedAt` for a constant) used to leave - // the whole suite green because only `debugReason` was asserted. `now` - // is distinct from `serverTimestamp` and `eventId` so `receivedAt` - // discriminates. + // any one spread (or swapping `clientReceivedAt` for a constant) used to + // leave the whole suite green because only `debugReason` was asserted. + // `now` is distinct from `serverTimestamp` and `eventId` so + // `clientReceivedAt` discriminates. const state = reduceDaemonTranscriptEvents( createDaemonTranscriptState(), normalizeDaemonEvent({ @@ -2747,7 +2747,7 @@ describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => { originatorClientId: 'client-9', eventId: 42, serverTimestamp: 1234, - receivedAt: 5, + clientReceivedAt: 5, }, ]); }); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 809a519da18..3c667e1cf32 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -16,6 +16,7 @@ import type { DaemonTranscriptBlock, DaemonTranscriptStore, DaemonUiSessionActions, + DaemonUnrecognizedDiagnostic, PromptResult, } from '@qwen-code/sdk/daemon'; import { DaemonHttpError } from '@qwen-code/sdk/daemon'; @@ -3267,8 +3268,10 @@ describe('DaemonSessionProvider', () => { }); sdkMocks.sessions.push(session); let blocks: readonly DaemonTranscriptBlock[] = []; + let diagnostics: readonly DaemonUnrecognizedDiagnostic[] = []; function Harness() { blocks = useDaemonTranscriptBlocks(); + diagnostics = useDaemonTranscriptState().unrecognizedDiagnostics; return null; } @@ -3286,6 +3289,14 @@ describe('DaemonSessionProvider', () => { expect(assistantBlocks).toHaveLength(1); expect((assistantBlocks[0] as { text?: string }).text).toBe('first second'); expect(blocks.some((b) => b.kind === 'debug')).toBe(false); + // The narrowed guard (#8823 review) lets `unrecognized_*` debug events + // through to the reducer: they route onto the sidechannel instead of + // `blocks[]`, so they cannot split the burst and must not be dropped + // before dispatch. + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toEqual( + expect.objectContaining({ debugReason: 'unrecognized_event' }), + ); }); it('keeps the burst in one block when a malformed_payload debug event interleaves', async () => { @@ -4063,6 +4074,98 @@ describe('DaemonSessionProvider', () => { }, ); + it('keeps history-sourced unrecognized diagnostics on the sidechannel when paging (#8823)', async () => { + // A session recorded by a newer daemon is exactly the forward-compat + // case the sidechannel exists for: unknown persisted session_update + // kinds normalize to `unrecognized_session_update` debug events in the + // throwaway history store, and applyTranscriptHistory must merge them + // onto the live store instead of dropping them. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const session = createMockSession({ + replaySnapshot: { + compactedReplay: [ + { + v: 1, + type: 'history_truncated', + data: { + reason: 'replay_window_exceeded', + truncatedEvents: 4, + retainedEvents: 1, + maxBytes: 512, + fullTranscriptAvailable: true, + }, + }, + { + id: 5, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'retained tail' }, + _meta: { 'qwen.session.recordId': 'record-retained' }, + }, + }, + }, + ], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [ + { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'mystery_kind_from_newer_daemon', + _meta: { 'qwen.session.recordId': 'record-old' }, + }, + }, + }, + ], + hasMore: false, + }); + let diagnostics: readonly DaemonUnrecognizedDiagnostic[] = []; + let history: ReturnType | undefined; + + function Harness() { + diagnostics = useDaemonTranscriptState().unrecognizedDiagnostics; + history = useDaemonTranscriptHistory(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + historyPageSize: 25, + }); + await act(async () => { + await flushPromises(); + }); + + expect(history?.hasMore).toBe(true); + expect(diagnostics).toHaveLength(0); + + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toEqual( + expect.objectContaining({ debugReason: 'unrecognized_session_update' }), + ); + }); + it('uses history_truncated marker recordId as pagination anchor when session_updates lack one', async () => { // Regression coverage: a live-journal truncation during a single long // in-flight turn can leave the retained window with no diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index a3df97d06a9..1bd56982037 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -17,9 +17,11 @@ import { useSyncExternalStore, } from 'react'; import { + DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS, DaemonClient, DaemonHttpError, DaemonSessionClient, + UNRECOGNIZED_DIAGNOSTICS_LIMIT, createDaemonTranscriptStore, extractServerTimestamp, matchTurnEvent, @@ -32,6 +34,7 @@ import { type DaemonTranscriptStore, type DaemonTurnCompleteData, type DaemonUiEvent, + type DaemonUnrecognizedDiagnostic, } from '@qwen-code/sdk/daemon'; import { createDaemonSessionActions, @@ -157,6 +160,7 @@ interface TranscriptHistoryMaterialization { nextOrdinal: number; toolBlockByCallId: Record; permissionBlockByRequestId: Record; + unrecognizedDiagnostics: readonly DaemonUnrecognizedDiagnostic[]; } const SESSION_TRANSCRIPT_PAGINATION_FEATURE = 'session_transcript_pagination'; @@ -298,6 +302,10 @@ function materializeTranscriptHistory( nextOrdinal: history.nextOrdinal, toolBlockByCallId: history.toolBlockByCallId, permissionBlockByRequestId: history.permissionBlockByRequestId, + // History pages come from older daemon versions are exactly the + // forward-compat case the sidechannel exists for (#8823); keep them + // instead of dropping the throwaway store's diagnostics. + unrecognizedDiagnostics: history.unrecognizedDiagnostics, }; } @@ -317,6 +325,12 @@ function applyTranscriptHistory( ...history.permissionBlockByRequestId, ...current.permissionBlockByRequestId, }, + // History entries are older than anything received live, so they go + // first; the slice keeps the newest entries within the sidechannel cap. + unrecognizedDiagnostics: [ + ...history.unrecognizedDiagnostics, + ...current.unrecognizedDiagnostics, + ].slice(-UNRECOGNIZED_DIAGNOSTICS_LIMIT), }; } @@ -2192,8 +2206,19 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const shouldGuardAssistant = !hasSessionActivePrompt() && store.getSnapshot().activeAssistantBlockId != null; + // `unrecognized_*` debug events route to the sidechannel + // instead of `blocks[]` (#8823), so they cannot split the + // streaming assistant block and must not be dropped here; + // only block-path debug events still need the guard. const eventsToDispatch = shouldGuardAssistant - ? transcriptUiEvents.filter((e) => e.type !== 'debug') + ? transcriptUiEvents.filter( + (e) => + e.type !== 'debug' || + (e.debugReason !== undefined && + ( + DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS as readonly string[] + ).includes(e.debugReason)), + ) : transcriptUiEvents; enqueueTranscriptEvents(eventsToDispatch); for (const uiEvent of uiEvents) { From 472c524fb2b5a20589acf7129752701288e1b881 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 16 Aug 2026 22:42:26 +0800 Subject: [PATCH 05/12] fix(webui): avoid flushing sidechannel diagnostics --- .../daemon/session/DaemonSessionProvider.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 1bd56982037..276d24d80db 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -2189,18 +2189,25 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setPromptStatus('idle'); } } + const hasBlockPathDebugEvent = uiEvents.some( + (e) => + e.type === 'debug' && + !( + e.debugReason !== undefined && + ( + DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS as readonly string[] + ).includes(e.debugReason) + ), + ); // The debug guard below reads the committed store's active // assistant block, but batching leaves earlier chunks from this // same burst in the pending buffer until the macrotask flush. An // observer burst that interleaves a debug event between assistant // chunks would otherwise miss the still-pending assistant block // and let the debug event split it. Commit the buffer first so the - // guard sees the effective state. Scoped to observer-mode debug - // events (rare) so steady streaming keeps batching. - if ( - !hasSessionActivePrompt() && - uiEvents.some((e) => e.type === 'debug') - ) { + // guard sees the effective state. Scoped to block-path debug events + // because unrecognized diagnostics route to the sidechannel. + if (!hasSessionActivePrompt() && hasBlockPathDebugEvent) { flushTranscriptSync(); } const shouldGuardAssistant = From d15e57bd3871b1d0a69a38c532db38c87f23d69b Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 16 Aug 2026 23:16:26 +0800 Subject: [PATCH 06/12] fix(sdk): preserve diagnostics across rewind --- .../src/daemon/ui/transcript.ts | 4 ---- .../test/daemon-ui-transcript.test.ts | 20 +++++++++++++------ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 9a21b1c7401..2092f793171 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -1682,10 +1682,6 @@ function rebuildTranscriptIndexes(state: DaemonTranscriptState): void { state.currentToolCallId = undefined; state.pendingUserShellCommand = undefined; state.lastFollowupSuggestion = undefined; - // Rewind erases the turns these diagnostics were routed from; keep the - // sidechannel aligned with the sibling per-turn state reset above. - state.unrecognizedDiagnostics = []; - const liveToolCallIds = new Set(); for (const block of state.blocks) { if (block.kind === 'tool') { diff --git a/packages/sdk-typescript/test/daemon-ui-transcript.test.ts b/packages/sdk-typescript/test/daemon-ui-transcript.test.ts index afeee2f975e..5823b7c4e2f 100644 --- a/packages/sdk-typescript/test/daemon-ui-transcript.test.ts +++ b/packages/sdk-typescript/test/daemon-ui-transcript.test.ts @@ -40,12 +40,17 @@ describe('daemon transcript rewind', () => { expect(state.activeAssistantBlockId).toBeUndefined(); }); - it('resets the unrecognized diagnostics sidechannel on rewind (#8823)', () => { - // The rewind drops the turn these diagnostics were routed from; the - // sidechannel must follow the sibling per-turn state resets instead of - // reporting diagnostics for turns that no longer exist. + it('preserves the unrecognized diagnostics sidechannel on rewind (#8823)', () => { + // Diagnostics have no per-turn association to prune by. Keep the bounded + // sidechannel intact instead of dropping retained-turn forward-compat + // signals. const turnEvents: DaemonUiEvent[] = [ { type: 'user.text.delta', text: 'first' }, + { + type: 'debug', + debugReason: 'unrecognized_event', + text: 'future frame during the retained turn', + }, { type: 'assistant.text.delta', text: 'first answer' }, { type: 'assistant.done' }, { type: 'user.text.delta', text: 'second' }, @@ -61,7 +66,7 @@ describe('daemon transcript rewind', () => { turnEvents, { now: 1 }, ); - expect(beforeRewind.unrecognizedDiagnostics).toHaveLength(1); + expect(beforeRewind.unrecognizedDiagnostics).toHaveLength(2); const state = reduceDaemonTranscriptEvents( createDaemonTranscriptState({ now: 1 }), @@ -80,7 +85,10 @@ describe('daemon transcript rewind', () => { 'user', 'assistant', ]); - expect(state.unrecognizedDiagnostics).toEqual([]); + expect(state.unrecognizedDiagnostics.map((entry) => entry.text)).toEqual([ + 'future frame during the retained turn', + 'future frame during the erased turn', + ]); }); it('attaches a completed-turn branch anchor to the active Assistant block', () => { From e6b40e5c3802caa66cb1cb16a64b3ace64a656b2 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 17 Aug 2026 02:36:28 +0800 Subject: [PATCH 07/12] fix(webui): dedupe sidechannel history records --- .../session/DaemonSessionProvider.test.tsx | 41 +++++++++++++++++++ .../daemon/session/DaemonSessionProvider.tsx | 7 +++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index dd589b6489f..c7d5f129681 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -4124,6 +4124,20 @@ describe('DaemonSessionProvider', () => { index < UNRECOGNIZED_DIAGNOSTICS_LIMIT - 1; index++ ) { + if (index === 0) { + yield { + id: 100, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'mystery_kind_from_newer_daemon_overlap', + _meta: { 'qwen.session.recordId': 'record-overlap' }, + }, + }, + }; + continue; + } yield { id: 100 + index, v: 1, @@ -4158,6 +4172,17 @@ describe('DaemonSessionProvider', () => { }, }, })), + { + id: 5, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'mystery_kind_from_newer_daemon_overlap', + _meta: { 'qwen.session.recordId': 'record-overlap' }, + }, + }, + }, ], hasMore: false, }); @@ -4195,6 +4220,22 @@ describe('DaemonSessionProvider', () => { expect.objectContaining({ debugReason: 'unrecognized_session_update' }), ); expect(diagnostics[1]).toEqual( + expect.objectContaining({ + debugReason: 'unrecognized_session_update', + sourceRecordIds: ['record-overlap'], + }), + ); + expect( + diagnostics.filter((entry) => + entry.sourceRecordIds?.includes('record-overlap'), + ), + ).toHaveLength(1); + expect( + diagnostics.filter((entry) => + entry.sourceRecordIds?.includes('record-old-1'), + ), + ).toHaveLength(1); + expect(diagnostics[2]).toEqual( expect.objectContaining({ debugReason: 'unrecognized_event' }), ); }); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 276d24d80db..588362ffecc 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -278,6 +278,11 @@ function materializeTranscriptHistory( displayedRecordIds.add(recordId); } } + for (const diagnostic of current.unrecognizedDiagnostics) { + for (const recordId of diagnostic.sourceRecordIds ?? []) { + displayedRecordIds.add(recordId); + } + } const freshEvents = displayedRecordIds.size === 0 ? events @@ -302,7 +307,7 @@ function materializeTranscriptHistory( nextOrdinal: history.nextOrdinal, toolBlockByCallId: history.toolBlockByCallId, permissionBlockByRequestId: history.permissionBlockByRequestId, - // History pages come from older daemon versions are exactly the + // History pages can carry frames recorded by newer daemon versions, exactly // forward-compat case the sidechannel exists for (#8823); keep them // instead of dropping the throwaway store's diagnostics. unrecognizedDiagnostics: history.unrecognizedDiagnostics, From 33984036328fed12719b27a9f04cde84dc6578cb Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 16 Aug 2026 20:30:36 +0000 Subject: [PATCH 08/12] fix(webui): align the paging sidechannel test with the normalizer keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paging test added in e6b40e5c failed deterministically (webui suite red, CI Test job red) for two reasons: 1. The fixtures stamped only _meta['qwen.session.recordId'], but the SDK normalizer's extractSourceRecordIds reads _meta.qwenTranscript.sourceRecordIds — no sidechannel entry ever carried sourceRecordIds, so the dedupe assertion could not pass and the new displayedRecordIds loop was never exercised by a passing test. Stamp BOTH keys, matching production replay frames (acp-bridge buildUpdateMeta) and the sibling dedupe test. 2. Cap arithmetic: LIMIT-1 live entries + 2 fresh history entries = LIMIT+1, so the newest-wins slice evicted record-old-1 which the test asserted present. Emit LIMIT-2 live events so the post-merge total lands exactly on the cap. Also correct the post-merge index assertions: history entries come first (old-1, old-2), then the deduped-once live overlap, then the first live mystery event. Suite 506/506, eslint + prettier clean. --- .../session/DaemonSessionProvider.test.tsx | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index c7d5f129681..ba66401234e 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -4109,7 +4109,10 @@ describe('DaemonSessionProvider', () => { update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'retained tail' }, - _meta: { 'qwen.session.recordId': 'record-retained' }, + _meta: { + 'qwen.session.recordId': 'record-retained', + qwenTranscript: { sourceRecordIds: ['record-retained'] }, + }, }, }, }, @@ -4119,9 +4122,12 @@ describe('DaemonSessionProvider', () => { events: async function* liveDiagnostics( opts: { signal?: AbortSignal } = {}, ) { + // LIMIT-2 live events so the post-merge total (live + the two + // fresh history entries, the overlap deduped away) lands exactly + // on the cap without newest-wins eviction. for ( let index = 0; - index < UNRECOGNIZED_DIAGNOSTICS_LIMIT - 1; + index < UNRECOGNIZED_DIAGNOSTICS_LIMIT - 2; index++ ) { if (index === 0) { @@ -4132,7 +4138,13 @@ describe('DaemonSessionProvider', () => { data: { update: { sessionUpdate: 'mystery_kind_from_newer_daemon_overlap', - _meta: { 'qwen.session.recordId': 'record-overlap' }, + // Production replay frames stamp BOTH keys (acp-bridge + // buildUpdateMeta); the normalizer's dedupe reads + // qwenTranscript.sourceRecordIds. + _meta: { + 'qwen.session.recordId': 'record-overlap', + qwenTranscript: { sourceRecordIds: ['record-overlap'] }, + }, }, }, }; @@ -4168,7 +4180,10 @@ describe('DaemonSessionProvider', () => { data: { update: { sessionUpdate: `mystery_kind_from_newer_daemon_${id}`, - _meta: { 'qwen.session.recordId': `record-old-${id}` }, + _meta: { + 'qwen.session.recordId': `record-old-${id}`, + qwenTranscript: { sourceRecordIds: [`record-old-${id}`] }, + }, }, }, })), @@ -4179,7 +4194,10 @@ describe('DaemonSessionProvider', () => { data: { update: { sessionUpdate: 'mystery_kind_from_newer_daemon_overlap', - _meta: { 'qwen.session.recordId': 'record-overlap' }, + _meta: { + 'qwen.session.recordId': 'record-overlap', + qwenTranscript: { sourceRecordIds: ['record-overlap'] }, + }, }, }, }, @@ -4207,7 +4225,7 @@ describe('DaemonSessionProvider', () => { expect(history?.hasMore).toBe(true); await vi.waitFor(() => - expect(diagnostics).toHaveLength(UNRECOGNIZED_DIAGNOSTICS_LIMIT - 1), + expect(diagnostics).toHaveLength(UNRECOGNIZED_DIAGNOSTICS_LIMIT - 2), ); await act(async () => { @@ -4215,11 +4233,24 @@ describe('DaemonSessionProvider', () => { await flushPromises(); }); + // Merge order: history entries first (older), then the live ones; the + // page's duplicate of the live overlap record is deduped away, so the + // two fresh history entries plus the live stream land exactly on the + // cap. expect(diagnostics).toHaveLength(UNRECOGNIZED_DIAGNOSTICS_LIMIT); expect(diagnostics[0]).toEqual( - expect.objectContaining({ debugReason: 'unrecognized_session_update' }), + expect.objectContaining({ + debugReason: 'unrecognized_session_update', + sourceRecordIds: ['record-old-1'], + }), ); expect(diagnostics[1]).toEqual( + expect.objectContaining({ + debugReason: 'unrecognized_session_update', + sourceRecordIds: ['record-old-2'], + }), + ); + expect(diagnostics[2]).toEqual( expect.objectContaining({ debugReason: 'unrecognized_session_update', sourceRecordIds: ['record-overlap'], @@ -4235,7 +4266,7 @@ describe('DaemonSessionProvider', () => { entry.sourceRecordIds?.includes('record-old-1'), ), ).toHaveLength(1); - expect(diagnostics[2]).toEqual( + expect(diagnostics[3]).toEqual( expect.objectContaining({ debugReason: 'unrecognized_event' }), ); }); From 6b5a5333e4c650a4674896366aa4c9957656f818 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 17 Aug 2026 16:48:33 +0800 Subject: [PATCH 09/12] fix(sdk): raise diagnostic sidechannel bundle budget --- packages/sdk-typescript/scripts/build.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index fcca9d983c1..988afbeb71d 100644 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -96,7 +96,9 @@ const rootDir = join(__dirname, '..'); // metadata (#9180). // Bumped from 195KB to 196KB for transient-vs-gone media hydration errors and // the reference-only replay placeholder. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 196 * 1024; +// Bumped from 196KB to 197KB for the unrecognized-diagnostic sidechannel after +// merging current main. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 197 * 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 From 6150a4e42fa8b953c51d8c3b867c7e26496c037c Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 17 Aug 2026 19:58:46 +0000 Subject: [PATCH 10/12] fix(sdk): raise the daemon browser bundle budget to 198KB and pin the diagnostics selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The sidechannel routing + selector cost ~1037 B over the 197KB cap (bundle measured 201893 B), failing the browser-bundle size gate; bump MAX_DAEMON_BROWSER_BUNDLE_BYTES to 198 * 1024. - Fold the rebase-residue 190→191→192 KB ledger entries into the accurate 190→195→196→197→198 lineage so the next bump has one canonical history. - Add a behavioral pin for selectUnrecognizedDiagnostics: it must return the routed sidechannel itself (toBe), discriminating a `return []` or shallow-copy regression that the typeof-only surface test cannot see; flip-verified. --- packages/sdk-typescript/scripts/build.js | 12 ++++------ .../test/daemon-ui-transcript.test.ts | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 9cbd7f2cd75..429a46fe49d 100644 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -87,18 +87,16 @@ const rootDir = join(__dirname, '..'); // APIs merged in from main. // Bumped from 189KB to 190KB for historical branch sessions and transcript // branch-point projection merged with the upload and reasoning APIs. -// Bumped from 190KB to 191KB for the composer text-file attachment metadata -// (#9180) on the local optimistic user transcript surface. -// Bumped from 191KB to 192KB for the unrecognized-diagnostic sidechannel -// (`unrecognizedDiagnostics` routing + selector, #8823). // Bumped from 190KB to 195KB for session media upload, cleanup, and hydration // merged with the branch-session APIs and the composer text-file attachment // metadata (#9180). // Bumped from 195KB to 196KB for transient-vs-gone media hydration errors and // the reference-only replay placeholder. -// Bumped from 196KB to 197KB for the unrecognized-diagnostic sidechannel and -// workspace session live-state daemon surface after merging current main. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 197 * 1024; +// Bumped from 196KB to 197KB for the workspace session live-state daemon +// surface after merging current main. +// Bumped from 197KB to 198KB for the unrecognized-diagnostic sidechannel +// (`unrecognizedDiagnostics` routing + selector, #8823). +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 198 * 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 diff --git a/packages/sdk-typescript/test/daemon-ui-transcript.test.ts b/packages/sdk-typescript/test/daemon-ui-transcript.test.ts index 5823b7c4e2f..451b331ac82 100644 --- a/packages/sdk-typescript/test/daemon-ui-transcript.test.ts +++ b/packages/sdk-typescript/test/daemon-ui-transcript.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { createDaemonTranscriptState, reduceDaemonTranscriptEvents, + selectUnrecognizedDiagnostics, UNRECOGNIZED_DIAGNOSTICS_LIMIT, } from '../src/daemon/ui/transcript.js'; import type { DaemonUiEvent } from '../src/daemon/ui/types.js'; @@ -594,4 +595,26 @@ describe('unrecognized diagnostics stay out of the chat transcript', () => { 'event_5 (unrecognized daemon event): {}', ); }); + + it('selectUnrecognizedDiagnostics returns the routed sidechannel itself', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { + type: 'debug', + text: 'language_changed (unrecognized daemon event): {"language":"en"}', + debugReason: 'unrecognized_event', + }, + ], + { now: 1 }, + ); + + // The documented read path must return the live sidechannel, not a copy + // or a stub: `toBe` discriminates a `return []` or shallow-copy + // regression that would compile and export green. + expect(selectUnrecognizedDiagnostics(state)).toBe( + state.unrecognizedDiagnostics, + ); + expect(selectUnrecognizedDiagnostics(state)).toHaveLength(1); + }); }); From 99c945d9e606a97ecf2b35e9fbcd659ffe19b973 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 17 Aug 2026 22:04:23 +0000 Subject: [PATCH 11/12] fix(sdk): reset the user pointer on sidechanneled diagnostics, share the routing predicate appendUnrecognizedDiagnostic left activeUserBlockId untouched while the replaced appendStatusBlock path reset it for every non-user block; a later mergeable user.text.delta with no promptId stamp (e.g. a peer client's $ echo) then appended onto the earlier user block across the diagnostic, collapsing two user turns into one and skewing rewindTranscriptToUserTurn's kind==='user' turn indexing. Keep the reset (assistant/thought pointers stay untouched, the point of the sidechannel); witness test flip-verified red without the one-line reset. Also export isUnrecognizedDiagnosticReason from types.ts next to DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS and call it at all three routing-guard sites (reducer, provider flush condition, provider drop filter) so the #7012/#8823 guard pair classifies every debug event against one source instead of three hand-written copies. --- packages/sdk-typescript/src/daemon/index.ts | 1 + .../sdk-typescript/src/daemon/ui/index.ts | 1 + .../src/daemon/ui/transcript.ts | 29 +++++++++--------- .../sdk-typescript/src/daemon/ui/types.ts | 17 +++++++++++ .../test/daemon-ui-transcript.test.ts | 30 +++++++++++++++++++ .../daemon/session/DaemonSessionProvider.tsx | 14 ++------- 6 files changed, 66 insertions(+), 26 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index cf0074741e2..d67f4dfd066 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -107,6 +107,7 @@ export { getSessionUpdatePayload, isDaemonUiSensitiveKey, isSubagentChildBlock, + isUnrecognizedDiagnosticReason, normalizeDaemonEvent, redactDaemonUiSensitiveFields, rebuildDaemonTranscriptBlockIndex, diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index 6b093bbec65..f02c81b81c2 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -65,6 +65,7 @@ export { DAEMON_PLAN_TOOL_CALL_ID, DAEMON_UI_DEBUG_REASONS, DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS, + isUnrecognizedDiagnosticReason, } from './types.js'; export type { DaemonUiContentPart } from './utils.js'; export type { diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index f205db5c2c1..863aa51a709 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -23,7 +23,7 @@ import type { } from './types.js'; import { DAEMON_PLAN_TOOL_CALL_ID, - DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS, + isUnrecognizedDiagnosticReason, } from './types.js'; import { createDaemonToolPreview } from './toolPreview.js'; import { isRecord } from './utils.js'; @@ -1292,23 +1292,12 @@ type UnrecognizedDiagnosticEvent = DaemonUiStatusEvent & { debugReason: DaemonUnrecognizedDiagnosticReason; }; -/** Membership over the runtime reason array, so a reason added there is - * routed here without a second hand-edited literal list (#8823 review). */ -function isUnrecognizedReason( - reason: DaemonUiStatusEvent['debugReason'], -): reason is DaemonUnrecognizedDiagnosticReason { - return ( - reason !== undefined && - (DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS as readonly string[]).includes( - reason, - ) - ); -} - function isUnrecognizedDiagnostic( event: DaemonUiStatusEvent, ): event is UnrecognizedDiagnosticEvent { - return event.type === 'debug' && isUnrecognizedReason(event.debugReason); + return ( + event.type === 'debug' && isUnrecognizedDiagnosticReason(event.debugReason) + ); } /** @@ -1326,6 +1315,16 @@ function appendUnrecognizedDiagnostic( state: DaemonTranscriptState, event: UnrecognizedDiagnosticEvent, ): void { + // The replaced `appendStatusBlock` path also reset the user pointer + // (its non-user block append runs `state.activeUserBlockId = undefined`). + // Keep that reset: diagnostics carry no association with the active user + // block, and a stale pointer lets a later mergeable `user.text.delta` + // with no promptId stamp (e.g. a peer client's `$ ` echo) append + // onto an earlier user block, collapsing two turns into one and skewing + // `rewindTranscriptToUserTurn`'s turn indexing. The streaming + // assistant/thought pointer stays untouched — that is the whole point of + // the sidechannel (see the doc above). + state.activeUserBlockId = undefined; // The replaced `appendStatusBlock` path capped exactly these diagnostics at // `MAX_TEXT_BLOCK_LENGTH`; a single SSE frame can carry ~16M code units and // up to `UNRECOGNIZED_DIAGNOSTICS_LIMIT` entries persist, so the cap stays. diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 481a3fa36e3..0d8fd83b196 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -320,6 +320,23 @@ export const DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS = [ export type DaemonUnrecognizedDiagnosticReason = (typeof DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS)[number]; +/** + * Membership over the runtime reason array, exported so every routing guard + * (reducer sidechannel here, provider flush/drop guard pair in webui) + * classifies against one source. A reason added to the array routes onto the + * sidechannel everywhere without hand-editing each consumer (#8823 review). + */ +export function isUnrecognizedDiagnosticReason( + reason: DaemonUiDebugReason | string | undefined, +): reason is DaemonUnrecognizedDiagnosticReason { + return ( + reason !== undefined && + (DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS as readonly string[]).includes( + reason, + ) + ); +} + /** * One forward-compatibility diagnostic mirrored onto the transcript * sidechannel. Carries the normalizer classification, the correlation diff --git a/packages/sdk-typescript/test/daemon-ui-transcript.test.ts b/packages/sdk-typescript/test/daemon-ui-transcript.test.ts index 451b331ac82..137af4cd682 100644 --- a/packages/sdk-typescript/test/daemon-ui-transcript.test.ts +++ b/packages/sdk-typescript/test/daemon-ui-transcript.test.ts @@ -529,6 +529,36 @@ describe('unrecognized diagnostics stay out of the chat transcript', () => { ).toEqual(['unrecognized_event', 'unrecognized_session_update']); }); + it('resets the active user pointer across a sidechanneled diagnostic', () => { + // The replaced `appendStatusBlock` path reset `activeUserBlockId` for + // every non-user block; the sidechannel must keep that reset. Without + // it, a later mergeable `user.text.delta` with no promptId stamp (e.g. + // a peer client's `$ ` echo) appends onto the earlier user block + // across the diagnostic, collapsing two user turns into one and + // skewing `rewindTranscriptToUserTurn`'s turn indexing. + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { type: 'user.text.delta', text: '$ cmd1' }, + { + type: 'debug', + text: 'some_future_event (unrecognized daemon event): {"a":1}', + debugReason: 'unrecognized_event', + }, + { type: 'user.text.delta', text: '$ cmd2' }, + ], + { now: 1 }, + ); + + expect(state.blocks.map((block) => block.kind)).toEqual(['user', 'user']); + expect( + state.blocks.map((block) => ('text' in block ? block.text : '')), + ).toEqual(['$ cmd1', '$ cmd2']); + // The pointer follows the latest user block, not the pre-diagnostic one. + expect(state.activeUserBlockId).toBe(state.blocks[1]?.id); + expect(state.unrecognizedDiagnostics).toHaveLength(1); + }); + it('keeps malformed-payload diagnostics in the transcript', () => { const state = reduceDaemonTranscriptEvents( createDaemonTranscriptState({ now: 1 }), diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index f287d354e67..f7fa2152546 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -17,13 +17,13 @@ import { useSyncExternalStore, } from 'react'; import { - DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS, DaemonClient, DaemonHttpError, DaemonSessionClient, UNRECOGNIZED_DIAGNOSTICS_LIMIT, createDaemonTranscriptStore, extractServerTimestamp, + isUnrecognizedDiagnosticReason, matchTurnEvent, normalizeDaemonEvent, type CreateSessionRequest, @@ -2206,12 +2206,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const hasBlockPathDebugEvent = uiEvents.some( (e) => e.type === 'debug' && - !( - e.debugReason !== undefined && - ( - DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS as readonly string[] - ).includes(e.debugReason) - ), + !isUnrecognizedDiagnosticReason(e.debugReason), ); // The debug guard below reads the committed store's active // assistant block, but batching leaves earlier chunks from this @@ -2235,10 +2230,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ? transcriptUiEvents.filter( (e) => e.type !== 'debug' || - (e.debugReason !== undefined && - ( - DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS as readonly string[] - ).includes(e.debugReason)), + isUnrecognizedDiagnosticReason(e.debugReason), ) : transcriptUiEvents; enqueueTranscriptEvents(eventsToDispatch); From 021940939604bc8cf7fb01de55911c8ba8347ccd Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 19 Aug 2026 21:53:10 +0800 Subject: [PATCH 12/12] fix(ci): prevent bite harness SIGPIPE --- scripts/tests/qwen-autofix-workflow.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 3f12809aa98..a625315cfc2 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -13285,7 +13285,7 @@ exit 1 // cases; a runnerScript drives the tree-state-proving cases. writeFileSync( join(tools, 'resolve-owning-packages.sh'), - `printf '%s\\n' ${resolverLines.map((l) => `'${l}'`).join(' ')}\n`, + `cat > /dev/null\nprintf '%s\\n' ${resolverLines.map((l) => `'${l}'`).join(' ')}\n`, ); writeFileSync( join(tools, 'bite-runner'),