-
Notifications
You must be signed in to change notification settings - Fork 3.1k
fix(web-shell): stop rendering unrecognized daemon events in transcripts #8812
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
defbe4f
2736e7f
bc32742
df0b757
98bc115
c21818d
c9224e5
8c9dbb0
1d8a5d7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Comment on lines
159
to
161
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R3-6 (1 of 2): The new Suggested fix: accept the workspace typecheck as the gate for type-only exports (it covers them today), or note in 中文说明[建议] R3-6(共 2 处,第 1 处):此处新增的 建议修复:接受 workspace 的 typecheck 作为类型导出的把关(目前确实覆盖),或在 — qwen3.8-max via Qwen Code /review (v0.21.8) |
||
| DaemonUiEvent, | ||
| DaemonUiEventBase, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Comment on lines
85
to
87
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R3-6 (2 of 2): Same gap on this barrel: the Suggested fix: same as the 中文说明[建议] R3-6(共 2 处,第 2 处):这个 barrel 上存在同样的缺口: 建议修复:与 — qwen3.8-max via Qwen Code /review (v0.21.8) |
||
| DaemonUiEvent, | ||
| DaemonUiEventBase, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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', | ||
|
carffuca marked this conversation as resolved.
|
||
| 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 | ||
|
Comment on lines
+851
to
+853
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R3-2: This comment says Suggested fix: reword to "…so 中文说明[建议] R3-2:该注释称当 payload 的判别字段「缺失、为空或不是字符串」时 建议修复:改写为「……因此当判别字段缺失或不是字符串时 — 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)}`, | ||
| }, | ||
| ]; | ||
|
|
@@ -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)}`, | ||
|
Comment on lines
1195
to
1197
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R3-3: Four of the six Suggested fix: extend the existing 中文说明[建议] R3-3:6 个 建议修复:扩充现有的 — qwen3.8-max via Qwen Code /review (v0.21.8) |
||
| }, | ||
| ]; | ||
|
|
@@ -1288,6 +1303,7 @@ function fallbackDebug( | |
| { | ||
| ...base, | ||
| type: 'debug', | ||
| debugReason: 'malformed_payload', | ||
| text: `${event.type}: ${reason}`, | ||
| }, | ||
| ]; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Comment on lines
+943
to
+944
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R2-2: The new renderer-facing closed enum 中文说明[建议] 新的面向渲染器的封闭枚举 — qwen3.8-max via Qwen Code /review (v0.21.8) |
||
| } | ||
|
|
||
| export interface DaemonPromptCancelledTranscriptBlock | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Suggested companion test next to this one (the dispatch shape mirrors 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 事件 → 块携带 — qwen3.8-max via Qwen Code /review (v0.21.8)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in bc32742, and I reproduced your mutant first: |
||
| }); | ||
|
|
||
| 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', { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion]
DaemonUiDebugReasonis 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 aretypes.tsitself and the two re-export lines, and an esbuild-strippedexport typecan never fail at runtime) while external consumers lose the documented union type — the first signal would be theirtscerror 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 innerdaemon/uibarrel 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.)中文说明
DaemonUiDebugReason是@qwen-code/sdk/daemon新增的公开类型,但这一公开面无任何守护:没有任何测试经公共 barrel 导入它,因此若某次重构丢失该再导出、或类型改名后忘了同步,所有构建与套件依旧全绿(仓内只有types.ts与两行再导出引用它,而export type会被 esbuild 擦除、运行时永不报错),外部消费者则无声地失去文档承诺的联合类型——第一个信号是他们升级后自己的tsc报错。——失败场景:未来的重构删掉了这一行再导出,构建测试全部通过,公开 API 静默缩水,直到消费者的编译中断。(说明:内层daemon/uibarrel 其实已被传递守护——外层 barrel 从./ui/index.js再导出,删除内层那行会让 sdk 构建直接报错;真正需要显式守护的只有这个最外层导出。)— Kimi-K3 via Qwen Code /review (v0.21.6)
There was a problem hiding this comment.
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. Bothvitest run(14 passed) andnpm -w packages/sdk-typescript run typecheckstayed green. Two reasons: vitest transpiles through esbuild, which erasesexport typewithout checking it, and this package's tsconfig isinclude: ["src/**/*.ts"]withexclude: [..., "test"], so nothing type-checks the test file at all.expectTypeOfalone cannot fence this surface here.So in bc32742 the union ships as a closed enum value instead, matching
DAEMON_ERROR_KINDS/DAEMON_APPROVAL_MODES:Re-exported as a value through both barrels, with a runtime
toEqualassertion next to theexpectTypeOfyou suggested. Mutation-checked: dropping the outer re-export now fails that test.