Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e2b688b
fix(sdk): route unrecognized diagnostics onto a bounded transcript si…
yiliang114 Aug 15, 2026
4bb31e1
Merge remote-tracking branch 'origin/main' into HEAD
yiliang114 Aug 15, 2026
767e1c8
fix(sdk): align browser bundle budget
yiliang114 Aug 15, 2026
b2a83e0
fix(sdk): close the sidechannel review round (#8823)
yiliang114 Aug 15, 2026
16f7fb1
fix(sdk): address round-2 sidechannel review for #8823
yiliang114 Aug 15, 2026
8dbabf5
fix(sdk): merge main and pin sidechannel pagination
yiliang114 Aug 16, 2026
472c524
fix(webui): avoid flushing sidechannel diagnostics
yiliang114 Aug 16, 2026
d15e57b
fix(sdk): preserve diagnostics across rewind
yiliang114 Aug 16, 2026
e6b40e5
fix(webui): dedupe sidechannel history records
yiliang114 Aug 16, 2026
3398403
fix(webui): align the paging sidechannel test with the normalizer keys
yiliang114 Aug 16, 2026
53a5a88
chore: merge main into diagnostic sidechannel
yiliang114 Aug 17, 2026
6b5a533
fix(sdk): raise diagnostic sidechannel bundle budget
yiliang114 Aug 17, 2026
1e6f922
Merge remote-tracking branch 'origin/main' into HEAD
yiliang114 Aug 17, 2026
6150a4e
fix(sdk): raise the daemon browser bundle budget to 198KB and pin the…
yiliang114 Aug 17, 2026
99c945d
fix(sdk): reset the user pointer on sidechanneled diagnostics, share …
yiliang114 Aug 17, 2026
7da3c33
Merge commit '872dd614f79b2a15d5c41902eeb420a78b3ceae2' into fix/9202…
yiliang114 Aug 19, 2026
b724586
Merge remote-tracking branch 'origin/main' into codex-auto-9202-17869…
yiliang114 Aug 19, 2026
0219409
fix(ci): prevent bite harness SIGPIPE
yiliang114 Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions docs/developers/daemon-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand All @@ -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`
Expand Down
4 changes: 3 additions & 1 deletion packages/sdk-typescript/scripts/build.js
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ const rootDir = join(__dirname, '..');
// Bumped from 196KB to 197KB for the workspace session live-state daemon
// surface (catalog version + live snapshot accessors) and immutable,
// identity-stable transcript block indexes used by browser renderers.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 197 * 1024;
// Bumped from 197KB to 198KB for the unrecognized-diagnostic sidechannel
// (`unrecognizedDiagnostics` routing + selector, #8823).
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 198 * 1024;
Comment thread
doudouOUC marked this conversation as resolved.
// 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
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk-typescript/src/daemon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -106,6 +107,7 @@ export {
getSessionUpdatePayload,
isDaemonUiSensitiveKey,
isSubagentChildBlock,
isUnrecognizedDiagnosticReason,
normalizeDaemonEvent,
redactDaemonUiSensitiveFields,
rebuildDaemonTranscriptBlockIndex,
Expand All @@ -120,9 +122,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 {
Expand Down Expand Up @@ -194,6 +198,8 @@ export type {
DaemonUiWorkspaceInitializedEvent,
DaemonUiWorkspaceMemoryChangedEvent,
DaemonUiWorkspaceToolToggledEvent,
DaemonUnrecognizedDiagnostic,
DaemonUnrecognizedDiagnosticReason,
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
NormalizeDaemonEventOptions,
} from './ui/index.js';
export {
Expand Down
11 changes: 10 additions & 1 deletion packages/sdk-typescript/src/daemon/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export {
selectToolProgress,
selectTranscriptBlocks,
selectTranscriptBlocksOrderedByEventId,
selectUnrecognizedDiagnostics,
UNRECOGNIZED_DIAGNOSTICS_LIMIT,
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
} from './transcript.js';
export { createDaemonTranscriptStore } from './store.js';
export { DAEMON_GOAL_STATUS_SENTINEL_PREFIX } from './sentinels.js';
Expand Down Expand Up @@ -59,7 +61,12 @@ 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,
isUnrecognizedDiagnosticReason,
} from './types.js';
export type { DaemonUiContentPart } from './utils.js';
export type {
DaemonShellTranscriptBlock,
Expand All @@ -81,6 +88,8 @@ export type {
DaemonTranscriptSidechannelState,
DaemonTranscriptState,
DaemonTranscriptStore,
DaemonUnrecognizedDiagnostic,
DaemonUnrecognizedDiagnosticReason,
// Chat-stream events
DaemonUiAssistantDoneEvent,
DaemonUiDebugReason,
Expand Down
119 changes: 112 additions & 7 deletions packages/sdk-typescript/src/daemon/ui/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,25 @@ import type {
DaemonTranscriptReducerOptions,
DaemonTranscriptState,
DaemonUiEvent,
DaemonUiStatusEvent,
DaemonUiTextEvent,
DaemonUnrecognizedDiagnostic,
DaemonUnrecognizedDiagnosticReason,
DaemonUserShellTranscriptBlock,
} from './types.js';
import { DAEMON_PLAN_TOOL_CALL_ID } from './types.js';
import {
DAEMON_PLAN_TOOL_CALL_ID,
isUnrecognizedDiagnosticReason,
} from './types.js';
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;
Expand Down Expand Up @@ -53,6 +64,7 @@ export function createDaemonTranscriptState(
activeThoughtBlockByParent: createIndex(),
// PR-E sidechannel: track current tool / approval mode / progress
toolProgress: createIndex(),
unrecognizedDiagnostics: [],
awaitingResync: false,
resyncRequiredCount: 0,
nextOrdinal: 1,
Expand Down Expand Up @@ -360,6 +372,10 @@ function applyDaemonTranscriptEvent(
break;
case 'status':
case 'debug':
if (isUnrecognizedDiagnostic(event)) {
Comment thread
yiliang114 marked this conversation as resolved.
appendUnrecognizedDiagnostic(next, event);
Comment thread
yiliang114 marked this conversation as resolved.
break;
}
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
appendStatusBlock(next, event.type, event.text, event, {
clearActiveText: event.clearActiveText,
});
Expand Down Expand Up @@ -1269,6 +1285,75 @@ function resolvePermissionBlock(
clearActiveText(state);
}

type UnrecognizedDiagnosticEvent = DaemonUiStatusEvent & {
type: 'debug';
debugReason: DaemonUnrecognizedDiagnosticReason;
};

function isUnrecognizedDiagnostic(
event: DaemonUiStatusEvent,
): event is UnrecognizedDiagnosticEvent {
return (
event.type === 'debug' && isUnrecognizedDiagnosticReason(event.debugReason)
);
}

/**
* 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 {
Comment thread
yiliang114 marked this conversation as resolved.
// 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 `$ <cmd>` 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.
// 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: truncateTextAtLimit(event.text),
clientReceivedAt: state.now,
...(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 }
: {}),
Comment thread
yiliang114 marked this conversation as resolved.
};
const diagnostics = [...state.unrecognizedDiagnostics, diagnostic];
state.unrecognizedDiagnostics =
diagnostics.length > UNRECOGNIZED_DIAGNOSTICS_LIMIT
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
? diagnostics.slice(-UNRECOGNIZED_DIAGNOSTICS_LIMIT)
: diagnostics;
}

function appendStatusBlock(
state: DaemonTranscriptState,
kind: 'status' | 'error' | 'debug',
Expand Down Expand Up @@ -1426,6 +1511,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,
Comment thread
yiliang114 marked this conversation as resolved.
};
const onTruncation = opts.onTruncation ?? truncationCallbacks.get(state);
if (onTruncation) truncationCallbacks.set(next, onTruncation);
Expand Down Expand Up @@ -1602,7 +1690,6 @@ function rebuildTranscriptIndexes(state: DaemonTranscriptState): void {
state.currentToolCallId = undefined;
state.pendingUserShellCommand = undefined;
state.lastFollowupSuggestion = undefined;

const liveToolCallIds = new Set<string>();
for (const block of state.blocks) {
if (block.kind === 'tool') {
Expand Down Expand Up @@ -1704,6 +1791,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,
Expand All @@ -1712,11 +1808,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(
Expand Down Expand Up @@ -1895,6 +1987,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(
Comment thread
yiliang114 marked this conversation as resolved.
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
Expand Down
68 changes: 68 additions & 0 deletions packages/sdk-typescript/src/daemon/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,64 @@ 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.
*
* 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 const DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS = [
'unrecognized_event',
'unrecognized_session_update',
] as const satisfies readonly DaemonUiDebugReason[];
Comment thread
yiliang114 marked this conversation as resolved.

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
* 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;
promptId?: string;
sourceRecordIds?: readonly string[];
branchRecordId?: string;
originatorClientId?: string;
eventId?: number;
serverTimestamp?: number;
/** Reducer receive time (`state.now` at dispatch). */
clientReceivedAt: number;
}

export interface DaemonUiStatusEvent extends DaemonUiEventBase {
type: 'status' | 'debug';
text: string;
Expand Down Expand Up @@ -1024,6 +1082,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;
Expand Down
Loading
Loading