Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 36 additions & 1 deletion docs/developers/daemon-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,12 +367,47 @@ function toolIcon(event: DaemonUiToolUpdateEvent): React.ReactNode {
The SDK has a `mcp__<server>__<tool>` 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`
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk-typescript/src/daemon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export {
createDaemonTranscriptStore,
DAEMON_GOAL_STATUS_SENTINEL_PREFIX,
DAEMON_PLAN_TOOL_CALL_ID,
DAEMON_UI_DEBUG_REASONS,
daemonBlockToHtml,
daemonBlockToMarkdown,
daemonBlockToPlainText,
Expand Down Expand Up @@ -156,6 +157,7 @@ export type {
DaemonUiAuthDeviceFlowFailedEvent,
DaemonUiAuthDeviceFlowStartedEvent,
DaemonUiAuthDeviceFlowThrottledEvent,
DaemonUiDebugReason,
DaemonUiErrorEvent,
Comment on lines 159 to 161

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] DaemonUiDebugReason is a new public type of @qwen-code/sdk/daemon, but nothing gates that public surface: no test imports it through this barrel, so if the re-export were dropped or the type renamed without updating the barrel, every suite stays green (the only in-repo referents are types.ts itself and the two re-export lines, and an esbuild-stripped export type can never fail at runtime) while external consumers lose the documented union type — the first signal would be their tsc error after upgrade. — Failure scenario: a future refactor drops this re-export → all builds and tests pass → the advertised public API silently shrinks until a consumer's compile breaks. (Note: the inner daemon/ui barrel IS transitively guarded — this outer barrel re-exports from ./ui/index.js, so deleting the inner line fails the sdk build; only the outermost export needs an explicit guard.)

// In an existing sdk test, import through the public barrel and pin the surface:
import type { DaemonUiDebugReason } from '../../src/daemon/index.js';
expectTypeOf<DaemonUiDebugReason>().toEqualTypeOf<
  'unrecognized_event' | 'unrecognized_session_update' | 'malformed_payload'
>();
中文说明

DaemonUiDebugReason 是 @qwen-code/sdk/daemon 新增的公开类型,但这一公开面无任何守护:没有任何测试经公共 barrel 导入它,因此若某次重构丢失该再导出、或类型改名后忘了同步,所有构建与套件依旧全绿(仓内只有 types.ts 与两行再导出引用它,而 export type 会被 esbuild 擦除、运行时永不报错),外部消费者则无声地失去文档承诺的联合类型——第一个信号是他们升级后自己的 tsc 报错。——失败场景:未来的重构删掉了这一行再导出,构建测试全部通过,公开 API 静默缩水,直到消费者的编译中断。(说明:内层 daemon/ui barrel 其实已被传递守护——外层 barrel 从 ./ui/index.js 再导出,删除内层那行会让 sdk 构建直接报错;真正需要显式守护的只有这个最外层导出。)

— Kimi-K3 via Qwen Code /review (v0.21.6)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and the suggested guard turned out not to hold — worth flagging since it affects other type-only guards in this package.

I implemented it as written first, then mutation-checked by deleting the re-export from src/daemon/index.ts. Both vitest run (14 passed) and npm -w packages/sdk-typescript run typecheck stayed green. Two reasons: vitest transpiles through esbuild, which erases export type without checking it, and this package's tsconfig is include: ["src/**/*.ts"] with exclude: [..., "test"], so nothing type-checks the test file at all. expectTypeOf alone cannot fence this surface here.

So in bc32742 the union ships as a closed enum value instead, matching DAEMON_ERROR_KINDS / DAEMON_APPROVAL_MODES:

export const DAEMON_UI_DEBUG_REASONS = [
  "unrecognized_event",
  "unrecognized_session_update",
  "malformed_payload",
] as const;
export type DaemonUiDebugReason = (typeof DAEMON_UI_DEBUG_REASONS)[number];

Re-exported as a value through both barrels, with a runtime toEqual assertion next to the expectTypeOf you suggested. Mutation-checked: dropping the outer re-export now fails that test.

DaemonUiEvent,
DaemonUiEventBase,
Expand Down
3 changes: 2 additions & 1 deletion packages/sdk-typescript/src/daemon/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -83,6 +83,7 @@ export type {
DaemonTranscriptStore,
// Chat-stream events
DaemonUiAssistantDoneEvent,
DaemonUiDebugReason,
DaemonUiErrorEvent,
DaemonUiEvent,
DaemonUiEventBase,
Expand Down
22 changes: 19 additions & 3 deletions packages/sdk-typescript/src/daemon/ui/normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand All @@ -401,6 +401,7 @@ function normalizeUnrecognizedEvent(
{
...base,
type: 'debug',
debugReason: 'unrecognized_event',
Comment thread
carffuca marked this conversation as resolved.
Resolved
text: `${event.type} (unrecognized daemon event): ${stringifyRedactedJson(event.data)}`,
},
];
Expand Down Expand Up @@ -682,6 +683,7 @@ function normalizeSessionUpdate(
{
...base,
type: 'debug',
debugReason: 'malformed_payload',
text: `session_update: ${stringifyRedactedJson(event.data)}`,
},
];
Expand Down Expand Up @@ -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)}`,
},
];
Expand Down Expand Up @@ -1135,6 +1147,7 @@ function normalizePermissionRequest(
{
...base,
type: 'debug',
debugReason: 'malformed_payload',
text: `permission_request: ${stringifyRedactedJson(event.data)}`,
},
];
Expand All @@ -1146,6 +1159,7 @@ function normalizePermissionRequest(
{
...base,
type: 'debug',
debugReason: 'malformed_payload',
text: `permission_request: ${stringifyRedactedJson(event.data)}`,
},
];
Expand Down Expand Up @@ -1179,6 +1193,7 @@ function normalizePermissionResolved(
{
...base,
type: 'debug',
debugReason: 'malformed_payload',
text: `${event.type}: ${stringifyRedactedJson(event.data)}`,
},
];
Expand Down Expand Up @@ -1288,6 +1303,7 @@ function fallbackDebug(
{
...base,
type: 'debug',
debugReason: 'malformed_payload',
text: `${event.type}: ${reason}`,
},
];
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk-typescript/src/daemon/ui/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1247,6 +1247,10 @@ function appendStatusBlock(
event.data !== undefined
? { data: event.data }
: {}),
...((event?.type === 'status' || event?.type === 'debug') &&
event.debugReason
? { debugReason: event.debugReason }
Comment thread
carffuca marked this conversation as resolved.
Resolved
: {}),
...(event?.type === 'session.branched'
? {
source: 'session_branched',
Expand Down
28 changes: 28 additions & 0 deletions packages/sdk-typescript/src/daemon/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions packages/sdk-typescript/test/unit/daemon-public-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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<DaemonEntryUiDebugReason>().toEqualTypeOf<
'unrecognized_event' | 'unrecognized_session_update' | 'malformed_payload'
>();
});
});
112 changes: 112 additions & 0 deletions packages/sdk-typescript/test/unit/daemonUi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),
]);
Comment on lines +2602 to +2607

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in bc32742, and I reproduced your mutant first: debugReason: event.debugReason ?? "unrecognized_event" in appendStatusBlock did survive both suites. The new test dispatches the model-switch shape through the reducer and asserts the block has source: "model_switch_summary" and no debugReason property at all; with the mutant applied it is the only failure.

});

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', {
Expand Down
Loading
Loading