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.

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] R3-6 (1 of 2): The new DaemonUiDebugReason type re-export here is ungated by any runtime test — test-efficacy probe (harness validated): reverting this hunk alone leaves every test green, because the only test reference is import type + expectTypeOf() in daemon-public-surface.test.ts, which vitest transpiles away without type-checking. The sibling value export DAEMON_UI_DEBUG_REASONS IS runtime-pinned by that test (its hunks were killed by the probe); the type-only export is not. — Failure scenario: a future barrel reshuffle drops this re-export → the whole runtime suite stays green → SDK consumers doing import type { DaemonUiDebugReason } from this entry break at compile time only after release, caught only if a typecheck runs over the consumer.

Suggested fix: accept the workspace typecheck as the gate for type-only exports (it covers them today), or note in daemon-public-surface.test.ts that the expectTypeOf half is enforced by npm run typecheck, not by the suite, so a future editor does not assume the test pins both halves.

中文说明

[建议] R3-6(共 2 处,第 1 处):此处新增的 DaemonUiDebugReason 类型再导出没有任何运行时测试把关——测试效力探针(harness 已验证)显示:单独回退这个 hunk 后所有测试仍为绿色,因为唯一的测试引用是 daemon-public-surface.test.ts 里的 import type + expectTypeOf(),vitest 转译时会将其擦除而不做类型检查。同层的值导出 DAEMON_UI_DEBUG_REASONS 被该测试在运行时固定(其 hunk 被探针杀死);类型导出则没有。— 失败场景:未来某次 barrel 重组删掉了这个再导出 → 整个运行时套件仍然全绿 → 通过该入口 import type { DaemonUiDebugReason } 的 SDK 使用者只在发布后才会在编译期报错,且只有对使用方运行 typecheck 才能发现。

建议修复:接受 workspace 的 typecheck 作为类型导出的把关(目前确实覆盖),或在 daemon-public-surface.test.ts 中注明 expectTypeOf 那一半由 npm run typecheck 而非测试套件强制执行,避免未来的编辑者误以为测试同时固定了两半。

— qwen3.8-max via Qwen Code /review (v0.21.8)

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,
Comment on lines 85 to 87

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] R3-6 (2 of 2): Same gap on this barrel: the DaemonUiDebugReason type re-export is ungated by any runtime test — reverting this hunk alone left every test green in the test-efficacy probe (harness validated), since the only reference is import type + expectTypeOf(), which vitest never type-checks. — Failure scenario: a future barrel reshuffle drops this re-export → the whole runtime suite stays green → SDK consumers doing import type { DaemonUiDebugReason } from @qwen-code/sdk/daemon/ui break at compile time only after release.

Suggested fix: same as the daemon/index.ts comment — accept the workspace typecheck as the gate, or document in the surface test that its expectTypeOf half is enforced by npm run typecheck, not by the suite.

中文说明

[建议] R3-6(共 2 处,第 2 处):这个 barrel 上存在同样的缺口:DaemonUiDebugReason 类型再导出没有任何运行时测试把关——测试效力探针(harness 已验证)中单独回退这个 hunk 后所有测试仍为绿色,因为唯一引用是 import type + expectTypeOf(),vitest 从不做类型检查。— 失败场景:未来某次 barrel 重组删掉这个再导出 → 整个运行时套件仍然全绿 → 通过 @qwen-code/sdk/daemon/uiimport type { DaemonUiDebugReason } 的 SDK 使用者只在发布后才会在编译期报错。

建议修复:与 daemon/index.ts 上的评论相同——接受 workspace 的 typecheck 作为把关,或在 surface 测试中注明其 expectTypeOf 那一半由 npm run typecheck 而非测试套件强制执行。

— qwen3.8-max via Qwen Code /review (v0.21.8)

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.
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
Comment on lines +851 to +853

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] R3-2: This comment says kind is undefined for a payload whose discriminator is "missing, empty or not a string", but getString returns '' for an empty string — only a missing or non-string discriminator yields undefined. Runtime classification is correct in every case (''?.trim() is falsy → malformed_payload, pinned by the { sessionUpdate: '' } test); only the explanation is wrong. — Failure scenario: a maintainer tracing a malformed block whose projected text starts with ": {…}" (the ${kind ?? 'session_update'} fallback does NOT fire because '' is not nullish) is told here that kind should be undefined there, and the rationale paragraph built on that mechanism misleads the diagnosis.

