fix(web-shell): stop rendering unrecognized daemon events in transcripts - #8812
Conversation
The daemon UI normalizer projects any frame it has no case for into a `debug` event carrying a raw JSON dump. webui's ChatViewer drops those blocks, but Web Shell renders `status` and `debug` together as system info, so every event kind the daemon ships ahead of the UI surfaces as unreadable JSON in the middle of the conversation. This has been patched per-symptom three times now: two string-prefix suppressions inside `isIgnoredWebShellStatus`, plus #8790 for `usage_update`. Give the normalizer's debug events a structured `debugReason` and let Web Shell branch on it instead of pattern-matching text: - `unrecognized_event` / `unrecognized_session_update` — the daemon runs ahead of this client; developer diagnostics, not conversation content. Web Shell no longer renders them. - `malformed_payload` — a frame the client does know arrived unusable. That is a real defect signal, so it stays visible. Debug events dispatched by clients themselves, such as Web Shell's own model-switch summary, carry no `debugReason` and keep rendering. The two `(unrecognized daemon event)` prefix checks are now covered by `debugReason` and are removed; the `Model switched: ` check stays, since `model.changed` projects to a `status` block rather than a debug one.
🖼️ web-shell visual previewRendered against a mock daemon (no real backend): the PR base vs this PR head Screenshots · before / afterℹ️ No screenshot changed against the PR base — but this PR edits 1 render-shaping file:
Either the change has no visual effect (logic, plumbing, a state the scenarios never reach), or no scenario renders this UI — in which case the preview cannot see it, and an empty result is a coverage gap rather than a clean bill of health. To make it visible, add a scenario to Full-resolution recordings (.webm) are attached to the workflow run. — Qwen Code · web-shell visuals |
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
Review of #8812 caught a hole in the new classification: `session_update` payloads such as `{}` or `{ sessionUpdate: 42 }` reach the default branch with `kind === undefined`, and stamping them `unrecognized_session_update` made Web Shell hide the only diagnostic a malformed frame produces. Reserve the unrecognized reason for a real unknown string kind. Also update the top-level default-case comment, which still pointed adapters at the debug text prefix, and add a reducer-level test proving `debugReason` survives the UI-event → transcript-block boundary: the normalizer tests inspect events and the Web Shell tests build blocks by hand, so dropping the spread in transcript.ts would leave both green.
|
Re-reviewed commit |
|
@qwen-code /triage |
|
Sandboxed verification: The verification run did not complete, so the phases below may be partial or missing entirely. Advisory evidence for human reviewers — not a review, an approval, or a CI check. 中文 — 判定:
|
|
Thanks for the PR! Re-run gate pass at the current head — the review-round fix commits and the merge of Template looks good ✓ Problem: observed, not theoretical. The normalizer projects any daemon frame it has no case for into a Direction: aligned. Web Shell is the main entry point for daemon-backed sessions, and this fixes the leak class instead of adding a fourth per-kind filter; #8808 was closed in favour of it. The SDK change is additive only (an optional Size: cross-package (sdk-typescript + web-shell) but small — ≈162 production lines (141+/21−), 401 test lines, 37 doc lines. No size concerns; the growth since the first pass is the review rounds' legacy-compat shim and its tests, which earn their place. Approach: right-sized. The three-way split is the minimum that stops the leak class without hiding real signals: Risk: no high-risk paths matched. The design fails open: any debug block without a Moving on to code review. 🔍 中文说明感谢贡献! 在当前 head 上的复审 gate——review 轮次的修复提交与 main 合并不改变任何 gate 输入,此前结论仍然成立,仅按当前 diff 更新数据。 模板完整 ✓ **问题:**已观测到的 bug,不是理论问题。normalizer 会把任何没有对应分支的 daemon 帧投影成带原始 JSON 的 **方向:**对齐。Web Shell 是 daemon 会话的主入口,本 PR 修的是整类泄漏而不是再加第四个按类型的过滤器;#8808 已因此关闭。SDK 改动纯增量( **规模:**跨包(sdk-typescript + web-shell)但很小——约 162 行生产代码(141+/21−)、401 行测试、37 行文档。无规模顾虑;相比首轮增加的部分是 review 轮次要求的向后兼容 shim 及其测试,值得保留。 **方案:**范围恰当。三分法是挡住整类泄漏、同时不隐藏真实信号的最小方案: **风险:**未命中高风险路径。设计是 fail-open 的:任何没有 进入代码审查 🔍 — Qwen Code · qwen3.8-max Reviewed at |
carffuca
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
中文说明
已审查——无阻断问题。 建议见行内评论。
— Kimi-K3 via Qwen Code /review (v0.21.6)
| DaemonUiAuthDeviceFlowThrottledEvent, | ||
| DaemonUiDebugReason, | ||
| DaemonUiErrorEvent, |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline. Not reviewed: reverse audit — stopped before round 2 by the review time budget.
中文说明
已审查。 建议见行内评论。 未审查:反向审计——评审时间预算不足,未能开始第 2 轮。
— qwen3.8-max via Qwen Code /review (v0.21.8)
| debugReason: kind | ||
| ? 'unrecognized_session_update' | ||
| : 'malformed_payload', |
There was a problem hiding this comment.
[Suggestion] The kind ? … : … split treats any truthy string discriminator as a forward-compatible unknown kind — but a whitespace-only discriminator (sessionUpdate: ' ') is truthy, so such a broken frame is classified unrecognized_session_update and hidden by Web Shell, defeating the invariant the comment above states: a broken frame is not a kind from a newer daemon, and must stay visible as malformed_payload. The same utils file already encodes the stricter convention — getFirstString treats whitespace-only strings as missing via entry.trim().length > 0. Probe-verified at the reviewed commit: sessionUpdate: ' ' currently yields unrecognized_session_update; gating on trim() flips it to malformed_payload with all of this PR's tests still green. — Failure scenario: a buggy daemon, older ACP peer, or proxying layer emits session_update with sessionUpdate: ' ' → classified unrecognized_session_update → Web Shell drops the block → the only diagnostic the malformed frame produces silently disappears from the transcript.
| debugReason: kind | |
| ? 'unrecognized_session_update' | |
| : 'malformed_payload', | |
| debugReason: kind && kind.trim() !== '' | |
| ? 'unrecognized_session_update' | |
| : 'malformed_payload', |
中文说明
kind ? … : … 分支把任何为真值(truthy)的字符串判别符都视为来自更新版 daemon 的前向兼容未知类型——但纯空白判别符(sessionUpdate: ' ')同样是真值,这种损坏帧因此被归为 unrecognized_session_update 并被 Web Shell 隐藏,违背上方注释声明的不变量:损坏帧不是来自更新版 daemon 的类型,必须以 malformed_payload 保持可见。同一 utils 文件已有更严格的惯例——getFirstString 用 entry.trim().length > 0 将纯空白字符串视为缺失。已在被审提交上用探针验证:sessionUpdate: ' ' 当前产生 unrecognized_session_update;改为 trim() 判断后翻转为 malformed_payload,且本 PR 全部测试依旧通过。——失败场景:有缺陷的 daemon、旧版 ACP 对端或中间代理发出 sessionUpdate: ' ' 的 session_update → 被归类为 unrecognized_session_update → Web Shell 丢弃该块 → 该损坏帧唯一的诊断信息从 transcript 中静默消失。
— qwen3.8-max via Qwen Code /review (v0.21.8)
There was a problem hiding this comment.
Fixed in bc32742 — gated on kind?.trim(), matching the getFirstString convention you pointed at. Added { sessionUpdate: " " } to the malformed-discriminator cases; reverting to the bare kind check fails it.
| expect(state.blocks).toEqual([ | ||
| expect.objectContaining({ | ||
| kind: 'debug', | ||
| debugReason: 'unrecognized_event', | ||
| }), | ||
| ]); |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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.
|
Qwen Code review timed out. Qwen review timed out after 10800 seconds (of the 180-minute budget). For large PRs, retry with a longer timeout by commenting: |
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 25 passed · 0 failed · 25 total 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:25 通过 · 0 失败 · 25 总计 Verification reportPR 8812 Deep Verification —
|
| # | Input frame | Base rendered | Head rendered |
|---|---|---|---|
| 1 | agent_message_chunk "Surface presented." |
assistant msg ✓ | assistant msg ✓ |
| 2 | session_update kind a2ui (the motivating shape) |
raw JSON row leaks | filtered — unrecognized_session_update |
| 3 | unknown top-level some_future_event |
raw JSON row leaks | filtered — unrecognized_event |
| 4 | language_changed |
filtered (text prefix) | filtered (debugReason) — parity |
| 5 | session_cwd_changed |
filtered (text prefix) | filtered (debugReason) — parity |
| 6 | session_update with update: {} (no discriminator) |
visible | visible — malformed_payload |
| 7 | memory_changed with bad scope |
visible | visible — malformed_payload |
| 8 | status Model switched: qwen3-coder-plus(openai) |
filtered (kept text check) | filtered (kept text check) |
| 9 | client-dispatched debug model_switch_summary |
visible | visible |
Counts: system messages 5 (base) → 3 (head); raw-JSON rows 2 → 0; kept diagnostics 3 = 3. Base control asserts the JSON rows ARE rendered (they are), so the flip is proven, not assumed. See evidence/01-ab-head-filtered.png and evidence/02-ab-base-json-leak.png for the two cells as printed.
Classification boundary cells (head): 'a2ui'/'some_future_kind' → unrecognized_session_update; null/42/''/missing discriminator → malformed_payload; whitespace-only ' ' → unrecognized_session_update (truthy string — a string kind this client doesn't know, defensible under the PR's own taxonomy). Base prints (debug, no reason) for all eight — the field simply doesn't exist there.
Suppression observability (checked per method)
The filter is render-layer only: a census run shows all three debug blocks survive in transcript state with text + debugReason intact (3 blocks in state, 1 message rendered). Raw frames are also untouched on the wire (the normalizer is a pure projection; no serve route is in the diff). So "which frame was this" remains recoverable — with one description caveat below.
Corrections
- Correction to the PR description (Risk & Scope): "
rawEventstill carries the original envelope for debug panels" holds only when normalization runs withincludeRawEvent: true. The normalizer stampsrawEventconditionally (normalizer.tscreateBase), and the Web Shell path'sDaemonSessionProviderdefaultsincludeRawEvent = false. In a default session, observability instead rests on the debug block's JSON text persisting in transcript state (census-verified) and the raw frame remaining on the SSE event stream. No code change requested — the PR's behavior is correct; only the risk-section wording is imprecise.
Findings
No blocking or non-blocking defects found in the changed code.
- (Observation, severity: note)
sessionUpdate: ' '(whitespace-only) classifies asunrecognized_session_updateand is therefore hidden. Consistent with the PR's taxonomy (it is a string kind no case handles), recorded so a future maintainer doesn't read it as a gap in the malformed branch. - (Observation, severity: note) The sandbox's head tree carries a leftover nested
packages/sdk-typescript/node_modules/eslint@8.57.1that makesnpm run lintinside that package crash on rule loading (@typescript-eslint/no-unused-expressionsTypeError). Proven environmental, not PR-caused: the PR touches nopackage.json/lockfile; a fresh base worktree (no nested install) lints clean with the same command; and the rooteslint@9.29.0passes cleanly on the head sdk package (same invocation shape the web-shell lint script uses), with a planted unused-variable probe confirming the gate bites.
Mutation matrix (vacuity of the new/changed tests)
All rows scripted (mutation-runner.mjs): apply mutation in place → run the catching suite with the JSON reporter → assert exactly the expected tests fail and nothing else → restore. Unmutated controls: daemonUi.test.ts 289/289 and transcriptToMessages.test.ts 115/115 green. See evidence/03-mutation-matrix.png.
| Mutant | Guard removed | Suite | Result |
|---|---|---|---|
| M1 | delete the isUnrecognizedDaemonDebug filter line |
web-shell adapter | killed 2/2 — exactly the two filter tests, no collateral (matches author's "two tests fail" claim) |
| M2 | drop debugReason: 'unrecognized_event' stamp |
sdk daemonUi | killed 2/2 (stamp test + reducer-boundary test) |
| M3 | drop the debugReason spread in appendStatusBlock |
sdk daemonUi | killed 1/1 — the new reducer-boundary test does its stated job |
| M4 | always stamp unrecognized_session_update (commit 2's fix reverted) |
sdk daemonUi | killed 1/1 — the discriminator-less-malformed test pins commit 2 |
| M5 | also filter malformed_payload (keep-side positive control) |
web-shell adapter | killed 1/1 — the "keeps malformed visible" assertion is live |
No survivors. Every guard the PR introduces is pinned by a test that fails with the behavioural assertion, and each kill set matched the intended test names exactly (zero unexpected failures).
Targeted gates (head)
| Gate | Result |
|---|---|
packages/sdk-typescript full suite |
1486/1486 pass (32 files) |
packages/sdk-typescript test/unit/daemonUi.test.ts |
289/289 pass |
packages/web-shell full suite |
2944/2944 pass (172 files) — matches the count claimed in the PR body |
packages/web-shell transcriptToMessages.test.ts |
115/115 pass |
tsc --noEmit both packages |
clean |
| eslint both packages (root eslint 9) | clean; liveness probe planted + caught + removed |
Multi-commit note: the snapshot lists 2 commits but only HEAD^2 is reachable (depth-2 checkout; defbe4fb is beyond the shallow boundary). The aggregate HEAD^1..HEAD diff — which contains both commits — is what this round verified; M4 specifically exercises commit 2's discriminator fix.
Not covered
- Reviewer Test Plan steps 1–4 in a real browser/daemon: the A/B exercises the identical normalize→reduce→adapter chain the live path uses, but no real
qwen serve+ Chrome session was driven (mock provider + a2ui MCP server setup was out of budget-scope). Steps 2–4 claims map to A/B cells pre-release: fix ci #1/OpenAI API Error: 401 Incorecct API Key provided #6–9; step 1's a2ui frame shape is A/B cell Where is the config saved? #2. - Step 3 ("frame still on the wire"): covered by construction only — the normalizer is a pure projection and the diff touches no serve route; no SSE capture was recorded. This verifies the shape (projection changed, frames untouched), not a live wire capture.
- Per-commit attribution for
defbe4fb(unreachable at depth 2 — see gates note). - Repo-wide lint/typecheck/test — only the two changed workspaces ran (per scope).
- webui rendering was verified by code reading only (it unconditionally drops all
kind === 'debug'blocks intranscriptAdapter.ts, so this PR cannot change its behavior). npm run lintinsidepackages/sdk-typescriptcrashes on a sandbox-only nested-eslint artifact (see Findings); not re-run after removing the artifact since the root-eslint run + base A/A settle the question.
Methodology
Environment: CI verify container (node:22-bookworm), tree = refs/pull/8812/merge (depth 2), npm ci + npm run build pre-run at HEAD. Harnesses drove compiled TypeScript sources directly via tsx (type-only cross-package imports are erased, so the base arm provably loaded base-tree code — realpaths printed by each run). A/B: identical 9-event scenario (7 wire envelopes + 2 client-dispatched UI events) piped through normalizeDaemonEvent → reduceDaemonTranscriptEvents → transcriptBlocksToDaemonMessages on both arms, with arm-conditional expectations encoded as assertions (base failing to render = control assertion passing). Mutation matrix: in-place mutation + vitest JSON reporter + restore, scripted in mutation-runner.mjs. Raw logs in logs/ (head-ab.log, base-ab.log, mutation-matrix.log, vitest JSON outputs); harnesses harness-ab.ts and mutation-runner.mjs are rerunnable. Base worktree removed after capture; tree left clean (git status empty).
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
yiliang114
left a comment
There was a problem hiding this comment.
Verified at head 2736e7f (adding substance to the existing approval). The classification is right: unrecognized_event / unrecognized_session_update for forward-compat noise, malformed_payload kept visible for frames this client knows that arrived broken, and the missing/empty/non-string discriminator case is correctly routed to malformed rather than unrecognized (with the rationale documented). The reducer carries debugReason onto status/debug blocks, and the new reducer-pass-through test catches the exact regression where both suites would stay green while production blocks lose the field. The Web Shell filter keys off debugReason instead of text prefixes, so future daemon kinds are covered automatically; client-dispatched debug blocks (no debugReason, e.g. model_switch_summary) still render, and the model.changed status filter stays text-keyed with the reason explained. Tests pin all three categories on both the normalizer and adapter sides. One CI note: the only red check on this head is review-pr failing on a runner worktree-cleanup error ('cannot delete branch worktree-triage used by worktree'), which is bot infrastructure, not this change. Nothing blocks merge.
doudouOUC
left a comment
There was a problem hiding this comment.
Found one backward-compatibility regression inline. I also independently reproduced the existing whitespace-discriminator finding: sessionUpdate: ' ' normalizes to unrecognized_session_update and is filtered instead of remaining visible as malformed_payload. I’m holding approval until these two classification holes are addressed.
Otherwise, the current head passed the focused SDK and Web Shell suites (289 + 115 tests), both package typechecks, changed-file ESLint/Prettier checks, and the workspace build.
|
@qwen-code /takeover |
|
🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the 中文说明🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 |
…n split Four review findings from #8812: - `WebShellTranscript` is a public entry point taking already-projected blocks, so blocks from an SDK predating `debugReason` still arrive with no reason and started rendering again when the prefix checks were removed. Fall back to the stable ` (unrecognized daemon event): ` marker when no reason is present — which covers every unrecognized event type, not just the two previously suppressed by name. The old-shape fixture is restored (adding `debugReason` to it had hidden this path) and a dedicated legacy test now pins it. - A whitespace-only discriminator is truthy, so `sessionUpdate: ' '` was classified unrecognized and hidden. Gate on `trim()`, matching the convention `getFirstString` already uses. - Add the mirror invariant for the reducer: a client-dispatched debug event must produce a block with no `debugReason`. Defaulting the field in `appendStatusBlock` otherwise passes every other test while tagging the model-switch summary unrecognized. - Guard the outermost public re-export. A type-only guard would not hold — vitest erases `export type` through esbuild and this package's tsconfig excludes `test/` — so ship the union as `DAEMON_UI_DEBUG_REASONS`, matching `DAEMON_ERROR_KINDS`, and assert it at runtime.
|
Follow-up verification on The whitespace discriminator issue is fixed: One usage-update compatibility issue remains blocking for the original “usage_update刷屏” scenario: transcriptBlocksToDaemonMessages([
{
kind: 'debug',
text: 'usage_update: {"used":1,"size":2}',
// no debugReason: legacy/persisted block
},
])At this head this still returns a system message containing the raw Please add a backward-compatible filter/migration for this exact legacy Reproduction was run directly against the PR head; the ACP wire path and new valid |
…gReason Follow-up verification on #8812 pointed out the marker fallback does not close the original report. #8790 stopped the SDK inserting new `usage_update` blocks, but `WebShellTranscript` renders whatever blocks its caller passes, so a transcript persisted or projected before that still holds them and the spam returns after upgrade. The legacy `session_update` projection is `<kind>: <json>` with no marker to key on, so match those by kind name instead. The list is closed on purpose — `usage_update` and `a2ui`, the two known to have leaked — and requires the `: {` shape, because a generic `<word>: {` rule would swallow legitimate diagnostics. Blocks the normalizer classified still win on `debugReason`, so `malformed_payload` and client-dispatched debug blocks stay visible. Mutation-checked in both directions: dropping the fallback fails the legacy test, and loosening the prefix to bare `usage_update:` fails the test that pins prose and classified blocks staying visible.
|
Reproduced exactly as you described and fixed in df0b757 — you are right that documenting the limitation did not close the original report. At The const LEGACY_SUPPRESSED_SESSION_UPDATE_PREFIXES = ["usage_update: {", "a2ui: {"];It only applies when Two regression tests, mutation-checked in both directions: dropping the fallback fails the legacy test, and loosening the prefix to bare What is still not recoverable, and I kept it as a code comment rather than widening the rule: a legacy block for some other unrecognized session-update kind. Matching those needs a generic shape rule that would hide legitimate content. New projections carry Web Shell suite 2947 passing, typecheck and lint clean. The ACP wire path is untouched. |
|
Thanks — I independently verified I opened #8823 to track the reducer behavior separately, with both reproductions, the shared root cause, and the acceptance criteria captured there. This keeps #8812 focused; I do not consider that pre-existing reducer issue a blocker for this PR. Please feel free to use #8823 for the dedicated follow-up PR you offered. |
|
🤖 AutoFix updated a stale base — the fix did not pass verification, but this PR was behind What I found before stopping: See the Qwen Autofix agent step logs for model/tool output. Run log: https://github.com/QwenLM/qwen-code/actions/runs/31327011711 🧠 Handled by Qwen Code · model/模型 |
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 64 passed · 0 failed · 64 total 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:64 通过 · 0 失败 · 64 总计 Verification reportPR 8812 Deep Verification (follow-up round) —
|
| # | Previous finding (round 1 @ 2736e7fe) |
Severity | Status at new head c9224e56 |
|---|---|---|---|
| 1 | sessionUpdate: ' ' (whitespace-only) classified unrecognized_session_update and hidden |
note | fixed — commit bc32742b gates on trim(). Re-measured, not diffed: cell W10 renders visible with reason=malformed_payload on head; mutant M7 (restore truthiness gate) is killed by exactly the discriminator test. |
| 2 | npm run lint inside packages/sdk-typescript crashes; attributed to a "leftover nested eslint artifact" of the sandbox |
note | superseded (characterization corrected) — a fresh container reproduces it, so it is not sandbox residue: npm installs eslint@8.57.1 nested because the committed packages/sdk-typescript/package.json declares "eslint": "^8.57.0" while the root has ^9.24.0. Pre-existing at base (PR touches no package.json/lockfile/eslint config — verified from the diff file list). Conclusion unchanged: not PR-caused; gate demonstrated with root eslint 9 instead. |
| 3 | Correction: PR Risk section says "rawEvent still carries the original envelope" but rawEvent is stamped only with includeRawEvent: true, which Web Shell never sets |
correction | stands — re-verified at new head: normalizer.ts:607 still conditional; no includeRawEvent anywhere in packages/web-shell/client/. Description text is unchanged. No code change requested. |
Central claim + A/B
Central claim (carried): unrecognized daemon frames no longer render as raw-JSON rows in Web Shell transcripts; malformed_payload diagnostics and client-dispatched debug blocks keep rendering — keyed on structured debugReason.
Delta claim (new this round): blocks that arrive without a debugReason — projected or persisted by an SDK older than this field — are still filtered, by shape: the anchored whole legacy projection <event-type> (unrecognized daemon event): <payload> plus a closed kind-name list (usage_update: {, a2ui: {), scoped to debug blocks only; while anything that merely quotes a marker, any status block, prose, and classified blocks all keep rendering.
Harness (harness-ab.ts, rerunnable): real wire envelopes → normalizeDaemonEvent → reduceDaemonTranscriptEvents → transcriptBlocksToDaemonMessages (the production chain), plus legacy cells feeding already-projected blocks directly into transcriptBlocksToDaemonMessages (the WebShellTranscript public seam). Head arm loaded the merge-commit tree; base arm loaded a git worktree at HEAD^1 = 0a3d7bb5; each run prints the module URLs it loaded (logs/head-ab.log, logs/base-ab.log), proving the base arm executed base-tree code — no workspace symlink crossed (the adapter's @qwen-code/sdk/daemon import is type-only and erased; its only runtime import is relative). Lockfile untouched by the PR, so reusing the root node_modules is a clean control.
Section W — wire frames (normalize → reduce → adapter)
| # | Input frame | Base | Head |
|---|---|---|---|
| W1 | agent_message_chunk "Surface presented." |
assistant msg ✓ | assistant msg ✓ |
| W2 | session_update kind a2ui |
raw JSON row leaks | filtered — unrecognized_session_update |
| W3 | unknown top-level some_future_event |
raw JSON row leaks | filtered — unrecognized_event |
| W4 | language_changed |
filtered (old text prefix) | filtered (debugReason) — parity |
| W5 | session_cwd_changed |
filtered (old text prefix) | filtered (debugReason) — parity |
| W6 | session_update update: {} (no discriminator) |
visible | visible — malformed_payload |
| W7 | memory_changed bad scope |
visible | visible — malformed_payload |
| W8 | model.changed → status Model switched: … |
filtered (kept text check) | filtered (kept text check) |
| W9 | client-dispatched debug model_switch_summary |
visible, source+data intact | visible, source+data intact, block has no debugReason |
| W10 | sessionUpdate: ' ' (whitespace) |
visible | visible — malformed_payload (round-1 note fixed by trim() gate) |
| W11 | session_update kind usage_update |
zero blocks (#8790) | zero blocks (#8790) — A/A parity |
Section L — legacy/persisted blocks (no debugReason) into the adapter
| # | Block text (kind) | Base | Head |
|---|---|---|---|
| L1 | language_changed (unrecognized daemon event): {"language":"en"} (debug) |
filtered (old prefix) | filtered (shape shim) — parity |
| L2 | some_future_event (unrecognized daemon event): {"a":1} (debug) |
renders | filtered — flip |
| L3 | usage_update: {"used":46351,…} (debug) |
renders | filtered — flip |
| L4 | a2ui: {"surfaceId":"s1",…} (debug) |
renders | filtered — flip |
| L5 | marker + non-object payloads 42 / true / null / plain string / empty ×5 |
all 5 render | all 5 filtered — flip |
| L6 | marker + realistic pretty-printed (2-space indent) payload | renders | filtered (prefix still matches) |
| L7 | 5 keep-negatives: status quoting marker; debug relaying marker mid-text; client summary quoting marker; status starting usage_update: {; prose usage_update: rejected by the proxy |
all 5 visible | all 5 visible |
| L8 | collision probe: client-dispatched debug (source set, no reason) whose text starts a2ui: {… |
renders | filtered — inherent shim boundary (see Findings) |
| L9 | char-class probe: marker with space in event type (weird type (…)) |
renders | renders — parity (see Findings) |
| L10 | legacy malformed shape session_update: {"update":{}} (debug) |
visible | visible — closed list does not swallow old malformed diagnostics |
Counts: 11 targeted leak observables on base (W2, W3, L2, L3, L4, L5×5, L6) → 0 on head; the documented collision probe L8 also flips (render → filter, inherent shim boundary); all 13 parity/keep cells (W1, W4–W11, L1, L7, L9, L10) are identical across arms. Census on head: 3 debug blocks survive in transcript state, 1 renders — the filter is render-layer only, so "which frame was this" remains recoverable from state and from the wire. Witness images: 01-ab-head-filtered.png, 02-ab-base-json-leak.png.
Corrections
- Correction to round 1's characterization of the sdk lint crash (carried finding Where is the config saved? #2 above): round 1 called the nested
eslint@8.57.1a "leftover sandbox artifact". A fresh container with a pristinenpm cireproduces it, so it is the legitimate npm outcome of the committedeslint: ^8.57.0devDependency inpackages/sdk-typescript/package.jsonconflicting with the root^9.24.0. It is pre-existing repo state, identical at base, and unrelated to this PR — but it is not environmental flotsam. This changes no verdict; it fixes the record. - The round-1 correction to the PR description's
rawEventwording stands unchanged (see status table row 3).
Findings
No blocking or non-blocking defects found in the changed code. Two boundary probes, recorded so a future maintainer doesn't misread them as gaps:
- (note) Shim collision is inherent and unreachable to fix at this layer (cell L8): a client-dispatched debug block with no
debugReasonwhose text starts with exactlya2ui: {/usage_update: {(or the whole legacy projection shape) is indistinguishable from a persisted legacy block, so it is filtered. The only known client dispatcher in this repo is Web Shell'smodel_switch_summary, whose text never takes these shapes; the shim is anchored, debug-scoped, and closed-list precisely to keep this surface minimal. The alternative (not filtering no-reason blocks) reopens the original spam for every persisted transcript. Documented tradeoff, not a defect. - (note) Event types outside
[A-Za-z0-9_.-]escape the shape shim (cell L9): e.g. a type containing a space renders on head — exactly as on base (parity), and every new projection carriesdebugReasonregardless, so only blocks persisted by old SDKs with exotic type strings could slip through. Daemon event types are wire-protocol identifiers; not reachable in practice.
Mutation matrix (vacuity of the new/changed tests)
Scripted (mutation-runner.mjs): mutate in place → run catching suite with vitest JSON reporter → assert the failed set matches the intended tests exactly → restore. Unmutated controls green: adapter 120/120, daemonUi.test.ts 290/290, daemon-public-surface.test.ts 14/14. Witness: 03-mutation-matrix.png.
| Mutant | Guard removed / inverted | Suite | Result |
|---|---|---|---|
| M1 | delete the isUnrecognizedDaemonDebug filter call (whole feature) |
adapter | killed 5/5 — exactly the five filter tests |
| M2 | disable the legacy shape shim only (reason branch intact) | adapter | killed 3/3 — exactly the three legacy tests (reason-classified filtering survives, as designed) |
| M3 | drop the kind === 'debug' guard (shim reaches status blocks) |
adapter | killed 1/1 — "only matches the legacy shape…" (status cell usage_update: { drops) |
| M4 | restore substring marker match (unanchored) | adapter | killed 1/1 — "only matches the legacy shape…" (relayed/quoted-marker cells drop) |
| M5 | restore the leading-char class [{"[] on the legacy payload |
adapter | killed 1/1 — "filters legacy projections whose payload is not an object" |
| M6 | default debugReason in appendStatusBlock (?? 'unrecognized_event') |
sdk | killed 1/1 — the mirror invariant test ("leaves client-dispatched debug blocks without a debugReason"); confirms the author's claim that only that test pins it |
| M7 | drop the trim() gate (truthy ' ' → unrecognized) |
sdk | killed 1/1 — "classifies a session_update with no usable discriminator as malformed" |
| M8 | loosen prefixes to bare usage_update: / a2ui: |
adapter | killed 1/1 — "does not let the legacy prefixes swallow prose…" |
| M9 | drop the DAEMON_UI_DEBUG_REASONS value re-export from the public entry |
sdk | killed 1/1 — "pins the union shipped by @qwen-code/sdk/daemon" fails on the runtime assertion (import resolves undefined; the test does exactly the job its comment describes) |
| PC | positive control: isIgnoredWebShellStatus returns false (pre-existing pin) |
adapter | killed 1/1 — "filters SDK model switch status noise" |
10/10 killed, 0 survivors, zero collateral failures — every kill set matched the intended test names exactly. (The runner's log prints M9 as "MISMATCH" only because this round's expectation predicted an import-level file failure; the actual failure is the intended test's runtime assertion, adjudicated as killed.)
Targeted gates (head c9224e56)
| Gate | Result |
|---|---|
packages/web-shell full suite |
2982/2982 pass, 173 files (round 1: 2944/172 — delta is this PR's 8 adapter tests + tests merged from main) |
packages/sdk-typescript full suite |
1501/1501 pass, 32 files (round 1: 1486 — delta is this PR's 7 sdk tests + tests merged from main) |
tsc --noEmit both packages |
clean (exit 0) |
| eslint (root 9.x, scoped to each package) | clean on both; liveness probe (planted unused variable) caught on both packages before the clean runs |
npm run lint inside sdk package |
crashes on nested eslint@8.57.1 — pre-existing, committed-dependency conflict, identical at base (see Corrections); not used as the gate |
Witness: 04-gates-suite-counts.png. Multi-commit note: the snapshot lists 7 commits but the checkout is depth-2 (git rev-list HEAD^1..HEAD^2 returns 1, the shallow-boundary artifact), so per-commit attribution is out of reach; the aggregate HEAD^1..HEAD diff — containing all seven — is what this round verified. The branch already merged current main (bot commit c9224e56), and HEAD is the clean merge into base tip 0a3d7bb5, so the merge itself is verified by construction.
Not covered
- Reviewer Test Plan in a real browser/daemon: steps map to harness cells — step 1 (unrecognized event) → W2/W3; step 2 (JSON row gone, conversation intact) → W1–W3; step 4 (model-switch summary survives) → W9 — but no live
qwen serve+ Chrome session was driven. The A/B exercises the identical normalize→reduce→adapter chain the live path uses; this reproduces the shape, not the end-to-end trigger. - Step 3 ("frame still on the wire"): by construction only — the normalizer is a pure projection and the diff touches no serve route; no SSE capture recorded.
- Per-commit attribution for the four delta commits (depth-2 checkout; verified in aggregate).
- Repo-wide lint/typecheck/test — only the two changed workspaces ran (per scope).
- webui rendering: verified by code reading only (it unconditionally drops all
kind === 'debug'blocks, so this PR cannot change its behavior). verify-capture.mjsran cleanly; no image pipeline gaps this round.
Methodology
Environment: CI verify container (node:22-bookworm), tree = refs/pull/8812/merge (depth 2), npm ci + npm run build pre-run at HEAD. Harnesses drove compiled TypeScript sources directly via tsx; each arm printed the module URLs it loaded so the base arm's isolation is evidenced in the logs, not assumed (logs/head-ab.log, logs/base-ab.log). A/B: 21 cells (11 wire frames + 10 legacy/boundary blocks) piped through the production chain on both arms, arm-conditional expectations encoded as assertions (a base cell leaking JSON is a control assertion that passes when base leaks). Mutation matrix: in-place mutation + vitest JSON reporter + restore, 10 mutants + positive control, scripted in mutation-runner.mjs. Gates: full workspace suites, tsc --noEmit, root eslint with planted liveness probes. Raw logs in logs/; harnesses harness-ab.ts and mutation-runner.mjs are rerunnable. Base worktree removed; tree left clean (git status empty).
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix review round — no code changes neededAll feedback in this round was triaged against head Findings re-verified as resolved in the code
Issue-level items: the primitive-payload gap ( No failed checks and no still-red checks were reported for this round. VerificationCommands actually run at head
中文说明Autofix 评审轮次 — 无需任何代码改动本轮所有反馈均基于 head 已复核确认在代码中解决的发现
议题级条目:原始值 payload 缺口( 本轮没有报告失败的检查,也没有持续红色的检查。 验证在 head
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
中文说明
已审查——无阻断问题。 建议见行内评论。
— qwen3.8-max via Qwen Code /review (v0.21.8)
| case 'status': | ||
| case 'debug': { | ||
| const statusBlock = block; | ||
| if (isUnrecognizedDaemonDebug(statusBlock)) break; |
There was a problem hiding this comment.
[Suggestion] R2-1: This filter leaves behind a now-dead, duplicated copy of the legacy marker match: isMidTurnInjectedDebugMessage in packages/web-shell/client/components/MessageList.tsx:650-652 still branches on message.content?.startsWith('mid_turn_message_injected (unrecognized daemon event):'). Probe-verified at this head: no message reaching MessageList can carry that prefix anymore — new blocks are dropped via debugReason === 'unrecognized_event', legacy blocks via LEGACY_UNRECOGNIZED_EVENT_PATTERN (which matches mid_turn_message_injected (unrecognized daemon event): ), and the dedicated normalizer case emits a status event whose text (Inserted message: …) never starts with the marker. — Concrete cost: the SDK comment this PR adds declares the diagnostic wording "changes without notice"; when it does, this orphaned shim silently stops matching, and no production data flows through it (the MessageList.test.ts fixtures exercise only the source branch). Fix, in MessageList.tsx (outside this diff):
const isMidTurnInjectedDebugMessage = (message: DaemonMessage): boolean =>
message.source === 'mid_turn_message_injected';中文说明
[建议] 该过滤器使得旧版标记匹配的另一个副本变成了死代码:packages/web-shell/client/components/MessageList.tsx:650-652 中的 isMidTurnInjectedDebugMessage 仍然基于 message.content?.startsWith('mid_turn_message_injected (unrecognized daemon event):') 分支。在此 head 上经探针验证:到达 MessageList 的消息不再可能携带该前缀——新区块通过 debugReason === 'unrecognized_event' 被丢弃,旧版区块通过 LEGACY_UNRECOGNIZED_EVENT_PATTERN(会匹配 mid_turn_message_injected (unrecognized daemon event): )被丢弃,而专门的 normalizer 分支发出的是 status 事件,其文本(Inserted message: …)从不以该标记开头。— 具体代价:本 PR 新增的 SDK 注释声明诊断措辞"随时可能变化,恕不通知";一旦措辞变化,这个孤立的兜底分支会悄悄失效,而没有任何生产数据流经它(MessageList.test.ts 的 fixture 只覆盖 source 分支)。修复方式(位于本 diff 未触及的 MessageList.tsx):删除 isMidTurnInjectedDebugMessage 中的 content?.startsWith(...) 分支,仅保留 message.source === 'mid_turn_message_injected'。
— qwen3.8-max via Qwen Code /review (v0.21.8)
| /** Mirrors `DaemonUiStatusEvent.debugReason`; only set on `debug` blocks. */ | ||
| debugReason?: DaemonUiDebugReason; |
There was a problem hiding this comment.
[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",没有提到这些投影现在携带渲染器必须据以分支的 debugReason;MIGRATION.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)
| if (block.debugReason !== undefined) { | ||
| return ( | ||
| block.debugReason === 'unrecognized_event' || | ||
| block.debugReason === 'unrecognized_session_update' | ||
| ); | ||
| } |
There was a problem hiding this comment.
[Suggestion] R2-3: This check enumerates two exact debugReason values, while the SDK's own contract — the doc comment on DAEMON_UI_DEBUG_REASONS this PR adds — defines the semantics by wildcard-named category: unrecognized_* is forward-compat noise to hide, malformed_* is a defect signal to keep visible. Probe-verified at this head: a block stamped unrecognized_tool_frame renders as raw JSON (even with marker-shaped text the legacy regex would otherwise catch), because the defined-reason short-circuit runs before the legacy fallback; the category-prefix variant below hides it without disturbing any currently-covered shape. Adding a fourth union member compiles silently against the two-literal comparison, so neither typecheck nor any existing test catches it. — Failure scenario: a future SDK release adds such a reason and stamps it in the normalizer → the exact match returns false → raw JSON dumps render in conversations again — the exact spam this PR removes returns silently, with both suites green.
| if (block.debugReason !== undefined) { | |
| return ( | |
| block.debugReason === 'unrecognized_event' || | |
| block.debugReason === 'unrecognized_session_update' | |
| ); | |
| } | |
| if (block.debugReason !== undefined) { | |
| return block.debugReason.startsWith('unrecognized_'); | |
| } |
(Alternatively, keep the enumeration with a comment noting each new unrecognized_* reason requires a Web Shell update.)
中文说明
[建议] 该检查精确枚举了两个 debugReason 取值,而 SDK 自身的契约——本 PR 新增的 DAEMON_UI_DEBUG_REASONS 文档注释——是按通配命名的类别定义语义的:unrecognized_* 是应隐藏的前向兼容噪音,malformed_* 是应保持可见的缺陷信号。在此 head 上经探针验证:打上 unrecognized_tool_frame 的区块会渲染出原始 JSON(即使其文本是旧版正则可以捕获的标记形状),因为携带 reason 的短路分支先于旧版兜底执行;改用下面的类别前缀匹配即可将其隐藏,且不影响当前已覆盖的任何形状。给联合类型新增第四个成员时,与两个字面量的比较仍能通过编译,因此 typecheck 和现有测试都无法捕获该问题。— 失败场景:未来某个 SDK 版本新增这样一个 reason 并在 normalizer 中打上 → 精确匹配返回 false → 原始 JSON 转储再次出现在对话中——本 PR 消除的刷屏悄悄回归,而两个测试套件全绿。
— qwen3.8-max via Qwen Code /review (v0.21.8)
The debugReason filter enumerated the two current `unrecognized_*` values, but the SDK contract this PR adds names reasons by category: `unrecognized_*` is forward-compat noise to hide, `malformed_*` a defect signal to keep visible. A reason a newer SDK adds would compile silently against the two-literal comparison and render raw JSON again with both suites green. Match the category prefix instead. Also drop the now-dead marker branch in MessageList's mid-turn hide check: every block carrying that prefix is filtered upstream in the adapter (reason-stamped via `debugReason`, legacy via the anchored pattern), and the dedicated normalizer case emits a status event keyed by `source`. Document `DaemonUiDebugReason` beside the sibling closed enums in the daemon-ui docs, whose forward-compat bullet still described the unstamped projection. Regression test pins both directions of the category contract with reasons outside the current enum.
|
🤖 Addressed the latest review feedback (round 2/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 2/100 轮)。改动内容与我反驳保留之处如下: Autofix review round — PR #8812All three automated-reviewer suggestions (R2-1, R2-2, R2-3) were verified Feedback points and dispositionsR2-1 — dead duplicated legacy marker match in
|
|
🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 131 passed · 0 failed · 131 total 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:131 通过 · 0 失败 · 131 总计 Verification reportPR 8812 Deep Verification (follow-up round 2) —
|
| # | Previous finding (round 2 @ c9224e56) |
Severity | Status at new head 1d8a5d7e |
|---|---|---|---|
| 1 | sessionUpdate: ' ' (whitespace-only) classified unrecognized and hidden |
note | fixed (carried, re-measured) — cell W10 renders visible with reason=malformed_payload on head; mutant M7 (drop trim() gate) killed by exactly the discriminator test. |
| 2 | npm run lint inside packages/sdk-typescript crashes — committed eslint: ^8.57.0 devDep vs root ^9.24.0 |
note | stands (re-measured) — fresh reproduction this round: nested eslint@8.57.1 installed, npm run lint exits 2, identical at base (PR touches no package.json/lockfile — verified from the diff file list). Pre-existing, not PR-caused; gate uses root eslint 9.29.0. |
| 3 | Correction: PR Risk section says "rawEvent still carries the original envelope" but rawEvent is stamped only with includeRawEvent: true, which Web Shell never sets |
correction | stands — re-verified: normalizer.ts:607 still conditional; zero occurrences of includeRawEvent in packages/web-shell/client/. Description text unchanged. No code change requested. |
| 4 | Shim collision is inherent (cell L8): a client-dispatched debug block whose text starts exactly a2ui: { is filtered |
note | stands (re-measured) — L8 flips render → filter exactly as before; shim unchanged by this round's delta. Documented tradeoff. |
| 5 | Event types outside [A-Za-z0-9_.-] escape the shape shim (cell L9) |
note | stands (re-measured) — L9 renders on both arms (parity); new projections carry debugReason regardless. |
Central claim + A/B
Central claim (carried): unrecognized daemon frames no longer render as raw-JSON rows in Web Shell transcripts; malformed_payload diagnostics and client-dispatched debug blocks keep rendering — keyed on structured debugReason, with a shape shim for legacy blocks carrying no reason.
Delta claim (this round): the reason filter matches the unrecognized_* category prefix (block.debugReason.startsWith('unrecognized_')) instead of the two enum literals, so a reason a newer SDK adds is hidden without a Web Shell change, while every other reason — including a future malformed_* — keeps rendering. The MessageList content-prefix branch for mid_turn_message_injected (unrecognized daemon event): was removed as dead.
Harness (harness-ab.ts, rerunnable): real wire envelopes → normalizeDaemonEvent → reduceDaemonTranscriptEvents → transcriptBlocksToDaemonMessages (the production chain), plus legacy/category cells feeding already-projected blocks directly into the adapter (the WebShellTranscript public seam). Head arm loaded the merge-commit tree; base arm loaded a git worktree at HEAD^1 = 55e20db3; each run prints the module URLs it loaded (logs/head-ab.log, logs/base-ab.log) — the base log shows only tmp/base-tree/… URLs, so base executed base-tree code. The adapter's @qwen-code/sdk/daemon import is import type (erased); its only runtime import is relative. Lockfile untouched by the PR, so reusing the root node_modules is a clean control.
Section W — wire frames (normalize → reduce → adapter)
| # | Input frame | Base | Head |
|---|---|---|---|
| W1 | agent_message_chunk "Surface presented." |
assistant msg ✓ | assistant msg ✓ |
| W2 | session_update kind a2ui |
raw JSON row leaks | filtered — unrecognized_session_update, block kept in state |
| W3 | unknown top-level some_future_event |
raw JSON row leaks | filtered — unrecognized_event |
| W4 | language_changed |
filtered (old text prefix) | filtered (debugReason) — parity |
| W5 | session_cwd_changed |
filtered (old text prefix) | filtered (debugReason) — parity |
| W6 | session_update update: {} (no discriminator) |
visible | visible — malformed_payload |
| W7 | memory_changed bad scope |
visible | visible — malformed_payload |
| W8 | model_switched → status Model switched: … |
filtered (kept text check) | filtered (kept text check) |
| W9 | client-dispatched debug model_switch_summary |
visible, source+data intact | visible, source+data intact, block has no debugReason |
| W10 | sessionUpdate: ' ' (whitespace) |
visible | visible — malformed_payload (round-1 note stays fixed) |
| W11 | session_update kind usage_update |
zero blocks (#8790) | zero blocks (#8790) — A/A parity |
| W12 | mid_turn_message_injected well-formed |
visible, keyed by source |
visible, keyed by source — parity (dedicated normalizer case) |
| W13 | mid_turn_message_injected {} (malformed) |
visible | visible — malformed_payload (shim does not match the fallback text) |
Section L — legacy/persisted blocks (no debugReason) into the adapter
| # | Block text (kind) | Base | Head |
|---|---|---|---|
| L1 | language_changed (unrecognized daemon event): {"language":"en"} (debug) |
filtered (old prefix) | filtered (shape shim) — parity |
| L2 | some_future_event (unrecognized daemon event): {"a":1} (debug) |
renders | filtered — flip |
| L3 | usage_update: {"used":46351,…} (debug) |
renders | filtered — flip |
| L4 | a2ui: {"surfaceId":"s1",…} (debug) |
renders | filtered — flip |
| L5 | marker + non-object payloads 42 / true / null / plain string / empty ×5 |
all 5 render | all 5 filtered — flip |
| L6 | marker + pretty-printed (2-space indent) payload | renders | filtered (prefix still matches) |
| L7 | 5 keep-negatives: status quoting marker; debug relaying marker mid-text; client summary quoting marker; status starting usage_update: {; prose usage_update: rejected by the proxy |
all 5 visible | all 5 visible |
| L8 | collision probe: client-dispatched debug (source set, no reason) whose text starts a2ui: {… |
renders | filtered — inherent shim boundary (carried note) |
| L9 | char-class probe: marker with space in event type (weird type (…)) |
renders | renders — parity (carried note) |
| L10 | legacy malformed shape session_update: {"update":{}} (debug) |
visible | visible — closed list does not swallow old malformed diagnostics |
| L11 | new: legacy mid_turn_message_injected (unrecognized daemon event): … (debug) |
renders (old MessageList branch only collapsed it) | filtered upstream — proves the removed MessageList branch's target is handled by the adapter |
Section C — debugReason category cells (this round's delta)
| # | Block (kind debug) | Base | Head |
|---|---|---|---|
| C1 | reason unrecognized_tool_frame (outside this build's enum) |
renders (field ignored) | filtered by category prefix — the delta claim |
| C2 | reason malformed_tool_frame (future defect signal) |
renders | renders — malformed_* category keeps rendering |
| C3 | reason unrecognized (no trailing underscore) |
renders | renders — category boundary, visible by design |
| C4 | reason '' |
renders | renders — parity |
| C5 | reason null |
renders | throws TypeError: Cannot read properties of null (reading 'startsWith') — see Findings |
| C6 | reason 42 |
renders | throws TypeError: …startsWith is not a function — see Findings |
| C7 | reason unrecognized_tool_frame + text quoting the marker |
renders | filtered — classified reason wins over the shim |
Counts: head 47/47, base 40/40 (base arm has fewer reason-stamp checks). 17 base-arm CONTROL cells leak or render: 12 raw-JSON leaks (W2, W3, L2, L3, L4, L5×5, L6, L11), the collision probe L8, and the 4 category cells (C1, C7 filtered on head as intended; C5/C6 throw on head — the finding probe, predicted and encoded as passing assertions; C2–C4 keep-negatives). All parity/keep cells (W1, W4–W13 except W2/W3, L1, L7×5, L9, L10, C2, C3, C4) are identical across arms. The filter is render-layer only: W2-head asserts the block survives in transcript state with its reason, so "which frame was this" remains recoverable from state and from the wire. Witness images: 01-ab-head-filtered.png, 02-ab-base-json-leak.png.
Corrections
No new corrections this round. The round-2 correction of the rawEvent description stands (status table row 3), and the round-2 characterization of the sdk lint crash as a committed-dependency conflict (not sandbox residue) was re-confirmed (row 2).
Findings
-
(note, new this round) The category-prefix expression is not total on degenerate
debugReasonvalues. The delta changed the reason branch from=== 'unrecognized_event' || === 'unrecognized_session_update'(total) toblock.debugReason.startsWith('unrecognized_')guarded only by!== undefined— so a block carryingdebugReason: null/42/true/{}now throws aTypeErrorinsidetranscriptBlocksToDaemonMessages(cells C5/C6; base rendered them). That function runs on every transcript render viauseMessages, and Web Shell's only containment is the rootErrorBoundary(main.tsx:153→RootErrorFallback) — so one such block replaces the entire app view with the error fallback screen. Reachability is low: every producer in this system writes string-or-absent (the normalizer stamps literals; the reducer's carry-over is truthy-gated; the typed public API excludes null), so triggering it requires a hand-edited persisted transcript or an out-of-system producer. It is still a robustness regression introduced by this round's delta, and it contradicts the forward-compat wording the same PR adds todocs/developers/daemon-ui/README.md— whose example (reason?.startsWith('unrecognized_') ?? false) is null-safe via?., i.e. the documented pattern is more defensive than the shipped code.Measured suggested fix (scratch copy, not applied to the PR): guard with
typeof—- if (block.debugReason !== undefined) { + if (typeof block.debugReason === 'string') { return block.debugReason.startsWith('unrecognized_'); }
Three measured results (
logs/fix-probe.log,fix-verify.json): (1) hostile fixtures go clean —null/42/true/{}no longer throw and degrade to visible, the safe default; (2) benign fixtures come out byte-identical — 11 string/absent-reason shapes produce identical message JSON on original vs patched; (3) the affected suite's counts are unchanged — adapter file 121/121 green both with and without the patch, so the fix should ship with its fixture: a block withdebugReason: nullasserting no throw + visible (no current test pins this axis). 19/19 probe assertions + 1 suite check passed. -
(note, carried) Shim collision is inherent (cell L8, unchanged): a client-dispatched debug block with no reason whose text starts exactly
a2ui: {/usage_update: {is indistinguishable from a persisted legacy block and is filtered. The only known client dispatcher (model_switch_summary) never produces these shapes; the alternative reopens the original spam for every persisted transcript. Documented tradeoff, not a defect. -
(note, carried) Event types outside
[A-Za-z0-9_.-]escape the shape shim (cell L9, unchanged): parity with base, and new projections carrydebugReasonregardless.
Mutation matrix (vacuity of the new/changed tests)
Scripted (mutation-runner.mjs): mutate in place → run catching suite with vitest JSON reporter → assert the failed set matches the intended tests exactly → restore via git checkout. Unmutated controls green: adapter 121/121, MessageList 131/131, daemonUi.test.ts 290/290, daemon-public-surface.test.ts 14/14. Witness: 03-mutation-matrix.png.
| Mutant | Guard removed / inverted | Suite | Result |
|---|---|---|---|
| M1 | delete the isUnrecognizedDaemonDebug filter call (whole feature) |
adapter | killed 6/6 — exactly the six filter tests (incl. the new category test) |
| M2 | disable the legacy shape shim only (reason branch intact) | adapter | killed 3/3 — exactly the three legacy tests |
| M3 | drop the kind === 'debug' guard (shim reaches status blocks) |
adapter | killed 1/1 — "only matches the legacy shape…" |
| M4 | restore substring marker match (unanchored) | adapter | killed 1/1 — "only matches the legacy shape…" |
| M5 | restore the leading-char class [{"[] on the legacy payload |
adapter | killed 1/1 — "filters legacy projections whose payload is not an object" |
| M8 | loosen prefixes to bare usage_update: / a2ui: |
adapter | killed 1/1 — "does not let the legacy prefixes swallow prose…" |
| M10 | REVERT the category prefix to the two-literal enum comparison (pre-delta code) | adapter | killed 1/1 — exactly the new test "keys the filter off the unrecognized_ category prefix, not the enum": the delta's central test is non-vacuous |
| M11 | loosen the prefix to startsWith('unrecognized') (no underscore) |
adapter | SURVIVED (predicted) — coverage gap: no test pins the trailing-underscore boundary (behavior matches the documented unrecognized_* convention; a reason named without the underscore would render — the safe default) |
| PC1 | positive control: isIgnoredWebShellStatus returns false |
adapter | killed 1/1 — "filters SDK model switch status noise" |
| M6 | default debugReason in appendStatusBlock (?? 'unrecognized_event') |
sdk | killed 1/1 — the mirror invariant test |
| M7 | drop the trim() gate |
sdk | killed 1/1 — "classifies a session_update with no usable discriminator as malformed" |
| M9 | drop the DAEMON_UI_DEBUG_REASONS value re-export from the public entry |
sdk | killed 1/1 — "pins the union shipped by @qwen-code/sdk/daemon" (runtime assertion; import resolves undefined) |
| M12 | RESTORE the removed MessageList content-prefix branch | MessageList | SURVIVED (predicted) — dead code: no message reaching MessageList can carry that content (the adapter's shape shim filters every such block upstream; the only other system-message producer is source: 'recap'), so restoring the branch changes nothing. This is the evidence the removal is behavior-preserving |
| PC2 | positive control: isMidTurnInjectedDebugMessage returns false |
MessageList | killed 2/2 — "classifies mid-turn status…" + "hides mid-turn injected debug rows…" — proves the suite is live for this function, so M12's survival means exactly "the removed branch contributed nothing" |
12 killed with exact test-set matches, 2 predicted survivors both adjudicated (1 coverage gap, 1 dead code), 0 unexpected survivors, zero collateral failures. Both survivors carry positive controls (PC1/PC2) proving the harness can fail the suites.
Targeted gates (head 1d8a5d7e)
| Gate | Result |
|---|---|
packages/web-shell full suite |
2983/2983 pass, 173 test files (round 2: 2982 — delta is this round's 1 new category test) |
packages/sdk-typescript full suite |
1501/1501 pass (unchanged — no sdk test deltas this round) |
tsc --noEmit both packages |
clean (exit 0, re-run live inside 04-gates-suite-counts.png) |
eslint (root 9.29.0 flat config, scoped packages/web-shell/client + packages/sdk-typescript/src + test) |
clean (exit 0); liveness probe (planted unused variable) reported @typescript-eslint/no-unused-vars on both packages before the clean run |
npm run lint inside sdk package |
exits 2 on nested eslint@8.57.1 — pre-existing committed-dependency conflict, identical at base (see status table); not used as the gate |
Witness: 04-gates-suite-counts.png. Multi-commit note: the snapshot lists 9 commits but the checkout is depth-2 (git rev-list HEAD^1..HEAD^2 returns 1, the shallow-boundary artifact), so per-commit attribution is out of reach; the aggregate HEAD^1..HEAD diff is what this round verified. Merge verified by construction: the PR head 1d8a5d7e is itself a merge of 8c9dbb0d with the current base tip 55e20db3, so the CI merge commit is conflict-free; main's only delta (packages/core/src/tools/workflow/*, #8694) is outside this PR's surface.
Not covered
- Reviewer Test Plan in a real browser/daemon: steps map to harness cells — step 1 (unrecognized event) → W2/W3/C1; step 2 (JSON row gone, conversation intact) → W1–W3; step 4 (model-switch summary survives) → W9 — but no live
qwen serve+ Chrome session was driven. The A/B exercises the identical normalize→reduce→adapter chain the live path uses; this reproduces the shape, not the end-to-end trigger. - Step 3 ("frame still on the wire"): by construction only — the normalizer is a pure projection and the diff touches no serve route; no SSE capture recorded.
- Per-commit attribution for the delta commit (depth-2 checkout; verified in aggregate).
- Repo-wide lint/typecheck/test — only the two changed workspaces ran (per scope).
- webui rendering: verified by code reading only (it unconditionally drops all
kind === 'debug'blocks, so this PR cannot change its behavior). - Reason-stamped
statusblocks: impossible by construction (the normalizer stamps onlydebugevents and the reducer only carries the field for them), so no cell was built for one. - The C5/C6 suggested fix is measured in a scratch copy only; it is not applied to the PR tree.
Methodology
Environment: CI verify container (node:22-bookworm), tree = refs/pull/8812/merge (depth 2), npm ci + npm run build pre-run at HEAD. Harnesses drove TypeScript sources directly via tsx; each A/B arm printed the module URLs it loaded so the base arm's isolation is evidenced in the logs, not assumed (logs/head-ab.log, logs/base-ab.log); the base arm ran from git worktree tmp/base-tree at HEAD^1, reusing root node_modules (lockfile untouched by the PR; the adapter's only runtime import is relative and its SDK import is type-only/erased). A/B: 31 cells (13 wire frames + 11 legacy blocks + 7 category blocks; L5's five non-object payloads are separate assertions) piped through the production chain on both arms, arm-conditional expectations encoded as assertions (a base cell leaking JSON is a control assertion that passes when base leaks). Suggested-fix evidence came from a scratch copy of packages/web-shell/client: the probe's "original" side was reconstructed from git show HEAD: (byte-verified identical) because the copy was made while the mutation runner owned the tree; the "patched" side's integrity is established by the probe itself — its 11 byte-identity assertions compare it against that proven-clean original across every pre-fix behavior (reason branch, shim, prefixes, status kind), so any stray state would have surfaced as a divergence. Mutation matrix: in-place mutation + vitest JSON reporter + git checkout restore, 14 mutants/controls + 4 unmutated controls, scripted in mutation-runner.mjs; the matrix was re-run live inside the capture for 03-mutation-matrix.png. Gates: full workspace suites (JSON reporter artifacts gate-web-shell.json, gate-sdk.json), tsc --noEmit, root eslint 9.29.0 with planted liveness probes. Raw logs in logs/; harnesses harness-ab.ts, mutation-runner.mjs, scratch/fix-probe.ts, and gate-summary.sh are rerunnable. Base worktree removed after the last base-arm run; tree left clean (git status empty).
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix review round: no action neededThe feedback batch for this round contained no actionable items:
No code changes were made this round. The branch remains at its current head, and no commit was created. Once the in-progress verification/review run completes, any findings it produces will be triaged in a subsequent round. 中文说明Autofix 复查轮次:无需处理本轮收到的反馈中没有需要处理的事项:
本轮未做任何代码改动。分支保持在当前提交,未创建新的提交。待正在运行的验证/审查完成后,其产生的任何发现都将在后续轮次中进行分类处理。 Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
|
Code review — no blockers. My independent proposal for this problem was exactly the shape this PR took: stamp a classification on normalizer-produced
Non-blocking observations: (1) the web-shell visuals harness has no scenario that seeds an unrecognized frame, so that check cannot see this change — a follow-up scenario in Testing — this is an unattended run: no PR code was built or executed here; evidence is the PR's own CI on the reviewed commit, fetched via the API, plus the thread's review record. All checks green on
The suite pins the change rather than passing identically without it — the new tests assert 中文说明代码审查——无阻塞问题。 我对这个问题的独立方案与本 PR 的形态一致:normalizer 产生的
非阻塞观察:(1) web-shell 视觉测试场景没有注入 unrecognized 帧的用例,该检查看不到本改动——后续可在 测试——本次为无人值守运行:未构建或执行任何 PR 代码;证据是通过 API 获取的 PR 自身 CI(见上表),以及线程中的 review 记录。 新测试断言的 — Qwen Code · qwen3.8-max Reviewed at |
|
Confidence: 5/5 — clean across every stage; three rounds of skeptical human review burned everything flammable out of this, and what's left is the minimal classification fix with a pinning test at every boundary. Stepping back: the approach matches my independent proposal, and the hard questions all resolve in its favour. The problem is observed and recurring (three per-symptom patches already), the fix attacks the classification instead of adding a fourth patch, and the SDK surface change is additive and documented. Every edit in the diff earns its place — the growth across the rounds is exactly what reviewers asked for: the discriminator-less/whitespace frames staying visible, the legacy shim anchored and payload-shape-agnostic, the reducer boundary tested, the docs written. The one reservation I'd normally carry into a merge — "the suite is green but does it pin anything?" — doesn't apply: the new tests fail on base by construction, and the fail-open invariant means the worst case for an unforeseen block is rendering exactly as it does today. The only thing no one can show from CI is the live browser transcript, and the visuals harness has no scenario reaching this state — named in the Stage 2 comment with the lane that would settle it. That gap is small here (the change is a pure block→message mapping, independently verified by @zjunothing at the penultimate head, with the author's real-daemon evidence on top), so it is a follow-up, not a hold. Approving, pinned to the reviewed commit — CI on it is fully green. ✅ 中文说明置信度:5/5——各阶段全部干净;经过三轮严格的人工审查,所有可质疑的点都已消除,留下的是最小的分类修复,每个边界都有钉住它的测试。 退一步看:方案与我的独立提议一致,所有难题都站在它这一边。问题是已观测且反复出现的(此前已打过三次按症状的补丁),修复攻击的是分类本身而不是第四次补丁,SDK 接口改动是增量且有文档的。diff 中每一处改动都有存在价值——各轮增加的内容恰好是 reviewer 要求的:无判别符/空白判别符帧保持可见、旧版 shim 有锚点且不依赖 payload 形状、reducer 边界有测试、文档补齐。我通常会在合并前保留的一点疑虑——"测试绿了但它钉住什么了吗?"——在这里不成立:新测试在 base 上必然失败,且 fail-open 不变式保证了未见过的块最坏也只是按今天的表现渲染。 唯一无法从 CI 展示的是真实浏览器中的 transcript,而视觉测试场景覆盖不到这个状态——已在 Stage 2 评论中点名,并给出可收尾的沙箱验证通道。这个缺口在此很小(改动是纯粹的块→消息映射,@zjunothing 已在倒数第二个 head 上独立验证,另有作者的真实 daemon 证据),因此是后续事项,不是拦阻理由。 批准,钉在所审查的提交上——该提交上的 CI 全绿。✅ — Qwen Code · qwen3.8-max Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
yiliang114
left a comment
There was a problem hiding this comment.
Re-review at head 1d8a5d7 after the two follow-up commits — both are improvements. The legacy shim handles exactly the backward-compat gap: WebShellTranscript is a public entry point taking already-projected blocks, so blocks projected or persisted by an SDK older than debugReason arrive with no reason; they are now shape-matched, and only shape-matched — the unrecognized-event regex is anchored at the start (a block merely quoting the marker stays visible), the payload is deliberately unconstrained since DaemonEvent.data is unknown and keying on a leading '{' would miss non-object payloads, and the legacy session_update prefixes are scoped to the two kinds known to have leaked (usage_update, a2ui) with the ': {' requirement so prose is never hidden. Switching the reason filter to the unrecognized_ prefix makes the category a contract: a reason a newer SDK adds under it is covered without a Web Shell change, while malformed_* and client-dispatched blocks stay visible by construction, and the README now documents that contract. Dropping the text-prefix fallback in MessageList's isMidTurnInjectedDebugMessage is the same cleanup. Tests cover the legacy shapes on both commits. CI green on this head. Nothing blocks merge.
doudouOUC
left a comment
There was a problem hiding this comment.
Re-reviewed the full 11-file diff at head 1d8a5d7ec6a50f80b355485f58de632ef12637b4. No blocking findings remain. I traced every debugReason write/read/caller site and both public barrels, checked all downstream transcript consumers (SDK renderers, WebUI, and Web Shell), and re-audited the classification boundary for unknown top-level frames, unknown and malformed session_update payloads, malformed known events, client-dispatched debug events, and persisted pre-debugReason blocks. The legacy matching is now shape-scoped and quote-safe, future unrecognized_* reasons are handled by category, malformed diagnostics remain visible, and the stale MessageList text-prefix branch is gone. The previously reported compatibility and false-suppression defects are fixed on this exact head. Focused Web Shell adapter tests pass 121/121, git diff --check is clean, and the substantive Ubuntu, Web Shell smoke/visual, and desktop CI checks are green. Approving.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline. Not explored to full depth (tool budget reached): Change summary: This PR adds a structured debugReason t...: Web Shell Playwright/e2e specs not run against the mutation (unit + adapter suites are the PR's declared guard and the seam both new test suites claim to cover)….
中文说明
已审查。 建议见行内评论。 未探索到全部深度(达到工具调用预算):Change summary: This PR adds a structured debugReason t...:Web Shell Playwright/e2e specs not run against the mutation (unit + adapter suites are the PR's declared guard and the seam both new test suites claim to cover)…。
— qwen3.8-max via Qwen Code /review (v0.21.8)
| // 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) => |
There was a problem hiding this comment.
[Suggestion] R3-1: The five inline block factories added by these tests (three legacy closures, two block closures, plus bare literals) duplicate this file's existing statusBlock helper, whose overrides: Partial<DaemonStatusTranscriptBlock> spread already supports { kind: 'debug', debugReason, source, data } — each spells the base shape out behind an as DaemonTranscriptBlock assertion. Probe-verified during this review: adding a required field to DaemonTranscriptBlockBase makes the typed helper fail to compile while the as-cast shapes compile with no diagnostic. (Both packages' typecheck configs exclude test files, so today this drift is caught by IDE checking only — that mitigates but does not remove the cost.) — Failure scenario: DaemonTranscriptBlockBase gains a required field → the typed helpers fail to compile but these as-cast literals keep compiling → the fixtures that exercise this filter silently stop tracking the real block shape.
The fix spans five sites, so no one-click suggestion — build the fixtures with the existing helper, e.g. statusBlock('legacy-1', 'language_changed (unrecognized daemon event): {"language":"en"}', 1, { kind: 'debug' }), passing debugReason/source/data through overrides; or add one top-level debugBlock factory next to statusBlock and use it in all five tests.
中文说明
[建议] R3-1:这些测试新增的 5 个内联区块工厂(3 个 legacy 闭包、2 个 block 闭包,另有裸字面量)重复了本文件已有的 statusBlock 辅助函数——其 overrides: Partial<DaemonStatusTranscriptBlock> 展开已支持 { kind: 'debug', debugReason, source, data }——每个工厂都在 as DaemonTranscriptBlock 断言背后手写完整的基础结构。本次评审中经探针验证:给 DaemonTranscriptBlockBase 添加一个必填字段后,带类型的辅助函数会编译失败,而这些 as 断言的写法却没有任何诊断地继续通过编译。(两个包的 typecheck 配置都排除了测试文件,所以目前这种漂移只会在 IDE 检查中暴露——这减轻了但并未消除代价。)— 失败场景:DaemonTranscriptBlockBase 增加必填字段 → 带类型的辅助函数编译失败,但这些 as 断言的字面量继续编译通过 → 覆盖该过滤器的 fixture 悄悄脱离真实的区块结构。
修复涉及 5 处,无法一键 suggestion——请用现有辅助函数构造 fixture,例如 statusBlock('legacy-1', 'language_changed (unrecognized daemon event): {"language":"en"}', 1, { kind: 'debug' }),通过 overrides 传入 debugReason/source/data;或在 statusBlock 旁新增一个顶层 debugBlock 工厂并在这 5 个测试中统一使用。
— qwen3.8-max via Qwen Code /review (v0.21.8)
| // `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 |
There was a problem hiding this comment.
[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 的判别字段「缺失、为空或不是字符串」时 kind 为 undefined,但 getString 对空字符串返回的是 ''——只有缺失或非字符串的判别字段才会得到 undefined。运行时分类在所有情况下都是正确的(''?.trim() 为假 → malformed_payload,{ sessionUpdate: '' } 测试已固定该行为);只有解释文字是错的。— 失败场景:维护者追查一个投影文本以 ": {…}" 开头的 malformed 区块时(由于 '' 不是 nullish,${kind ?? 'session_update'} 兜底不会触发),会被这段注释告知此处 kind 应为 undefined,而基于该机制构建的理由段落会把诊断引偏。
建议修复:改写为「……因此当判别字段缺失或不是字符串时 kind 为 undefined,为空时则为 ''。无论哪种情况帧都是坏的……」——后面的 trim() 一句已经正确覆盖了纯空白的情形。
— qwen3.8-max via Qwen Code /review (v0.21.8)
| type: 'debug', | ||
| debugReason: 'malformed_payload', | ||
| text: `${event.type}: ${stringifyRedactedJson(event.data)}`, |
There was a problem hiding this comment.
[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、缺 requestId 的 permission_request、缺 requestId 的 permission_resolved、以及没有可用 update 字段的 session_update 信封,各自断言 debugReason: 'malformed_payload'。
— qwen3.8-max via Qwen Code /review (v0.21.8)
| * 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 |
There was a problem hiding this comment.
[Suggestion] R3-5: This comment says both kinds were suppressed "at the source" by the normalizer, but that is true only for usage_update (case 'usage_update': return []). The SDK normalizer has no a2ui case at all — current daemons actively emit a2ui frames (the acp-bridge republishes them), which the default branch still projects into debug blocks tagged unrecognized_session_update, hidden only at render time by the debugReason filter above. — Failure scenario: a future maintainer reads "suppressed at the source", believes the 'a2ui: {' legacy prefix is dead weight and deletes it from LEGACY_SUPPRESSED_SESSION_UPDATE_PREFIXES as cleanup → transcripts persisted or projected by pre-debugReason SDKs re-render raw a2ui: {…} JSON spam in Web Shell — the exact regression #8790 addressed. (The deletion would fail the legacy-filter test, but this rationale block is precisely what a future reader trusts.)
Suggested fix: reword to "…usage_update, which the normalizer suppresses at the source, and a2ui, which the current normalizer still projects as an unrecognized_session_update debug block (hidden via debugReason above); this prefix covers blocks produced before debugReason existed."
中文说明
[建议] R3-5:该注释称这两种类型都被 normalizer「在源头屏蔽」,但这只对 usage_update 成立(case 'usage_update': return [])。SDK normalizer 完全没有 a2ui 分支——当前 daemon 仍在主动发出 a2ui 帧(acp-bridge 会重新发布它们),default 分支仍会将其投影为带 unrecognized_session_update 标记的 debug 区块,只是由上方的 debugReason 过滤器在渲染时隐藏。— 失败场景:未来某位维护者读到「在源头屏蔽」,认为 'a2ui: {' 这条旧版前缀是死代码而作为清理删除 → 由 debugReason 出现之前的 SDK 持久化或投影的 transcript 会重新在 Web Shell 中渲染出原始 a2ui: {…} JSON 刷屏——正是 #8790 处理过的那个回归。(删除会导致 legacy 过滤测试失败,但这段理由说明恰恰是未来读者会信任的内容。)
建议修复:改写为「……usage_update——normalizer 已在源头屏蔽;以及 a2ui——当前 normalizer 仍会将其投影为 unrecognized_session_update debug 区块(由上方 debugReason 隐藏);此前缀用于覆盖 debugReason 存在之前产生的区块。」
— qwen3.8-max via Qwen Code /review (v0.21.8)
| DaemonUiAuthDeviceFlowThrottledEvent, | ||
| DaemonUiDebugReason, | ||
| DaemonUiErrorEvent, |
There was a problem hiding this comment.
[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)
| DaemonUiAssistantDoneEvent, | ||
| DaemonUiDebugReason, | ||
| DaemonUiErrorEvent, |
There was a problem hiding this comment.
[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/ui 做 import type { DaemonUiDebugReason } 的 SDK 使用者只在发布后才会在编译期报错。
建议修复:与 daemon/index.ts 上的评论相同——接受 workspace 的 typecheck 作为把关,或在 surface 测试中注明其 expectTypeOf 那一半由 npm run typecheck 而非测试套件强制执行。
— qwen3.8-max via Qwen Code /review (v0.21.8)
Review 总结结论:LGTM。 用结构化 SDK 侧(sdk-typescript)
Web Shell 侧
测试与验证
文档(daemon-ui README 的 debug reason categorization 一节)同步到位。无阻塞问题。 |
ytahdn
left a comment
There was a problem hiding this comment.
LGTM — structured debugReason classification replaces per-kind string suppression; all 7 normalizer debug sites stamped, legacy shape-shim anchored and fail-safe, malformed/client-dispatched debug kept visible, MessageList simplification backed by the upstream adapter filter. CI green, thorough tests.
|
Released in v0.21.9. |














What this PR does
This PR stops Web Shell from rendering the daemon UI normalizer's "I don't know this frame" debug projections as conversation content. The normalizer now stamps a structured
debugReasonon every debug event it produces, and Web Shell branches on that instead of pattern-matching the debug text.Web Shell is the primary entry point for daemon-backed sessions, and it is the only surface where these projections are user-visible — so this is a user-facing defect on the main path, not a niche renderer detail. It should land ahead of further per-kind suppressions.
Why it's needed
The normalizer projects any frame it has no case for into a
debugevent whose text is a raw JSON dump of the payload. webui's ChatViewer drops those blocks (packages/webui/src/daemon/transcriptAdapter.ts:56), but Web Shell rendersstatusanddebugtogether as a system info message (packages/web-shell/client/adapters/transcriptToMessages.ts). So every event kind the daemon ships ahead of the UI surfaces in Web Shell as an unreadable JSON row in the middle of the conversation.This has been patched per-symptom three times already:
language_changedandsession_cwd_changed— suppressed by string prefix inisIgnoredWebShellStatususage_update— fix(sdk): hide ACP usage updates from transcripts #8790a2ui— fix(sdk): hide A2UI bridge frames from transcripts #8808Each fix covers exactly one kind, and the next new daemon event leaks again. This PR addresses the classification instead.
debugReasonsplits the debug channel into three cases:unrecognized_event/unrecognized_session_update— the daemon runs ahead of this client. Developer diagnostics, not conversation content; Web Shell no longer renders them.malformed_payload— a frame the client does have a case for arrived unusable. That signals an actual defect, so it stays visible.debugReasonat all — debug events dispatched by clients themselves, such as Web Shell's own model-switch summary (App.tsx,source: 'model_switch_summary'). These keep rendering; the filter must not sweep them up.The two
(unrecognized daemon event)prefix checks are now covered bydebugReasonand are removed. TheModel switched:check stays:model.changedprojects to astatusblock, not a debug one.Reviewer Test Plan
How to verify
a2uiexposing a tool that returns an A2UI command array plus fallback text, settools.toolSearch.enabled: falseso the tool is declared upfront, and prompt the model to call it — the ACP bridge republishes the commands assessionUpdate: 'a2ui', which no normalizer case handles.GET /session/:id/eventsand check thea2uiframe is still delivered.debugReason).Evidence (Before & After)
Verified against a real
qwen servedaemon with an isolatedQWEN_HOME, a recording mock OpenAI-compatible provider, a stdio MCP server nameda2ui, and the real Web Shell in Chrome.The verification build deliberately contains no
case 'a2ui'in the normalizer —grep 'case"a2ui"' dist/web-shell/assets/index-*.jsreturns 0 — so what the screenshot shows is the classification fix alone suppressing a kind nothing special-cases.Before (build without this change): the transcript carries a full
a2ui: { "sessionUpdate": "a2ui", "a2ui": { "surfaceId": ..., "commands": [...] } }row.After (this change, still no per-kind case): same prompt, header still reports one tool call, only
Surface presented.remains.A follow-up SSE capture confirms the
a2uiframe is still emitted once — the frame is unaffected, only its transcript projection changed.Unit coverage: the normalizer stamps each of the three reasons; the Web Shell adapter drops both
unrecognized_*reasons, keepsmalformed_payload, and keeps client-dispatched debug blocks with theirsource/dataintact. Removing the filter line makes two of those tests fail (mutation-checked).Tested on
Environment (optional)
macOS, local Node.js workspace, real
qwen servedaemon + real Web Shell. Fullsdk-typescript(1484),webui(454) andweb-shell(2944) suites pass, plus lint and typecheck on both packages.Risk & Scope
rawEventstill carries the original envelope for debug panels, andmalformed_payload— the case that indicates a real defect — stays visible.debugReasonis an optional field onDaemonUiStatusEventandDaemonStatusTranscriptBlock; consumers that ignore it behave exactly as before.Linked Issues
#8790 suppressed
usage_updateand #8808 would have suppresseda2ui, one event kind each. This PR fixes the classification both work around, so no further per-kind suppression is needed — #8808 has been closed in favour of this one. Since Web Shell is the main entry point for daemon-backed sessions, this is a user-facing defect on the main path.中文说明
本 PR 的改动
本 PR 让 Web Shell 不再把 daemon UI normalizer "我不认识这个帧" 的 debug 投影当作对话内容显示。normalizer 现在给它产生的每个 debug 事件打上结构化的
debugReason,Web Shell 依据它分支,而不是去匹配 debug 文本。Web Shell 是 daemon 会话的主入口,也是唯一会把这些投影暴露给用户的界面——所以这是主路径上的用户可见缺陷,而不是某个渲染器的边角细节,应当优先于继续按类型逐个屏蔽。
为什么需要
normalizer 会把任何没有对应分支的帧投影成
debug事件,文本是 payload 的原始 JSON。webui 的 ChatViewer 会丢弃这些块(packages/webui/src/daemon/transcriptAdapter.ts:56),而 Web Shell 把status和debug一起渲染成 system 信息(packages/web-shell/client/adapters/transcriptToMessages.ts)。因此,daemon 每领先 UI 增加一种事件类型,Web Shell 就会在对话中间出现一行无法阅读的 JSON。这个症状已经被逐个打过三次补丁:
language_changed和session_cwd_changed——在isIgnoredWebShellStatus里用字符串前缀屏蔽usage_update——fix(sdk): hide ACP usage updates from transcripts #8790a2ui——fix(sdk): hide A2UI bridge frames from transcripts #8808每次修复只覆盖一种类型,下一个新事件照样泄漏。本 PR 改的是分类本身。
debugReason把 debug 通道拆成三种情况:unrecognized_event/unrecognized_session_update——daemon 领先于当前客户端。属于开发者诊断信息而非对话内容,Web Shell 不再渲染。malformed_payload——客户端确实有分支的帧带着不可用的 payload 到达。这说明真的出了问题,因此保持可见。debugReason——客户端自己派发的 debug 事件,例如 Web Shell 自己的模型切换摘要(App.tsx,source: 'model_switch_summary')。这些继续渲染,过滤不能把它们一起扫掉。两条
(unrecognized daemon event)前缀检查已由debugReason覆盖,予以删除。Model switched:那条保留:model.changed投影成的是status块,不是 debug 块。审查者测试计划
验证方法
a2ui的 stdio MCP server,提供返回 A2UI 命令数组 + fallback 文本的工具,设置tools.toolSearch.enabled: false让工具直接声明,然后提示模型调用它——ACP bridge 会把命令重新发成sessionUpdate: 'a2ui',而 normalizer 没有对应分支。GET /session/:id/events,检查a2ui帧仍被投递。debugReason的 debug 块)。证据(修复前与修复后)
在真实
qwen servedaemon(隔离QWEN_HOME)、记录型 mock OpenAI 兼容服务、名为a2ui的 stdio MCP server 以及 Chrome 中的真实 Web Shell 上验证。验证用的构建故意不包含 normalizer 里的
case 'a2ui'——grep 'case"a2ui"' dist/web-shell/assets/index-*.js结果为 0——因此截图展示的是分类修复本身挡住了一个没有任何专门处理的类型。修复前(不含本改动的构建):transcript 中出现完整的
a2ui: { "sessionUpdate": "a2ui", "a2ui": { "surfaceId": ..., "commands": [...] } }行。修复后(本改动,仍然没有按类型的分支):同一条 prompt,头部仍显示一次工具调用,只剩
Surface presented.。随后再抓一次 SSE,确认
a2ui帧仍然发出一次——帧本身不受影响,变的只是它的 transcript 投影。单元测试覆盖:normalizer 会打上三种 reason;Web Shell 适配器丢弃两种
unrecognized_*,保留malformed_payload,并保留客户端派发的 debug 块及其source/data。删掉过滤那一行会让其中两个测试失败(已做变异验证)。测试平台
环境(可选)
macOS、本地 Node.js workspace、真实
qwen servedaemon + 真实 Web Shell。sdk-typescript(1484)、webui(454)和web-shell(2944)完整测试套件全部通过,两个包的 lint 与 typecheck 亦通过。风险与范围
rawEvent仍然保留原始事件信封供调试面板使用,而真正表示缺陷的malformed_payload保持可见。debugReason是DaemonUiStatusEvent和DaemonStatusTranscriptBlock上的可选字段,忽略它的消费者行为与此前完全一致。关联 Issue
#8790 屏蔽了
usage_update,#8808 本来要屏蔽a2ui,各自只覆盖一种事件类型。本 PR 修的是两者共同绕开的分类问题,因此不再需要继续按类型逐个屏蔽——#8808 已因本 PR 关闭。鉴于 Web Shell 是 daemon 会话的主入口,这是主路径上的用户可见缺陷。