diff --git a/docs/developers/daemon-ui/README.md b/docs/developers/daemon-ui/README.md index b518f9f8865..564dce222b8 100644 --- a/docs/developers/daemon-ui/README.md +++ b/docs/developers/daemon-ui/README.md @@ -367,12 +367,47 @@ function toolIcon(event: DaemonUiToolUpdateEvent): React.ReactNode { The SDK has a `mcp____` naming heuristic fallback — even 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): + +```ts +import type { DaemonUiDebugReason } from '@qwen-code/sdk/daemon'; +// 'unrecognized_event' | 'unrecognized_session_update' | 'malformed_payload' +``` + +The canonical list is exported as `DAEMON_UI_DEBUG_REASONS`. Reason names +are wildcard-named categories: `unrecognized_*` means the daemon sent a +frame this SDK version has no case for — forward-compat noise, developer +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: + +```ts +function hideDebugBlock(reason?: DaemonUiDebugReason): boolean { + // Hide forward-compat noise by category so reasons a newer SDK adds are + // covered automatically. Defect signals and client-dispatched debug + // events (which carry no reason) keep rendering. + return reason?.startsWith('unrecognized_') ?? false; +} +``` + +`status` events never carry a `debugReason`, and neither do debug events +dispatched by clients themselves (e.g. Web Shell's model-switch summary) — +both must keep rendering. + ## Forward-compat principles 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 +- Unknown daemon event types → `debug` event with the raw type name, + stamped with an `unrecognized_*` `debugReason` (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/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index d5d626f277d..7686d152923 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -88,6 +88,7 @@ export { createDaemonTranscriptStore, DAEMON_GOAL_STATUS_SENTINEL_PREFIX, DAEMON_PLAN_TOOL_CALL_ID, + DAEMON_UI_DEBUG_REASONS, daemonBlockToHtml, daemonBlockToMarkdown, daemonBlockToPlainText, @@ -156,6 +157,7 @@ export type { DaemonUiAuthDeviceFlowFailedEvent, DaemonUiAuthDeviceFlowStartedEvent, DaemonUiAuthDeviceFlowThrottledEvent, + DaemonUiDebugReason, DaemonUiErrorEvent, DaemonUiEvent, DaemonUiEventBase, diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index 868aeef0324..7e5d8ab7b45 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -59,7 +59,7 @@ export { stringifyJson, stripOscSequences, } from './utils.js'; -export { DAEMON_PLAN_TOOL_CALL_ID } from './types.js'; +export { DAEMON_PLAN_TOOL_CALL_ID, DAEMON_UI_DEBUG_REASONS } from './types.js'; export type { DaemonUiContentPart } from './utils.js'; export type { DaemonShellTranscriptBlock, @@ -83,6 +83,7 @@ export type { DaemonTranscriptStore, // Chat-stream events DaemonUiAssistantDoneEvent, + DaemonUiDebugReason, DaemonUiErrorEvent, DaemonUiEvent, DaemonUiEventBase, diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 57069447846..064cf988f7f 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -386,9 +386,9 @@ export function normalizeDaemonEvent( // unknown event types, the doubled block-consumption rate // accelerated `maxBlocks` trimming of real content. The `debug` // shape already carries the event-type as a prefix, so the - // status block was redundant. Adapters that want a user-visible - // banner can pattern-match on `event.type === 'debug'` and the - // text prefix. + // status block was redundant. Adapters deciding how to present a + // debug block must branch on `debugReason` — the text prefix is + // diagnostic wording and changes without notice. return normalizeUnrecognizedEvent(event, base); } } @@ -401,6 +401,7 @@ function normalizeUnrecognizedEvent( { ...base, type: 'debug', + debugReason: 'unrecognized_event', text: `${event.type} (unrecognized daemon event): ${stringifyRedactedJson(event.data)}`, }, ]; @@ -682,6 +683,7 @@ function normalizeSessionUpdate( { ...base, type: 'debug', + debugReason: 'malformed_payload', text: `session_update: ${stringifyRedactedJson(event.data)}`, }, ]; @@ -846,6 +848,16 @@ function normalizeSessionUpdate( { ...base, type: 'debug', + // `getSessionUpdatePayload` accepts any record, so `kind` is + // `undefined` for a payload whose discriminator is missing, empty or + // not a string. That is a broken frame, not a kind from a newer + // daemon — classifying it as unrecognized would hide the only + // diagnostic a malformed `session_update` produces. A whitespace-only + // discriminator is truthy but no more usable than an empty one, so + // apply the same `trim()` convention `getFirstString` uses. + debugReason: kind?.trim() + ? 'unrecognized_session_update' + : 'malformed_payload', text: `${kind ?? 'session_update'}: ${stringifyRedactedJson(update)}`, }, ]; @@ -1135,6 +1147,7 @@ function normalizePermissionRequest( { ...base, type: 'debug', + debugReason: 'malformed_payload', text: `permission_request: ${stringifyRedactedJson(event.data)}`, }, ]; @@ -1146,6 +1159,7 @@ function normalizePermissionRequest( { ...base, type: 'debug', + debugReason: 'malformed_payload', text: `permission_request: ${stringifyRedactedJson(event.data)}`, }, ]; @@ -1179,6 +1193,7 @@ function normalizePermissionResolved( { ...base, type: 'debug', + debugReason: 'malformed_payload', text: `${event.type}: ${stringifyRedactedJson(event.data)}`, }, ]; @@ -1288,6 +1303,7 @@ function fallbackDebug( { ...base, type: 'debug', + debugReason: 'malformed_payload', text: `${event.type}: ${reason}`, }, ]; diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 887c0ca2e14..6a615916d49 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -1247,6 +1247,10 @@ function appendStatusBlock( event.data !== undefined ? { data: event.data } : {}), + ...((event?.type === 'status' || event?.type === 'debug') && + event.debugReason + ? { debugReason: event.debugReason } + : {}), ...(event?.type === 'session.branched' ? { source: 'session_branched', diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 44cf7dd23ee..5472701372a 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -273,11 +273,37 @@ export interface DaemonUiModelChangedEvent extends DaemonUiEventBase { modelId: string; } +/** + * Why the normalizer produced a `debug` projection instead of a typed event. + * + * `unrecognized_*` means the daemon sent a frame this normalizer has no case + * for — expected whenever the daemon runs ahead of the client, and the payload + * is developer diagnostics rather than conversation content. `malformed_*` + * means a frame the normalizer *does* know arrived with an unusable payload, + * which signals an actual defect. + * + * Renderers should branch on this instead of pattern-matching the debug text: + * client-dispatched debug events (e.g. Web Shell's model-switch summary) carry + * no `debugReason` at all and must keep rendering. + */ +export const DAEMON_UI_DEBUG_REASONS = [ + 'unrecognized_event', + 'unrecognized_session_update', + 'malformed_payload', +] as const; + +export type DaemonUiDebugReason = (typeof DAEMON_UI_DEBUG_REASONS)[number]; + export interface DaemonUiStatusEvent extends DaemonUiEventBase { type: 'status' | 'debug'; text: string; source?: string; data?: unknown; + /** + * Set only on normalizer-produced `debug` events. Absent on `status` events + * and on debug events dispatched by clients themselves. + */ + debugReason?: DaemonUiDebugReason; /** * Client-dispatch opt-out: `false` inserts the status block without * finalizing the active assistant/thought block, so read-only command @@ -914,6 +940,8 @@ export interface DaemonStatusTranscriptBlock extends DaemonTranscriptBlockBase { errorKind?: DaemonErrorKind; source?: string; data?: unknown; + /** Mirrors `DaemonUiStatusEvent.debugReason`; only set on `debug` blocks. */ + debugReason?: DaemonUiDebugReason; } export interface DaemonPromptCancelledTranscriptBlock 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 4daa77c3338..90dd843f4ee 100644 --- a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts +++ b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts @@ -134,10 +134,12 @@ import type { DaemonWorkspaceVoiceUpdate, KnownDaemonEvent, } from '../../src/index.js'; +import { DAEMON_UI_DEBUG_REASONS } from '../../src/daemon/index.js'; import type { DaemonChannelStartupAttemptFailure as DaemonEntryChannelStartupAttemptFailure, DaemonChannelStartupFailure as DaemonEntryChannelStartupFailure, DaemonChannelWorkerStartErrorResponse as DaemonEntryChannelWorkerStartErrorResponse, + DaemonUiDebugReason as DaemonEntryUiDebugReason, } from '../../src/daemon/index.js'; describe('public SDK entry — typed daemon event surface (#4217)', () => { @@ -468,3 +470,22 @@ describe('runtime MCP add/remove SDK types', () => { expect(res.removed).toBe(true); }); }); + +describe('daemon UI debug-reason public surface', () => { + it('pins the union shipped by @qwen-code/sdk/daemon', () => { + // A type-only guard would not hold here: vitest transpiles through + // esbuild, which erases `export type` without checking it, and this + // package's tsconfig excludes `test/`, so nothing type-checks this file. + // The union therefore ships as a closed enum value — matching + // DAEMON_ERROR_KINDS and friends — and the runtime assertion below is + // what actually fails if the re-export is dropped or the members drift. + expect(DAEMON_UI_DEBUG_REASONS).toEqual([ + 'unrecognized_event', + 'unrecognized_session_update', + 'malformed_payload', + ]); + expectTypeOf().toEqualTypeOf< + 'unrecognized_event' | 'unrecognized_session_update' | 'malformed_payload' + >(); + }); +}); diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 19f9ee9444c..93b10e7b977 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -2522,6 +2522,118 @@ describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => { expect(events).toEqual([]); }); + it('stamps debugReason on unrecognized daemon events', () => { + const events = normalizeDaemonEvent( + envelopeOf('some_future_event', { sessionId: 's1' }), + ); + + expect(events).toEqual([ + expect.objectContaining({ + type: 'debug', + debugReason: 'unrecognized_event', + }), + ]); + }); + + it('stamps debugReason on unrecognized session_update kinds', () => { + const events = normalizeDaemonEvent( + envelopeOf('session_update', { + update: { sessionUpdate: 'some_future_kind', payload: { a: 1 } }, + }), + ); + + expect(events).toEqual([ + expect.objectContaining({ + type: 'debug', + debugReason: 'unrecognized_session_update', + }), + ]); + }); + + it('classifies a session_update with no usable discriminator as malformed', () => { + // `getSessionUpdatePayload` accepts any record, so these reach the default + // branch with `kind === undefined`. They are broken frames, not kinds from + // a newer daemon — marking them unrecognized would let renderers hide the + // only diagnostic they produce. + for (const update of [ + {}, + { sessionUpdate: 42 }, + { sessionUpdate: '' }, + // Truthy but no more usable than an empty string. + { sessionUpdate: ' ' }, + ]) { + expect( + normalizeDaemonEvent(envelopeOf('session_update', { update })), + ).toEqual([ + expect.objectContaining({ + type: 'debug', + debugReason: 'malformed_payload', + }), + ]); + } + }); + + it('stamps debugReason on malformed payloads of known events', () => { + const events = normalizeDaemonEvent( + envelopeOf('memory_changed', { scope: 'not-a-scope' }), + ); + + expect(events).toEqual([ + expect.objectContaining({ + type: 'debug', + debugReason: 'malformed_payload', + }), + ]); + }); + + it('carries debugReason through the reducer onto the transcript block', () => { + // 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. + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + normalizeDaemonEvent( + envelopeOf('some_future_event', { sessionId: 's1' }), + ), + ); + + expect(state.blocks).toEqual([ + expect.objectContaining({ + kind: 'debug', + debugReason: 'unrecognized_event', + }), + ]); + }); + + 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 + // `appendStatusBlock` (e.g. `event.debugReason ?? 'unrecognized_event'`) + // passes every other test in both suites while silently tagging the + // summary as unrecognized, which Web Shell then filters out. + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { + type: 'debug', + text: 'Model switched to qwen3-coder-plus', + source: 'model_switch_summary', + }, + ], + ); + + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toEqual( + expect.objectContaining({ + kind: 'debug', + source: 'model_switch_summary', + }), + ); + expect(state.blocks[0]).not.toHaveProperty('debugReason'); + }); + it('normalizes memory_changed with closed-enum scope + mode', () => { const events = normalizeDaemonEvent( envelopeOf('memory_changed', { diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index bf993dc322c..83ee4addb67 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -1272,6 +1272,7 @@ describe('transcriptBlocksToDaemonMessages', () => { { id: 'debug-1', kind: 'debug', + debugReason: 'unrecognized_event', text: 'language_changed (unrecognized daemon event): ' + '{"sessionId":"dd699cc0-6ef7-4882-92d9-1076ac5b87e9",' + @@ -1285,6 +1286,273 @@ describe('transcriptBlocksToDaemonMessages', () => { expect(messages).toEqual([]); }); + it('filters legacy unrecognized-event blocks that carry no debugReason', () => { + // `WebShellTranscript` takes already-projected blocks from its caller, so + // blocks projected or persisted by an SDK older than `debugReason` still + // arrive with no reason. They must keep being filtered — and not only the + // two event types that used to be suppressed by name. + const legacy = (id: string, text: string) => + ({ + id, + kind: 'debug', + text, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }) as DaemonTranscriptBlock; + + const messages = transcriptBlocksToDaemonMessages([ + legacy( + 'legacy-1', + 'language_changed (unrecognized daemon event): {"language":"en"}', + ), + legacy( + 'legacy-2', + 'session_cwd_changed (unrecognized daemon event): {"cwd":"/work"}', + ), + legacy( + 'legacy-3', + 'some_future_event (unrecognized daemon event): {"a":1}', + ), + ]); + + expect(messages).toEqual([]); + }); + + it('filters unrecognized session_update kinds the daemon adds later', () => { + // The event kind here is deliberately one no normalizer case handles: the + // filter must key off `debugReason`, not a list of known-noisy prefixes. + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'debug-2', + kind: 'debug', + debugReason: 'unrecognized_session_update', + text: 'some_future_kind: {"payload":{"nested":"json"}}', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + } as DaemonTranscriptBlock, + ]); + + expect(messages).toEqual([]); + }); + + it('keys the filter off the unrecognized_ category prefix, not the enum', () => { + // A newer SDK may stamp reasons this build's `DaemonUiDebugReason` does + // not list; the category prefix is the contract. `unrecognized_*` noise + // hides, `malformed_*` defect signals keep rendering. + const block = (id: string, debugReason: string, text: string) => + ({ + id, + kind: 'debug', + debugReason, + text, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }) as DaemonTranscriptBlock; + + const messages = transcriptBlocksToDaemonMessages([ + block( + 'future-unrecognized', + 'unrecognized_tool_frame', + 'tool_frame: {"frameId":"f1"}', + ), + block( + 'future-malformed', + 'malformed_tool_frame', + 'tool_frame: broken frame payload', + ), + ]); + + expect(messages.map((m) => m.id)).toEqual(['future-malformed']); + }); + + it('keeps malformed-payload debug blocks visible', () => { + // A frame the client *does* know about arrived broken — that is a real + // defect signal, not forward-compatibility noise. + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'debug-3', + kind: 'debug', + debugReason: 'malformed_payload', + text: 'memory_changed: malformed memory_changed payload', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + } as DaemonTranscriptBlock, + ]); + + expect(messages).toEqual([ + { + id: 'debug-3', + role: 'system', + content: 'memory_changed: malformed memory_changed payload', + variant: 'info', + timestamp: 1, + }, + ]); + }); + + it('filters legacy usage_update and a2ui blocks that carry no debugReason', () => { + // The original spam report. #8790 stopped the SDK inserting new + // `usage_update` blocks, but a transcript persisted or projected before + // that still holds them, and `WebShellTranscript` renders whatever its + // caller passes — so without this the reported spam returns on upgrade. + const legacy = (id: string, text: string) => + ({ + id, + kind: 'debug', + text, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }) as DaemonTranscriptBlock; + + const messages = transcriptBlocksToDaemonMessages([ + legacy('legacy-usage', 'usage_update: {"used":46351,"size":1000000}'), + legacy('legacy-a2ui', 'a2ui: {"surfaceId":"s1","commands":[]}'), + ]); + + expect(messages).toEqual([]); + }); + + it('filters legacy projections whose payload is not an object', () => { + // `DaemonEvent.data` is `unknown`, and `stringifyJson` returns strings + // verbatim, primitives as `42` / `true` / `null`, and `''` for undefined. + // Keying the match on a leading `{` let all of those through. + const legacy = (id: string, payload: string) => + ({ + id, + kind: 'debug', + text: `some_future_event (unrecognized daemon event): ${payload}`, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }) as DaemonTranscriptBlock; + + const messages = transcriptBlocksToDaemonMessages([ + legacy('obj', '{"a":1}'), + legacy('arr', '[1,2]'), + legacy('str', 'plain text payload'), + legacy('num', '42'), + legacy('bool', 'true'), + legacy('null', 'null'), + legacy('empty', ''), + ]); + + expect(messages).toEqual([]); + }); + + it('only matches the legacy shape, never a quoted marker or a status block', () => { + // The text match is a compatibility shim, so it must be scoped to `debug` + // blocks and anchored to the whole projection. Matching the marker as a + // substring would hide any block that merely relays it. + const block = ( + id: string, + kind: string, + text: string, + extra: Record = {}, + ) => + ({ + id, + kind, + text, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + ...extra, + }) as DaemonTranscriptBlock; + + const messages = transcriptBlocksToDaemonMessages([ + // A status line is real content even when it quotes the marker. + block( + 'status-1', + 'status', + 'peer reported (unrecognized daemon event): malformed frame', + ), + // A legacy malformed payload relaying an upstream message. + block( + 'legacy-malformed', + 'debug', + 'permission_request: {"message":"peer said (unrecognized daemon event): x"}', + ), + // A client-dispatched summary that happens to quote it. + block( + 'client-1', + 'debug', + 'Model switch failed: upstream said (unrecognized daemon event): x', + { source: 'model_switch_summary' }, + ), + // A status block whose text starts with a suppressed session-update kind. + block('status-2', 'status', 'usage_update: {"used":1}'), + ]); + + expect(messages.map((m) => m.id)).toEqual([ + 'status-1', + 'legacy-malformed', + 'client-1', + 'status-2', + ]); + }); + + it('does not let the legacy prefixes swallow prose or classified blocks', () => { + // The prefix list is a compatibility shim for a specific projection + // shape, not a content filter: it must not hide a block the normalizer + // explicitly classified, nor text that merely starts with the word. + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'malformed-1', + kind: 'debug', + debugReason: 'malformed_payload', + text: 'usage_update: {"used":1}', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + } as DaemonTranscriptBlock, + { + id: 'prose-1', + kind: 'debug', + text: 'usage_update: rejected by the proxy', + clientReceivedAt: 2, + createdAt: 2, + updatedAt: 2, + } as DaemonTranscriptBlock, + ]); + + expect(messages.map((m) => m.id)).toEqual(['malformed-1', 'prose-1']); + }); + + it('keeps client-dispatched debug blocks that carry no debugReason', () => { + // Web Shell dispatches its own `debug` event for the model-switch summary. + // Only the normalizer stamps `debugReason`, so client-side debug blocks + // must not be swept up by the unrecognized-event filter. + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'debug-4', + kind: 'debug', + text: 'Model switched to qwen3-coder-plus', + source: 'model_switch_summary', + data: { modelId: 'qwen3-coder-plus' }, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + } as DaemonTranscriptBlock, + ]); + + expect(messages).toEqual([ + { + id: 'debug-4', + role: 'system', + content: 'Model switched to qwen3-coder-plus', + variant: 'info', + timestamp: 1, + source: 'model_switch_summary', + data: { modelId: 'qwen3-coder-plus' }, + }, + ]); + }); + it('filters SDK model switch status noise', () => { const messages = transcriptBlocksToDaemonMessages([ statusBlock('st1', 'Model switched: qwen3-coder-plus(openai)', 1), diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index f301bd03210..3b885dbadeb 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -109,10 +109,85 @@ function applyBackgroundAgentTaskUpdate( } function isIgnoredWebShellStatus(text: string): boolean { + // `model.changed` projects to a `status` block, not a `debug` one, so this + // stays text-keyed. The Web Shell renders its own richer model-switch + // summary (dispatched as a client-side `debug` event) instead. + return text.startsWith('Model switched: '); +} + +/** + * Whole shape of the legacy top-level projection — ` + * (unrecognized daemon event): ` — anchored at the start. Matching + * the marker anywhere in the text would hide any block that merely quotes it, + * such as a malformed payload carrying an upstream peer's message. + * + * The payload is deliberately unconstrained. `DaemonEvent.data` is `unknown` + * and `stringifyJson` returns strings verbatim, serializes primitives as + * `42` / `true` / `null`, and yields `''` for `undefined` — so keying on a + * leading `{` would let every non-object payload slip through. The event-type + * prefix plus the fixed phrase is specific enough on its own. + */ +const LEGACY_UNRECOGNIZED_EVENT_PATTERN = + /^[A-Za-z0-9_.-]+ \(unrecognized daemon event\): /; + +/** + * The legacy `session_update` projection is `: ` — no marker to + * key on, so those blocks can only be matched by kind name. Deliberately + * scoped to the kinds known to have leaked into transcripts before the + * normalizer suppressed them at the source: `usage_update` (#8790, the + * original spam report) and `a2ui`, whose command JSON the bridge splits out + * of the tool frame precisely to keep it out of transcripts. Anchored, and + * requiring the `: {` shape, so prose starting with the word still renders. + */ +const LEGACY_SUPPRESSED_SESSION_UPDATE_PREFIXES = [ + 'usage_update: {', + 'a2ui: {', +]; + +/** + * Daemon frames the normalizer had no case for are developer diagnostics — + * a raw JSON dump of an event this client does not understand. They routinely + * appear whenever the daemon ships a new event kind ahead of the UI, and + * rendering them drops unreadable JSON into the middle of the conversation. + * + * Keyed on the normalizer's `debugReason` rather than the block text. The + * SDK names reasons by category: `unrecognized_*` is forward-compat noise, + * hidden here by prefix so a reason a newer SDK adds is covered without a + * Web Shell change. Everything else deliberately stays visible — `malformed_*` + * means a frame this client *does* know arrived broken and is worth + * surfacing, and client-dispatched debug blocks (e.g. the model-switch + * summary) carry no `debugReason` at all. + * + * `WebShellTranscript` is a public entry point that takes already-projected + * blocks from its caller, so blocks projected — or persisted — by an SDK older + * than `debugReason` still arrive here with no reason at all. Those are + * matched by shape instead, and only ever by shape: text matching is a + * compatibility shim, so it is scoped to `debug` blocks (this helper is also + * called for `status`, which never carried these projections) and anchored to + * the whole projection, never a substring. A block that merely quotes a + * marker — a malformed payload relaying an upstream message, a + * client-dispatched summary, an ordinary status line — keeps rendering. + * + * Legacy `session_update` blocks have no marker, so they are matched by kind + * name instead — see the prefix list above. That list is closed on purpose: a + * generic `: {` rule would swallow legitimate diagnostics. An old block + * for some other unrecognized session-update kind therefore still renders; + * new projections carry the reason and are covered. + */ +function isUnrecognizedDaemonDebug( + block: DaemonStatusTranscriptBlock, +): boolean { + if (block.debugReason !== undefined) { + return block.debugReason.startsWith('unrecognized_'); + } + // Only `debug` blocks ever carried an unrecognized projection; a `status` + // block matching one of these shapes is real content. + if (block.kind !== 'debug') return false; return ( - text.startsWith('language_changed (unrecognized daemon event):') || - text.startsWith('session_cwd_changed (unrecognized daemon event):') || - text.startsWith('Model switched: ') + LEGACY_UNRECOGNIZED_EVENT_PATTERN.test(block.text) || + LEGACY_SUPPRESSED_SESSION_UPDATE_PREFIXES.some((prefix) => + block.text.startsWith(prefix), + ) ); } @@ -647,6 +722,7 @@ export function transcriptBlocksToDaemonMessages( case 'status': case 'debug': { const statusBlock = block; + if (isUnrecognizedDaemonDebug(statusBlock)) break; const branchDisplayName = statusBlock.source === 'session_branched' ? getSessionBranchDisplayName(statusBlock.data) @@ -673,10 +749,11 @@ export function transcriptBlocksToDaemonMessages( needsNewContentMessage = true; break; } - // Status/debug blocks are daemon-level diagnostics, not tool output. - // Keeping them in the main transcript avoids hiding global messages - // such as SSE lag warnings, malformed-event debug lines, or shell - // result notices inside whichever subAgent happened to be active. + // Status blocks and the debug blocks that survive the filter above are + // daemon-level diagnostics, not tool output. Keeping them in the main + // transcript avoids hiding global messages such as SSE lag warnings, + // malformed-event debug lines, or shell result notices inside + // whichever subAgent happened to be active. messages.push({ id: block.id, role: 'system', diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index 6244f13b6c6..9ef79ee02fe 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -641,16 +641,8 @@ function isHideableStep(item: DisplayItem, isFinalAnswer: boolean): boolean { } } -function isMidTurnInjectedDebugMessage(message: { - content?: string; - source?: string; -}): boolean { - return ( - message.source === 'mid_turn_message_injected' || - message.content?.startsWith( - 'mid_turn_message_injected (unrecognized daemon event):', - ) === true - ); +function isMidTurnInjectedDebugMessage(message: { source?: string }): boolean { + return message.source === 'mid_turn_message_injected'; } export function getTurnTimelineNode(