Suggested fix: reword to "…so kind is undefined when the discriminator is missing or not a string, and '' when it is empty. Either way the frame is broken…" — the trim() sentence that follows already covers the whitespace-only case correctly.

中文说明

[建议] R3-2:该注释称当 payload 的判别字段「缺失、为空或不是字符串」时 kindundefined,但 getString 对空字符串返回的是 ''——只有缺失或非字符串的判别字段才会得到 undefined。运行时分类在所有情况下都是正确的(''?.trim() 为假 → malformed_payload{ sessionUpdate: '' } 测试已固定该行为);只有解释文字是错的。— 失败场景:维护者追查一个投影文本以 ": {…}" 开头的 malformed 区块时(由于 '' 不是 nullish,${kind ?? 'session_update'} 兜底不会触发),会被这段注释告知此处 kind 应为 undefined,而基于该机制构建的理由段落会把诊断引偏。

建议修复:改写为「……因此当判别字段缺失或不是字符串时 kindundefined,为空时则为 ''。无论哪种情况帧都是坏的……」——后面的 trim() 一句已经正确覆盖了纯空白的情形。

— qwen3.8-max via Qwen Code /review (v0.21.8)

// 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)}`,
Comment on lines 1195 to 1197

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] R3-3: Four of the six malformed_payload stamping sites have no test pinning the reason: normalizeSessionUpdate's !update branch (~686), both normalizePermissionRequest malformed branches (~1150, ~1162), and normalizePermissionResolved (anchored here). Mutation-verified twice during this review: flipping all four to debugReason: 'unrecognized_event' leaves the SDK suites (304 tests) and the Web Shell adapter suite (121 tests) fully green. — Failure scenario: a future edit flips one of these reasons → Web Shell silently hides the defect-signal blocks for broken permission_request / permission_resolved / session_update envelopes → this PR's documented "malformed_payload stays visible" guarantee breaks with no test failing.

Suggested fix: extend the existing 'stamps debugReason on malformed payloads of known events' test to also cover a permission_request with non-record data and one missing requestId, a permission_resolved missing requestId, and a session_update envelope with no usable update field, each asserting debugReason: 'malformed_payload'.

中文说明

[建议] R3-3:6 个 malformed_payload 打点位置中有 4 个没有测试固定其 reason:normalizeSessionUpdate!update 分支(约 686 行)、normalizePermissionRequest 的两个 malformed 分支(约 1150、1162 行)、以及 normalizePermissionResolved(锚点所在位置)。本次评审中两次变异验证:把这 4 处全部翻转为 debugReason: 'unrecognized_event' 后,SDK 套件(304 个测试)与 Web Shell 适配器套件(121 个测试)仍然全绿。— 失败场景:未来某次改动翻转其中一处的 reason → Web Shell 悄悄隐藏损坏的 permission_request / permission_resolved / session_update 信封所产生的缺陷信号区块 → 本 PR 文档承诺的「malformed_payload 保持可见」被破坏,却没有任何测试失败。

建议修复:扩充现有的 'stamps debugReason on malformed payloads of known events' 测试,增加:data 非 record 的 permission_request、缺 requestIdpermission_request、缺 requestIdpermission_resolved、以及没有可用 update 字段的 session_update 信封,各自断言 debugReason: 'malformed_payload'

— qwen3.8-max via Qwen Code /review (v0.21.8)

},
];
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.
: {}),
...(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;
Comment on lines +943 to +944

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] R2-2: The new renderer-facing closed enum debugReason / DAEMON_UI_DEBUG_REASONS is undocumented in docs/developers/daemon-ui/README.md, where its sibling closed enums are documented ("Error categorization (PR-A)" for errorKind — "Renderers should branch on errorKind" — and "Tool provenance dispatch"). The doc's "Forward-compat principles" bullet still says "Unknown daemon event types → debug event with the raw type name", with no mention that these projections now carry a debugReason renderers must branch on; MIGRATION.md documents DaemonErrorKind for renderer authors and is likewise untouched. — Concrete cost: an adapter author following that doc either renders the raw-JSON spam this PR exists to remove, or pattern-matches the text prefix — the exact brittle coupling the new normalizer comment forbids ("the text prefix is diagnostic wording and changes without notice") — so their filter silently breaks the next time the wording changes. Suggested fix: add a short section beside "Error categorization" documenting DaemonUiDebugReason (the three values, the unrecognized-vs-malformed semantics, and "branch on debugReason, not the text prefix"), and extend the "Unknown daemon event types" bullet to note the stamp.

中文说明

[建议] 新的面向渲染器的封闭枚举 debugReason / DAEMON_UI_DEBUG_REASONS 未在 docs/developers/daemon-ui/README.md 中记录,而该文档正是记录其同类封闭枚举的地方(errorKind 的 "Error categorization (PR-A)"——"Renderers should branch on errorKind"——以及 "Tool provenance dispatch")。文档中 "Forward-compat principles" 一条仍写着 "Unknown daemon event types → debug event with the raw type name",没有提到这些投影现在携带渲染器必须据以分支的 debugReasonMIGRATION.md 为渲染器作者记录了 DaemonErrorKind,同样未被更新。— 具体代价:按该文档实现的适配器作者要么渲染出本 PR 要消除的原始 JSON 刷屏,要么去匹配文本前缀——正是新 normalizer 注释所禁止的脆弱耦合("文本前缀是诊断措辞,随时可能变化,恕不通知")——他们的过滤器会在下次措辞变化时悄悄失效。建议修复:在 "Error categorization" 旁增加一小节,记录 DaemonUiDebugReason(三个取值、unrecognized 与 malformed 的语义,以及"基于 debugReason 分支,而不是文本前缀"),并扩充 "Unknown daemon event types" 条目说明该标记。

— qwen3.8-max via Qwen Code /review (v0.21.8)

}

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

Choose a reason for hiding this comment

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

[Suggestion] This round-trip test pins only the positive direction of the contract (normalizer event → block carries debugReason). The mirror invariant — a client-dispatched debug event must produce a block with no debugReason, which is exactly what keeps Web Shell's model-switch summary visible — is tested nowhere: the Web Shell tests construct blocks by hand and never route a client dispatch through appendStatusBlock. Probe-verified mutant: changing the spread in appendStatusBlock (transcript.ts) to debugReason: event.debugReason ?? 'unrecognized_event' survives all 409 tests in both suites (sdk-typescript daemon-UI + web-shell adapter), while the model-switch summary block would then carry unrecognized_event and be filtered out of the transcript. — Failure scenario: a future edit that defaults debugReason on debug events passes every test in this diff, tags the model-switch summary block unrecognized_event, and isUnrecognizedDaemonDebug silently removes the summary from the Web Shell transcript with both suites green.

Suggested companion test next to this one (the dispatch shape mirrors App.tsx):

it('keeps client-dispatched debug blocks free of debugReason', () => {
  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' }));
  expect(state.blocks[0]).not.toHaveProperty('debugReason');
});
中文说明

这个往返测试只固定了契约的正向(normalizer 事件 → 块携带 debugReason)。镜像不变量——客户端派发的 debug 事件必须产生不带 debugReason 的块,而这正是 Web Shell 模型切换摘要保持可见的原因——没有任何测试覆盖:Web Shell 的测试手工构造块,从不把客户端派发经过 appendStatusBlock。变异探针已验证:把 appendStatusBlock(transcript.ts)中的展开改成 debugReason: event.debugReason ?? 'unrecognized_event' 后,两个套件共 409 个测试全部通过,而模型切换摘要块会被打上 unrecognized_event 并被过滤出 transcript。——失败场景:未来某次编辑给 debug 事件的 debugReason 加上默认值,本 diff 的所有测试仍然通过,摘要块被打上 unrecognized_event,isUnrecognizedDaemonDebug 在两个套件全绿的情况下把摘要从 Web Shell transcript 静默移除。

— qwen3.8-max via Qwen Code /review (v0.21.8)

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