Skip to content

fix(web-shell): close the four deferred #9812 review follow-ups - #11107

Merged
wenshao merged 1 commit into
mainfrom
fix/11076-webui-retirement-followups
Sep 5, 2026
Merged

fix(web-shell): close the four deferred #9812 review follow-ups#11107
wenshao merged 1 commit into
mainfrom
fix/11076-webui-retirement-followups

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

Closes the four review suggestions deferred out of #9812 and tracked in #11076. Each one protects a behaviour that had nothing pinning it, so each could regress silently.

  • Historical sessionStorage key. clientLifecycle.test.ts only ever round-tripped through SESSION_CLIENT_ID_STORAGE_PREFIX, so renaming that constant moved the read and the write together and left every test green — while a tab that persisted its client id under the WebUI-era key lost it across the migration, and the daemon then saw a fresh X-Qwen-Client-Id for the same controller. Three assertions now spell the key literally, including its percent-encoded session suffix.
  • ChatRecord export script. The legacy-JSONL rejection is a behaviour flip refactor!: retire @qwen-code/webui #9812 introduced, and it had no test because the script had no exports and ran main() at import. The input gate is now selectChatRecords, the render is renderHtmlFromObjects(objects, api) with the export API passed in, and main() runs only as the process entry point. The CLI behaves exactly as before. A new suite covers the rejection and the happy path.
  • Substring-trap fixture. The retirement deleted packages/webui/src/components/Shellfish.tsx from the platform-sensitivity classifier's fixtures without replacement. Restored under a live path.
  • Hook documentation. InputForm was deleted with packages/webui, but the publicly exported hook's docblock still told integrators to render it.

Why it's needed

These are not theoretical. #9812's review confirmed each with a mutation witness, and #11076 has carried them since. The two behaviours worth stating plainly:

The key rename is invisible to the suite by construction — that is what makes it worth a literal assertion rather than another round-trip. And removing the legacy-JSONL throw does not produce no error; it falls through to the neighbouring Unrecognized JSONL format path, so a test that merely expects a rejection would pass. The new test asserts the message verbatim and that the renderer is never reached, which is what makes the mutant die.

The Shellfish.tsx case is a different trap from the packages/web-shell/client/App.tsx fixture already in that suite. That one guards the compound (web-shell as a dashed segment); this one guards the keyword being the head of a stem. A loosening that breaks one leaves the other green — measured below.

Reviewer Test Plan

How to verify

A verification brief ships with the branch at docs/verification/11076-webui-retirement-followups/README.md, with the exact commands, the two mutations to apply, and what each should turn red.

  • cd packages/web-shell && npx vitest run client/daemon/session/clientLifecycle.test.ts
  • npx vitest run --config ./scripts/tests/vitest.config.ts scripts/tests/export-html-from-chatrecord-jsonl.test.js
  • node --test --test-concurrency=1 .github/scripts/ci/classify-platform-sensitivity.test.mjs
  • grep -c InputForm packages/web-shell/client/daemon/useDaemonFollowupSuggestion.ts0

Both new suites are already reached by CI on their own: the scripts test via npm run test:scripts (part of test:ci), the classifier via HELPER_TESTS in ci.yml.

Evidence (Before & After)

N/A — no user-visible surface. Tests, a docblock, and a testability refactor with no behaviour change.

The classifier item was run locally, mutation included:

intact tree + new fixture                                  → 12 pass, 0 fail
mutant (SUBSYSTEM_STEM_HEAD `[-_]` → `[^/]*`) + fixture    → 11 pass, 1 fail
mutant + original test file (fixture absent)               → 12 pass, 0 fail   ← survived before

The export module was exercised by a standalone node script asserting every behaviour the vitest file asserts — all passed. That validates the module, not the vitest wiring.

Tested on

OS Status
🍏 macOS ⚠️ not tested
🪟 Windows ⚠️ not tested
🐧 Linux ✅ items 3 and 4 only

Environment (optional)

Prettier clean (3.6.1, matching the lockfile). The two vitest suites were not run — the authoring machine cannot run vitest without OOM risk, which is why the brief and the module probe exist. CI is the authority on items 1 and 2.

Risk & Scope

  • Main risk or tradeoff: the export script gained exports and an entry guard purely for testability. The CLI path is unchanged — main() still runs when the file is the process entry point, verified — but it is a real edit to a script no suite previously touched, which is precisely why it previously had no test.
  • Not validated / out of scope: --help on that script still needs built CLI output, because loadExportApi() is awaited before argument parsing. Pre-existing, untouched. The two vitest suites are unrun locally (see above).
  • Breaking changes / migration notes: none.

Linked Issues

Closes #11076. Refs #9812.

中文说明

这个 PR 做了什么

完成 #9812 延后、并由 #11076 跟踪的四条评审建议。每一条保护的行为此前都没有任何东西钉住,因此都可能悄悄回归。

  • 历史 sessionStorage 键。 clientLifecycle.test.ts 一直只通过 SESSION_CLIENT_ID_STORAGE_PREFIX 做往返测试,因此重命名该常量会让读写两端一起移动、所有测试仍为绿——而用 WebUI 时期的键持久化过 client id 的标签页会在迁移中丢失它,守护进程随后为同一个控制器看到全新的 X-Qwen-Client-Id。现在有三条断言以字面量拼出该键,包括其百分号编码的 session 后缀。
  • ChatRecord 导出脚本。 遗留 JSONL 的拒绝是 refactor!: retire @qwen-code/webui #9812 引入的行为翻转,此前没有测试,因为该脚本没有任何导出、且在 import 时就执行 main()。现在输入闸门是 selectChatRecords,渲染是 renderHtmlFromObjects(objects, api)(导出 API 作为参数传入),main() 仅在该文件是进程入口时运行。CLI 行为与此前完全一致。新增测试覆盖拒绝路径与正常路径。
  • 子串陷阱夹具。 退役操作把 packages/webui/src/components/Shellfish.tsx 从平台敏感度分类器的夹具中删除且无替代,现以存活路径补回。
  • Hook 文档。 InputFormpackages/webui 一并删除,但公开导出的 hook 的文档注释仍在指示集成方渲染它。

为什么需要

这些都不是理论问题。#9812 的评审为每一条都给出了变异见证,#11076 一直挂着它们。两点值得直说:

常量重命名对测试套件在构造上就是不可见的——正因如此才值得用字面量断言,而不是再加一个往返测试。而删掉遗留 JSONL 的 throw 并不会导致「没有错误」:它会落到相邻的 Unrecognized JSONL format 分支,因此只断言「会抛错」的测试仍会通过。新测试逐字断言错误文案、并断言渲染器从未被触达,这才是变异体致死的原因。

Shellfish.tsx 与该套件中已有的 packages/web-shell/client/App.tsx 夹具是两种不同的陷阱:后者防的是复合词(web-shell 作为带连字符的分段),前者防的是关键词位于词干开头。破坏其中一条的放宽不会让另一条变红——下方有实测。

评审者验证计划

如何验证

分支中附带验证说明:docs/verification/11076-webui-retirement-followups/README.md,内含确切命令、两个待施加的变异,以及各自应当变红的位置。

  • cd packages/web-shell && npx vitest run client/daemon/session/clientLifecycle.test.ts
  • npx vitest run --config ./scripts/tests/vitest.config.ts scripts/tests/export-html-from-chatrecord-jsonl.test.js
  • node --test --test-concurrency=1 .github/scripts/ci/classify-platform-sensitivity.test.mjs
  • grep -c InputForm packages/web-shell/client/daemon/useDaemonFollowupSuggestion.ts0

两个新增套件本身已被 CI 覆盖:scripts 测试经 npm run test:scripts(属于 test:ci),分类器经 ci.yml 中的 HELPER_TESTS

证据(改前 / 改后)

不适用——无用户可见面。改动为测试、一处文档注释,以及一次不改变行为的可测试性重构。

分类器一项已在本地运行,含变异(数据见上方英文代码块)。

导出模块由一个独立 node 脚本逐条断言了 vitest 文件所断言的全部行为,全部通过。这验证的是模块本身,不是 vitest 接线。

测试环境

系统 状态
🍏 macOS ⚠️ 未测试
🪟 Windows ⚠️ 未测试
🐧 Linux ✅ 仅第 3、4 项

运行环境(可选)

Prettier 通过(3.6.1,与 lockfile 一致)。两个 vitest 套件未运行——编写这些改动的机器运行 vitest 有 OOM 风险,这正是验证说明与模块探针存在的原因。第 1、2 项以 CI 为准。

风险与范围

  • 主要风险或权衡: 导出脚本为可测试性新增了导出与入口守卫。CLI 路径未变——该文件作为进程入口时 main() 仍会运行,已验证——但这确实是对一个此前无任何套件触及的脚本的真实改动,而这正是它此前没有测试的原因。
  • 未验证 / 不在范围: 该脚本的 --help 仍需构建产物,因为 loadExportApi() 在参数解析之前被 await。此为既有行为,未改动。两个 vitest 套件在本地未运行(见上)。
  • 破坏性变更 / 迁移说明: 无。

关联 Issue

Closes #11076. Refs #9812.

Each of the four suggestions deferred out of #9812 was left with nothing
pinning it, so the behaviour each one protects could regress silently.

Pin the historical sessionStorage key. `clientLifecycle.test.ts` only ever
round-tripped through `SESSION_CLIENT_ID_STORAGE_PREFIX`, so renaming that
constant moved the read and the write together and left every test green,
while a tab that persisted its id under the WebUI-era key lost it across the
migration. Three assertions now spell the key literally, including its
percent-encoded session suffix.

Make the ChatRecord export script testable and cover both paths. It had no
exports and ran `main()` at import, which is why the legacy-JSONL rejection —
a behaviour flip #9812 introduced — had no test. The input gate is now
`selectChatRecords`, the render is `renderHtmlFromObjects(objects, api)` with
the export API passed in, and `main()` runs only as the process entry point.
The CLI behaves as before. The new suite asserts the rejection message
verbatim and that the renderer is never reached, so removing the throw fails
rather than falling through to the neighbouring error.

Restore the substring-trap fixture the retirement deleted, under a live path.
`Shellfish.tsx` guards the keyword being the head of a stem, which is a
different loosening from the `web-shell` compound case already covered.

Replace the deleted `InputForm` in the public hook's docblock with
`ChatEditor`, which declares the three props, naming `ChatPane` and `App` as
the in-tree hosts.

Verification brief for the two vitest-backed items is committed alongside;
item 3 was run here, mutation included, and the mutant survives without the
new fixture.

Refs #11076, #9812.
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Sep 5, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Qwen Triage ended earlyview run. It stopped before finishing; check the run log.

⚠️ Qwen Triage 提前结束 —— 查看运行。未跑完,请查看运行日志。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — all required headings present, and the Chinese section is a full paragraph-by-paragraph translation rather than a summary.

Problem: observed, not theoretical. These are the four non-blocking suggestions deferred out of #9812 (refactor!: retire @qwen-code/webui, merged today at 244 files / −27,743 lines) and tracked in #11076, which is still open. I read all four cited review threads (R1-8, R1-9, R1-10, R1-14) and each one carries a mutation witness plus a prescribed fix; the four items here map 1:1 onto them. So this is closing a real, permalinked coverage gap, not inventing one.

Direction: aligned. #9812 was deliberately converged by deferring these, and landing them now is the follow-through that makes the deferral honest rather than a way to drop them. Nothing here touches a runtime surface — it is tests, one docblock, and the minimum refactor needed to make a previously untestable script importable. No auth/sandbox/model-selection/telemetry/release surface, so no direction escalation.

Size: not applicable — no core paths are touched. packages/web-shell/client/** is not one of the packages/*/src/{auth,providers,models,config,tools,services}/** protected patterns, and only one package is involved, so this is not a cross-package change either. For reference the split is 72 production lines (59 in the export script, 13 in a comment-only hook edit), 238 test lines, 117 docs lines.

Approach: the scope is right — exactly the four tracked items and nothing else, no drive-by edits. I have one genuine question rather than an objection, about the 117-line brief at docs/verification/11076-webui-retirement-followups/README.md. docs/verification/ is established convention (the abort-controller-refactor entry ships a 120-line README plus logs and scripts, so this is leaner than precedent), but this particular file is framed as a handoff for work the authoring machine could not do — it opens "Every claim below is unverified on the authoring machine" and ends "What to report back". Once CI lands on items 1 and 2 that content is stale, and it substantially duplicates the PR description. Is a permanently committed file the right home for it, or should the brief live in the PR thread and the commit keep only the tests? Worth a thought before merge; not a blocker either way.

Risk: no elevated risk signals — the Stage 1e revert-correlated path scan matched nothing here.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 所有必需标题齐全,中文部分是逐段完整翻译,而非摘要。

问题:已观测,非理论性。这四条是 #9812refactor!: retire @qwen-code/webui,今天以 244 文件 / −27743 行合并)中刻意延后的非阻塞建议,由仍处于 open 状态的 #11076 跟踪。我读了全部四条被引用的 review thread(R1-8、R1-9、R1-10、R1-14),每条都带变异见证与指定的修复方式;本 PR 的四项与其一一对应。所以这是在关闭一个真实、有永久链接的覆盖缺口,而不是凭空造一个问题。

方向:对齐。#9812 是通过延后这些建议来刻意收敛的,现在把它们落地,才让"延后"是诚实的跟进,而不是变相丢弃。本次改动不涉及任何运行时面——只有测试、一处文档注释,以及让一个此前不可测脚本变得可 import 所需的最小重构。不触及 auth/sandbox/模型选择/telemetry/发布面,因此无需方向升级。

规模:不适用——未触及核心路径。packages/web-shell/client/** 不属于 packages/*/src/{auth,providers,models,config,tools,services}/** 保护模式,且只涉及一个 package,因此也不构成跨 package 改动。参考拆分:生产代码 72 行(导出脚本 59 行,hook 纯注释改动 13 行),测试 238 行,文档 117 行。

方案:范围合理——恰好是跟踪的四项,没有别的,也没有夹带顺手改动。我有一个真正的疑问(不是反对),关于 docs/verification/11076-webui-retirement-followups/README.md 这 117 行说明文件。docs/verification/ 是既有惯例(abort-controller-refactor 目录下有 120 行 README 外加日志与脚本,所以本文件比先例更精简),但这个文件的定位是"编写机器无法完成的工作"的交接说明——开头是"Every claim below is unverified on the authoring machine",结尾是"What to report back"。一旦 CI 覆盖第 1、2 项,这些内容就过期了,且与 PR 描述大量重复。是否应该把永久提交的文件作为它的归宿,还是让说明留在 PR 线程里、提交中只保留测试?合并前值得考虑;两种选择都不构成阻塞。

风险:无升级风险信号——Stage 1e 的 revert 相关路径扫描在此没有命中。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 5e8d4c3f2435423c214f09f5d73758073af8d9d3 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Code review

No blocking issues, and no AGENTS.md violations. I checked the load-bearing claims rather than taking the description's word for them, because three of the four items are assertions about what a test will do under a mutation — which is the kind of claim that reads well and can still be wrong.

Item 1 — sessionStorage key. clientLifecycle.ts:8 really is 'qwen-code-webui-client-id:session:', and sessionClientIdKey builds ${PREFIX}${encodeURIComponent(sessionId)}, so the three literals match, including work/space:1work%2Fspace%3A1. All four symbols the new tests call are already imported, and the enclosing describe('persistStableClientId') has a beforeEach that clears sessionStorage, so the added read-after-write case has no ordering dependence on its neighbours.

Item 2 — export script. The refactor is behaviour-preserving: same order of operations, same three error messages, same arguments to buildProductSessionData and toHtml. loadExportApi() is still awaited before parseArgs, so the "--help needs a build" wart is genuinely untouched as claimed rather than quietly changed. The entry guard is not a new idiom — scripts/check-i18n.ts:684-685 is the same shape character for character. I also checked it against the only real caller: runner.py:332 spawns node <absolute path> with cwd=exporter_script.parent, so invokedDirectly is true and main() still runs.

The new suite's assertions are accurate against the implementation, including the two that are easy to get subtly wrong and would have made the test vacuous: buildProductSessionData really does return messages: [] (the records go into the conversation handed to collectSessionMetadata, not into the returned session data), and startTimeFor really does take the earliest timestamp rather than the first record. Both are asserted correctly.

Item 3 — classifier fixture. The mutation claim holds, and it turns on a detail worth stating because the fixture would be worthless without it: both SUBSYSTEM_SEGMENT and SUBSYSTEM_STEM_HEAD are constructed with the 'i' flag. Intact, /Shellfish.tsx fails to match because shell is followed by f, not [-_]; under the prescribed [-_][^/]* mutant it matches, so the fixture kills it. The "different trap from App.tsx" claim is independently correct too — in packages/web-shell/client/App.tsx the shell is preceded by -, so (?:^|/) never matches and that fixture cannot kill this mutant. Using an invented filename is the existing convention in that loop, not a shortcut: cryptic.ts, emptyState.ts, uploader.ts and plateauDetector.ts are all synthetic, none exist on disk. The gap was real — classify-platform-sensitivity.mjs:60 still documents "Nor inside a longer word: Shellfish.tsx, plateauDetector.ts, cryptic.ts" while nothing pinned the Shellfish case.

Item 4 — docblock. ChatEditor.tsx:271-273 declares exactly followupState?, onAcceptFollowup?, onDismissFollowup?, each typed from UseDaemonFollowupSuggestionReturn, so the replacement is accurate and the cited line numbers are right. InputForm no longer exists anywhere under packages/ (only the unrelated InputFormat enum).

One non-blocking observation. The entry guard fails silently. If invokedDirectly ever evaluated false at a real invocation — the plausible route is a symlink, where Node resolves import.meta.url to the realpath while process.argv[1] keeps the link path — the script would exit 0 having written nothing, and runner.py only tests returncode == 0 before printing "Rendered chat HTML saved". scripts/check-tui-dep-direction.mjs:499 uses realpathSync(process.argv[1]), which is symlink-robust. Not worth changing for one non-symlinked call site, but that is the idiom to reach for if this script ever gains a second caller.

One limit on my own verification. I could not check the PR's historical claim (that the retirement deleted the old packages/webui fixture) against git history: the CI checkout is a one-commit shallow clone, so git log -S is meaningless here. I verified the coverage gap against the current tree and the review thread instead, which is sufficient — the gap exists either way — but I did not confirm the deletion story itself.

CI test evidence

Fetched once from the check-runs API at the reviewed commit; not polled, per the no-waiting rule. No check has failed. But the honest headline is that the evidence this PR most needs has not arrived: Test (ubuntu-latest, Node 22.x) and Lint & Static (ubuntu-latest, Node 22.x) are still in progress, and those are the only lanes that will run the two new vitest suites. The author states plainly that vitest could not be run on the authoring machine, so for items 1 and 2 CI is not one source of evidence among several — it is the only one, and it is currently outstanding. Item 3 the author did run locally including the mutant; item 4 is comment-only.

Worth noting for the same reason: Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) are skipped on this commit, so the new suites will be exercised on Linux only. That is consistent with the "Tested on" table and acceptable for jsdom and Node-path tests, but it does mean this PR lands with single-platform evidence.

Final CI results for 5e8d4c3 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
web-shell E2E Smoke (ubuntu-latest, Node 22.x) 🚫 cancelled
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Sandboxed verification would settle this: @qwen-code /verify — the central claim for items 1 and 2 is that the new tests bite, and a green suite cannot show that; it shows the tests pass, not that they fail when the protected behaviour breaks. Specifically, the two mutants are predicted rather than measured: renaming SESSION_CLIENT_ID_STORAGE_PREFIX to 'qwen-code-web-shell-client-id:session:' should redden the two literal-key tests, and deleting the throw in selectChatRecords's looksLikeExportJsonl branch should redden the exact-message and renderer-never-reached tests. Item 3's mutant the author already measured; these two are the unverified part of an otherwise well-evidenced PR. The author has write access, so this is a direct run rather than a sponsored one. /tmux is not the right lane here — there is no TUI surface, and the PR's own Evidence section is N/A.

中文说明

代码审查

无阻塞问题,也没有违反 AGENTS.md。我没有采信 PR 描述,而是逐条核对了关键论断——因为四项中有三项是"测试在某种变异下会如何表现"的断言,这类说法读起来很顺,但完全可能是错的。

第 1 项(sessionStorage 键): clientLifecycle.ts:8 确实是 'qwen-code-webui-client-id:session:'sessionClientIdKey 的构造是 ${PREFIX}${encodeURIComponent(sessionId)},因此三个字面量都对得上,包括 work/space:1work%2Fspace%3A1。新测试调用的四个符号均已 import,且所在 describe('persistStableClientId') 带有清空 sessionStoragebeforeEach,所以新增的"先写后读"用例与相邻用例之间不存在顺序依赖。

第 2 项(导出脚本): 重构保持行为不变——操作顺序、三条错误文案、传给 buildProductSessionDatatoHtml 的参数均一致。loadExportApi() 仍在 parseArgs 之前 await,所以"--help 也需要构建产物"这个既有缺陷确实原样未动,而非被悄悄改掉。入口守卫不是新造的写法——scripts/check-i18n.ts:684-685 与之逐字符同形。我也对照了唯一的真实调用方:runner.py:332node <绝对路径> 启动、cwd=exporter_script.parent,因此 invokedDirectly 为真,main() 仍会执行。

新测试的断言与实现相符,包括两处最容易写错、一旦写错就会让测试变成空壳的地方:buildProductSessionData 返回的确实是 messages: [](records 进入交给 collectSessionMetadataconversation,而不进入返回的 session data),startTimeFor 取的确实是最早时间戳而非首条记录。两处断言都正确。

第 3 项(分类器夹具): 变异论断成立,且取决于一个必须点明的细节——没有它这个夹具毫无价值:SUBSYSTEM_SEGMENTSUBSYSTEM_STEM_HEAD 都带 'i' 标志。原样情况下 /Shellfish.tsx 不匹配,因为 shell 后面是 f 而非 [-_];施加 [-_][^/]* 变异后即匹配,因此该夹具能杀死变异体。"与 App.tsx 是不同陷阱"这一说法也独立成立——在 packages/web-shell/client/App.tsxshell 前面是 -(?:^|/) 永不匹配,所以那个夹具杀不死这个变异体。使用虚构文件名是该循环的既有惯例,并非取巧:cryptic.tsemptyState.tsuploader.tsplateauDetector.ts 全是虚构的,磁盘上都不存在。缺口是真实的——classify-platform-sensitivity.mjs:60 至今仍写着 "Nor inside a longer word: Shellfish.tsx, plateauDetector.ts, cryptic.ts",而 Shellfish 这一例此前无任何夹具固定。

第 4 项(文档注释): ChatEditor.tsx:271-273 恰好声明了 followupState?onAcceptFollowup?onDismissFollowup?,且类型均来自 UseDaemonFollowupSuggestionReturn,因此替换准确、引用的行号也对。InputFormpackages/ 下已完全不存在(只剩无关的 InputFormat 枚举)。

一条非阻塞观察: 入口守卫的失败方式是静默的。如果某次真实调用中 invokedDirectly 为假——最可能的路径是符号链接(Node 把 import.meta.url 解析为 realpath,而 process.argv[1] 保留链接路径)——脚本会在什么都没写的情况下以 0 退出,而 runner.py 只判断 returncode == 0 就打印 "Rendered chat HTML saved"。scripts/check-tui-dep-direction.mjs:499 用的是 realpathSync(process.argv[1]),对符号链接更稳健。只有一个非符号链接调用点,不值得为此改动;但如果这个脚本将来有第二个调用方,应该采用那种写法。

我自身验证的一处局限: PR 的历史性论断(退役操作删除了旧的 packages/webui 夹具)我无法用 git 历史核对——CI 检出是单 commit 浅克隆,git log -S 在此毫无意义。我改为对照当前代码树与 review thread 验证覆盖缺口,这已经足够(缺口无论如何都存在),但删除过程本身我未确认。

CI 测试证据

按"不轮询"规则,只在被审 commit 上一次性从 check-runs API 取得。没有任何检查失败。 但诚实的结论是:本 PR 最需要的证据尚未到达——Test (ubuntu-latest, Node 22.x)Lint & Static (ubuntu-latest, Node 22.x) 仍在进行中,而这两条是唯二会跑两个新增 vitest 套件的通道。作者明确说明编写机器无法运行 vitest,因此对第 1、2 项而言,CI 不是"若干证据来源之一",而是唯一来源,且目前仍未出结果。第 3 项作者已在本地含变异跑过;第 4 项为纯注释改动。

同一原因值得注意:Test (macos-latest, Node 22.x)Test (windows-latest, Node 22.x) 在该 commit 上被跳过,因此新套件只会在 Linux 上被执行。这与"测试环境"表格一致,对 jsdom 与 Node 路径类测试也可接受,但确实意味着本 PR 是以单一平台的证据落地的。

(上方表格为真实检查名与结论,由 finalize 任务在 CI 结束后就地更新。)

沙箱验证可以定这件事:@qwen-code /verify —— 第 1、2 项的核心论断是新测试"咬得住",而绿色套件证明不了这一点;它证明测试通过,不证明受保护行为被破坏时测试会失败。具体说,两个变异体是推演而非实测:把 SESSION_CLIENT_ID_STORAGE_PREFIX 改名为 'qwen-code-web-shell-client-id:session:' 应让两条字面量键测试变红;删掉 selectChatRecordslooksLikeExportJsonl 分支的 throw,应让"精确文案"与"渲染器从未被触达"两条测试变红。第 3 项的变异作者已实测;这两处是这份整体证据扎实的 PR 中尚未验证的部分。作者有写权限,因此这是直接运行,而非 sponsored run。/tmux 在此不是合适的通道——没有 TUI 面,PR 自己的"证据"一栏也是 N/A。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 5e8d4c3f2435423c214f09f5d73758073af8d9d3 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 5e8d4c3. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

terminal-turn-error-copy-narrow-dark before/after

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — every claim I could check independently held up, including the one I expected to break; two non-blocking notes, and CI on the two suites that matter has not landed yet.

Going back to what I said I would do before reading the diff: my independent proposal was essentially this PR. Pin the storage key with a literal, extract an exported input gate plus an entry guard so the script becomes importable, restore the fixture under a live path, correct the docblock. The one place the PR is better than what I sketched is item 2 — the review thread offered "run against a built CLI package or stub that import", and instead of mocking await import('@qwen-code/qwen-code/export') the author threaded the export API in as a parameter to renderHtmlFromObjects. That is the cleaner of the two options and it removes a whole class of mock drift.

What makes me comfortable is that I tried to break the load-bearing claim and could not. The item-3 mutation story depends on Shellfish.tsx matching shell case-insensitively; if those regexes were case-sensitive the fixture would be decoration and the author's measured 11-pass/1-fail would be impossible. They carry the 'i' flag, so it holds — and the companion claim that App.tsx cannot kill the same mutant is also true, for an unrelated reason (shell preceded by -, so (?:^|/) never matches). Two independently correct details in a row is not what a plausible-sounding but hollow PR looks like. The item-2 assertions being right about messages: [] and earliest-timestamp startTime — both easy to assert wrongly in a way that leaves a green, vacuous test — points the same direction.

The two notes I would not merge over, but would say out loud:

The entry guard fails silently. A false invokedDirectly exits 0 having written nothing, and the only caller checks the return code before announcing success. The realistic route there is a symlink. It does not apply today — runner.py passes an absolute, non-symlinked path — and the guard matches an existing idiom in check-i18n.ts, so I am not asking for a change. I am noting it because the failure would be quiet rather than loud.

And the question from Stage 1 still stands on the docs/verification/ brief. It is within precedent and it is honestly written — it opens by saying what was not verified, which is more than most such files manage — but it is a handoff document whose usefulness expires the moment the two pending lanes report. Fine to keep; just a deliberate choice rather than a default.

One thing about provenance, because it is the honest reading rather than the flattering one: the four items originate in /review comments posted by this project's own bot, and #11076 was filed by the PR's author. So the chain is bot review → author's tracking issue → author's PR, and "a maintainer asked for this" is not quite the provenance. It does not change my verdict, because I checked each item against the code on its own merits and each one is substantively correct — the sessionStorage key is genuinely unpinned by a literal today, and the classifier genuinely documents a Shellfish case no fixture covers. Worth saying so the approver knows what weight the item list itself carries.

Not approving in this run: Test (ubuntu-latest, Node 22.x) and Lint & Static (ubuntu-latest, Node 22.x) are still in progress, and they are the only lanes that will execute the two new vitest suites. Since the author could not run vitest locally, approving now would attest to a result that does not exist yet. Approval is deferred until CI lands green on 5e8d4c3f2435423c214f09f5d73758073af8d9d3; if anything goes red or the head moves, that deferral is withheld rather than silently granted. In the meantime @qwen-code /verify is the one command that would close the remaining gap — the two unmeasured mutants named in Stage 2.

中文说明

Confidence: 4/5 —— 我能独立核对的论断全部成立,包括我原以为会被推翻的那一条;有两条非阻塞意见,且关键的两个套件 CI 尚未出结果。

回到我在读 diff 之前写下的独立方案:基本就是这个 PR。用字面量固定存储键、抽出一个导出的输入闸门加入口守卫使脚本可被 import、在存活路径下补回夹具、修正文档注释。第 2 项上 PR 比我的设想更好——review thread 给的是"对构建后的 CLI 包运行,或打桩该 import",而作者没有去 mock await import('@qwen-code/qwen-code/export'),而是把导出 API 作为参数传入 renderHtmlFromObjects。这是两个选项里更干净的一个,也消除了一整类 mock 漂移。

让我放心的是:我尝试推翻最关键的论断,没能成功。第 3 项的变异说法取决于 Shellfish.tsx 能否大小写不敏感地匹配到 shell;如果那两个正则区分大小写,这个夹具就是摆设,作者实测的 11 通过/1 失败也不可能出现。它们带 'i' 标志,所以成立——而"App.tsx 杀不死同一变异体"这一配套论断也成立,且出于另一个原因(shell 前面是 -(?:^|/) 永不匹配)。连续两处细节各自独立成立,这不是"听起来合理但内里空洞"的 PR 的样子。第 2 项的断言在 messages: [] 与最早时间戳 startTime 上也都正确——这两处极易写错,写错就会留下一个绿色但空壳的测试——指向同一结论。

两条我不会因此拒绝合并、但要说出口的意见:

入口守卫的失败是静默的。invokedDirectly 为假时脚本以 0 退出且什么都没写,而唯一的调用方只判断返回码就宣布成功。现实中的触发路径是符号链接。今天并不适用——runner.py 传的是绝对且非符号链接的路径——而且该守卫与 check-i18n.ts 中的既有写法一致,所以我并不要求修改。我指出来是因为这种失败会是安静的,而不是响亮的。

Stage 1 提出的关于 docs/verification/ 说明文件的疑问依然成立。它符合先例,写得很诚实——开头就说明哪些被验证,这比多数同类文件做得好——但它是一份交接文档,其价值在两条待完成通道出结果的那一刻就到期。保留没问题;只是这应当是一个有意识的选择,而不是默认做法。

关于来源,说一个诚实而非好听的读法:这四项源自本项目自己的 bot 发出的 /review 评论,而 #11076 由本 PR 作者创建。所以链条是 bot review → 作者的跟踪 issue → 作者的 PR,"某位维护者要求做这件事"并不完全等于其真实来源。这不改变我的结论,因为我对每一项都单独对照代码核查过,每一项在实质上都是正确的——今天那个 sessionStorage 键确实没有任何字面量固定,分类器也确实文档化了一个没有任何夹具覆盖的 Shellfish 案例。之所以说出来,是为了让批准者知道这份清单本身该被赋予多大权重。

本次不批准:Test (ubuntu-latest, Node 22.x)Lint & Static (ubuntu-latest, Node 22.x) 仍在进行中,而它们是唯二会执行两个新增 vitest 套件的通道。由于作者本地无法运行 vitest,现在批准等于为一个尚不存在的结果背书。批准已推迟至 CI 在 5e8d4c3f2435423c214f09f5d73758073af8d9d3 上全绿;若有任何一项变红或 head 发生移动,该推迟将被撤回,而不是默默放行。与此同时,@qwen-code /verify 是能补上剩余缺口的唯一命令——即 Stage 2 中点名的两个未实测变异体。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 5e8d4c3f2435423c214f09f5d73758073af8d9d3 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed. Suggestions are inline.

Not reviewed: build-and-test — Test (windows-latest, Node 22.x) and Test (macos-latest, Node 22.x) were skipped in CI and the new scripts suite was run on Linux only, so its non-Linux legs are unexercised.

Not reviewed: reverse audit — stopped before round 7 by the review time budget.

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

未审查(原文为英文):build-and-test — Test (windows-latest, Node 22.x) and Test (macos-latest, Node 22.x) were skipped in CI and the new scripts suite was run on Linux only, so its non-Linux legs are unexercised.

未审查:反向审计——评审时间预算不足,未能开始第 7 轮。

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

Comment on lines +189 to +191
const invokedDirectly =
typeof process.argv[1] === 'string' &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-1: This guard compares two different spellings of one path. path.resolve(process.argv[1]) is purely lexical, while Node realpaths the ESM main entry before setting import.meta.url — so when the invocation path carries a symlinked component the two differ, invokedDirectly is false, main() never runs, and the process exits 0 with empty stdout and stderr having written nothing. The same invocation works at the merge base, where main() ran unconditionally, so this is a regression rather than new surface. The only in-tree caller amplifies it: runner.py:332 builds its argv from Path(__file__).parent / <script>, and Python preserves a symlinked spelling instead of realpath-ing it, so a checkout reached through any symlinked path component (macOS /tmp/private/tmp, a symlinked home or workspace) reaches the guard as a divergent spelling — and runner.py:340-341 branches solely on returncode == 0, printing Rendered chat HTML saved: <name> for a file that was never written, with no warning line to fall back on. Nothing on an automated lane witnesses the guard's true branch either: no workflow mentions runner.py, and the new suite only imports the module, so the divergence is invisible to CI in both directions.

Witness:

PR arm    node <real path>/export-html-from-chatrecord-jsonl.js in.jsonl --out a.html
          -> "Wrote HTML export to: .../a.html"  exit=0  a.html exists? YES
PR arm    node /tmp/verify-r11-U7HJ/link.js in.jsonl --out b.html   (symlink -> same file)
          -> (no stdout, no stderr)  exit=0  b.html exists? NO
BASE arm  merge base f7479995, same symlink invocation
          -> "Wrote HTML export to: .../base.html"  exit=0  base.html exists? YES
          (base file has no invokedDirectly guard; its line 166 is `main().catch((error) => {`)
control   cd <symlinked dir> && node export-html-...js in.jsonl --out rel.html
          -> HTML written, exit=0  (process.cwd() is already physical, so argv[1] must carry the symlink)
python3   runner.py reached through a symlinked dir -> Path(__file__).parent keeps the link spelling
fix       realpath on the argv side, applied in a scratch tree
          -> symlink arm writes the HTML; import purity held; dependent suite 10 passed (10)
Suggested change
const invokedDirectly =
typeof process.argv[1] === 'string' &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
const invokedDirectly = (() => {
if (typeof process.argv[1] !== 'string') return false;
try {
// Node realpaths the ESM main entry, so both sides must be realpath'd.
return (
fs.realpathSync(process.argv[1]) ===
fs.realpathSync(fileURLToPath(import.meta.url))
);
} catch {
return false;
}
})();

The guard must still evaluate false when the module is imported: the comment at :187-188 states that contract, scripts/tests/export-html-from-chatrecord-jsonl.test.js:18 depends on it, and loadExportApi()'s ERR_MODULE_NOT_FOUND branch calls process.exit(1) at :22 — so the realpath call must not throw at import time, which is why the try/catch is part of the fix rather than optional (realpathSync raises ENOENT when argv[1] names no real file, as with node -e). Note also that dropping or rewriting path.resolve alone changes nothing observable: Node absolutizes argv[1] itself while keeping the symlink spelling, so the asymmetry is realpath-versus-not, not absolute-versus-relative.

The test that must pin this is a spawn case in scripts/tests/export-html-from-chatrecord-jsonl.test.js that symlinks the script into a temp dir and asserts node <symlink> is not a silent exit-0 no-op (non-empty stderr, or a non-zero exit) — please revert the realpath comparison afterwards and confirm that case goes red, since nothing spawns this script on any lane today.

中文说明

这个入口守卫比较的是同一个路径的两种拼写。path.resolve(process.argv[1]) 纯粹是词法解析,而 Node 在设置 import.meta.url 之前会对 ESM 主入口做 realpath——因此当调用路径中含有符号链接成分时两者不相等,invokedDirectly 为 false,main() 永不执行,进程在什么都没写的情况下以 0 退出,且 stdout 与 stderr 均为空。同样的调用在 merge base 上是正常的(那时 main() 无条件执行),所以这是本次改动引入的回归,而非新表面。

仓库内唯一的调用方放大了这个问题:runner.py:332Path(__file__).parent / <script> 构造 argv,而 Python 会保留符号链接拼写、不做 realpath,因此任何经由符号链接路径成分到达的检出(macOS 的 /tmp/private/tmp、被软链的 home 或工作区)都会以不一致的拼写抵达该守卫;随后 runner.py:340-341 仅凭 returncode == 0 就打印 Rendered chat HTML saved: <name>,为一个从未写出的文件报告成功,且没有任何 warning 可兜底。同时,没有任何自动化通道见证该守卫的真分支:没有 workflow 提到 runner.py,新增测试也只是 import 该模块,所以这个偏差在两个方向上对 CI 都是不可见的。

修复后守卫仍必须在模块被 import 时为 false::187-188 的注释声明了这一约定,scripts/tests/export-html-from-chatrecord-jsonl.test.js:18 依赖它,而 loadExportApi()ERR_MODULE_NOT_FOUND 分支会在 :22 调用 process.exit(1)——所以 realpath 调用不能在 import 期抛错,这正是 try/catch 属于修复本体而非可选项的原因(当 argv[1] 不指向真实文件时 realpathSync 会抛 ENOENT,例如 node -e)。另外请注意:仅去掉或改写 path.resolve 不会带来任何可观测变化,因为 Node 自己会把 argv[1] 绝对化但保留符号链接拼写——真正的不对称在于是否 realpath,而非是否绝对化。

用于钉住这一点的测试,应当是 scripts/tests/export-html-from-chatrecord-jsonl.test.js 中一个 spawn 用例:在临时目录里为该脚本创建符号链接,断言 node <symlink> 不是静默的 exit-0 空操作(stderr 非空,或退出码非零)。修好之后请把 realpath 比较改回去,确认该用例变红——因为目前没有任何通道会 spawn 这个脚本。

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

Comment on lines +160 to +162
it('requires every field the renderer reads', () => {
expect(looksLikeChatRecord(chatRecord())).toBe(true);
expect(looksLikeChatRecord(null)).toBe(false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-2-1: This test is named requires every field the renderer reads, but it discriminates only 3 of looksLikeChatRecord's 9 conjuncts (value !== null, 'parentUuid' in value, typeof value.sessionId === 'string'). The uuid, timestamp, type, cwd and version checks — and the outer typeof value === 'object' — can each be deleted with the whole suite staying green, because every other input in the file supplies a complete chatRecord(). The cost is concrete: a JSONL line missing cwd, or carrying a numeric timestamp, is then admitted past the gate this PR newly extracts and handed to the export API's document allowlist, which is exactly the boundary selectChatRecords' own JSDoc says only well-formed source ChatRecords may cross. Its sibling block at :146-148 has the same shape and is reported alongside this one.

Witness:

looksLikeChatRecord (src :81-89), each conjunct deleted in turn, suite re-run:
  :81 value !== null            killed     :86 timestamp  SURVIVES (10 passed)
  :82 typeof value === 'object' SURVIVES   :87 type       SURVIVES (10 passed)
  :83 uuid                      SURVIVES   :88 cwd        SURVIVES (10 passed)
  :84 'parentUuid' in value     killed     :89 version    SURVIVES (10 passed)
  :85 sessionId                 killed     -> 6 of 9 survive
patched per-conjunct sweep -> intact 10 passed (10); all 7 killable conjuncts killed
it('requires every field the renderer reads', () => {
  expect(looksLikeChatRecord(chatRecord())).toBe(true);
  expect(looksLikeChatRecord(null)).toBe(false);
  // parentUuid may be null, but the key has to be present.
  const { parentUuid: _dropped, ...withoutParent } = chatRecord();
  expect(looksLikeChatRecord(withoutParent)).toBe(false);
  expect(looksLikeChatRecord(chatRecord({ parentUuid: null }))).toBe(true);
  for (const field of ['uuid', 'sessionId', 'timestamp', 'type', 'cwd', 'version']) {
    expect(looksLikeChatRecord(chatRecord({ [field]: 7 })), field).toBe(false);
  }
});

'parentUuid' in value at src :84 is a presence check rather than a type check, so parentUuid must stay out of the type loop and chatRecord({ parentUuid: null }) must stay true — otherwise the fix silently converts a presence contract into a type one.

The test that must pin this is this same block: with the loop added, removing any one of the six typeof ... === 'string' checks from looksLikeChatRecord has to turn it red and name the field — please delete one and confirm.

中文说明

这个测试名为 requires every field the renderer reads,但它只区分了 looksLikeChatRecord 九个合取项中的三个(value !== null'parentUuid' in valuetypeof value.sessionId === 'string')。uuidtimestamptypecwdversion 这几个检查,以及外层的 typeof value === 'object',每一个都可以被删掉而整套测试依然全绿——因为文件中其他所有输入都提供了完整的 chatRecord()。代价是具体的:缺少 cwd 的行、或 timestamp 为数字的行,就会越过本 PR 新抽取出的这道闸门,被交给导出 API 的文档白名单,而这正是 selectChatRecords 自己的 JSDoc 所说「只有格式正确的源 ChatRecord 才能通过」的那道边界。位于 :146-148 的姊妹代码块形态相同,与本条一并报告。

约束:src :84'parentUuid' in value 是存在性检查而非类型检查,所以 parentUuid 必须留在类型循环之外,且 chatRecord({ parentUuid: null }) 必须仍为 true——否则这个修复会把「存在性约定」悄悄变成「类型约定」。

用于钉住这一点的测试就是本代码块自身:加上循环之后,从 looksLikeChatRecord 中删掉六个 typeof ... === 'string' 检查里的任意一个,都必须让它变红并指出是哪个字段——请删掉一个确认。

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

Comment on lines +146 to +148
describe('looksLikeExportJsonl', () => {
it('matches only the legacy metadata header shape', () => {
expect(looksLikeExportJsonl([legacyMetadata()])).toBe(true);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-2-2: Same pattern as the looksLikeChatRecord block below, and this half has a user-visible consequence. The block probes 2 of looksLikeExportJsonl's 5 conjuncts (only type and startTime vary), so first !== null at src :96, typeof first === 'object' at :97 and typeof first.sessionId === 'string' at :99 can each be deleted with the suite green. Deleting the first !== null guard is not just a lost type check: it turns a domain error into a crash for a JSONL file whose first line is the literal null, which readJsonlObjects happily pushes because JSON.parse('null') yields null and the blank-line skip does not drop it — so the diagnostic the CLI prints changes.

Witness:

looksLikeExportJsonl (src :96-100), each conjunct deleted in turn:
  :96  first !== null                    SURVIVES (10 passed)
  :97  typeof first === 'object'         SURVIVES (10 passed)
  :98  first.type === 'session_metadata' killed
  :99  typeof first.sessionId === 'string' SURVIVES (10 passed)
  :100 typeof first.startTime === 'string' killed
src :96 deleted, real CLI on a file whose only line is `null`:
  intact -> CLI stderr "Unrecognized JSONL format (expected ChatRecord-per-line)."
  mutant -> CLI stderr "Cannot read properties of null (reading 'type')"
patched sweep -> intact 11 passed (11); :96 and :99 mutants killed; :97 still survives
it('requires every field of the legacy header, and survives a null line', () => {
  expect(looksLikeExportJsonl([legacyMetadata()])).toBe(true);
  expect(looksLikeExportJsonl([chatRecord()])).toBe(false);
  expect(looksLikeExportJsonl([legacyMetadata({ type: 'user' })])).toBe(false);
  expect(looksLikeExportJsonl([legacyMetadata({ sessionId: 7 })])).toBe(false);
  expect(looksLikeExportJsonl([legacyMetadata({ startTime: undefined })])).toBe(false);
  expect(looksLikeExportJsonl([null])).toBe(false);
  // The null guard is what keeps a `null` first line a domain error, not a TypeError.
  expect(() => selectChatRecords([null])).toThrow(
    'Unrecognized JSONL format (expected ChatRecord-per-line).',
  );
});

Two constraints on the shape of that fix. selectChatRecords is synchronous (export function selectChatRecords(objects) at src :143) while renderHtmlFromObjects is async (src :165), so the [null] case must be expect(() => ...).toThrow(...) and not rejects — a rejects on a non-promise fails for the wrong reason. And looksLikeExportJsonl(objects) reads objects[0] (src :93-94), so every per-field variant has to stay wrapped in a one-element array; passing the bare object would make objects[0] the first property value and turn the sweep vacuous. legacyMetadata(overrides = {}) at :36 already accepts overrides, so this compiles as written.

One bound on the claim, so the fix is not over-sold: src :97's typeof first === 'object' is not killable by any input JSON.parse can produce, because for a primitive first.type boxes to undefined and fails the 'session_metadata' comparison anyway. The sweep above therefore pins 2 of this predicate's 3 survivors, not all 5 conjuncts.

The tests that must pin this are the two added assertions: deleting src :96 or src :99 has to turn this block red — please delete each in turn and confirm.

中文说明

与下方 looksLikeChatRecord 代码块是同一类问题,而这一半带有用户可见的后果。该块只探测了 looksLikeExportJsonl 五个合取项中的两个(只有 typestartTime 变化),因此 src :96first !== null:97typeof first === 'object':99typeof first.sessionId === 'string' 每一个都可以被删掉而测试全绿。删掉 first !== null 守卫不只是少了一个类型检查:对于首行是字面量 null 的 JSONL 文件,它会把一个领域错误变成崩溃——readJsonlObjects 会照常推入这个值,因为 JSON.parse('null') 得到 null,而空行跳过逻辑不会丢弃它——于是 CLI 打印的诊断信息变了。

修复形态有两条约束。selectChatRecords 是同步的(src :143export function selectChatRecords(objects)),而 renderHtmlFromObjectsasync(src :165),所以 [null] 用例必须写成 expect(() => ...).toThrow(...) 而不是 rejects——对非 Promise 使用 rejects 会以错误的理由失败。另外 looksLikeExportJsonl(objects) 读取的是 objects[0](src :93-94),所以每个按字段变形的用例都必须保持包在单元素数组里;直接传对象会让 objects[0] 变成第一个属性值,使整个扫描失效。:36legacyMetadata(overrides = {}) 已接受覆盖参数,因此上面的代码可以直接编译。

对结论的一个边界,以免修复被说过头:src :97typeof first === 'object' 无法被任何 JSON.parse 能产生的输入杀死,因为对原始值而言 first.type 会装箱为 undefined,本来就无法通过 'session_metadata' 比较。因此上面的扫描钉住的是该谓词三个存活项中的两个,而不是全部五个合取项。

用于钉住这一点的测试就是新增的那两条断言:删掉 src :96 或 src :99 都必须让本块变红——请逐个删除并确认。

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

Comment on lines +233 to +235
* `ChatEditor` declares exactly these three props, typed from this
* interface; `ChatPane` and `App` are the two in-tree hosts that call
* this hook and thread them down.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-4: This docblock ships on a publicly exported hook and tells consumers to render ChatEditor, but ChatEditor is not a value export reachable from either package entry, so step 1 of the Consumers: list cannot be followed from outside the repo. client/index.tsx:174 holds the only public reference to that module and it re-exports a different symbol as a type only; daemon-react-sdk.ts never mentions it. The text lands verbatim in the published declaration file, which is the API doc surface an integrator's editor shows on hover — so replacing the stale InputForm reference with an unreachable one trades a deleted component for an unimportable one.

Witness:

import('@qwen-code/web-shell')
  total value exports: 55 | ChatEditor present? false | useDaemonFollowupSuggestion present? true
  component-ish: StandaloneWebShell, WebShell, WebShellTranscript, WebShellWithProviders
import('@qwen-code/web-shell/daemon-react-sdk')
  value exports: 47 | ChatEditor present? false | useDaemonFollowupSuggestion present? true
grep -c ChatEditor packages/web-shell/dist/index.js -> 0
import { ChatEditor } from '@qwen-code/web-shell';
  -> SyntaxError: The requested module '@qwen-code/web-shell' does not provide an export named 'ChatEditor'
import('@qwen-code/web-shell/dist/index.js')
  -> Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './dist/index.js' is not defined by "exports"
client/index.tsx:174 -> export type { ComposerToolbarAction } from './components/ChatEditor';
ships verbatim: dist/types/daemon/useDaemonFollowupSuggestion.d.ts:58-62
Suggested change
* `ChatEditor` declares exactly these three props, typed from this
* interface; `ChatPane` and `App` are the two in-tree hosts that call
* this hook and thread them down.
* `ChatEditor` accepts these three as optional props typed from this
* interface (`ChatEditor.tsx:271-273`); it is internal to the package and
* is not part of its public export surface. `ChatPane` and `App` are the
* two in-tree hosts that call this hook and thread them down.

The constraint on the rewording is packages/web-shell/client/index.tsx:174export type { ComposerToolbarAction } from './components/ChatEditor'; is the only public reference to that module, and package.json declares exactly two export entries (. and ./daemon-react-sdk), so the replacement text must not imply a value export exists. If integrators are meant to compose it, the alternative fix is to export the component; the in-tree half of the sentence is accurate either way (ChatEditor.tsx:271-273 really does declare those three optional props typed from this interface, and ChatPane.tsx:518 / App.tsx:7020 really are the only two in-tree call sites).

中文说明

这段文档注释挂在一个公开导出的 hook 上,并指示使用方渲染 ChatEditor,但 ChatEditor 并不是从包的任一入口可达的值导出,因此仓库外部无法照做 Consumers: 列表的第 1 步。client/index.tsx:174 是该模块唯一的公开引用,而它只是以「仅类型」的方式再导出了另一个符号;daemon-react-sdk.ts 则完全没有提到它。这段文字会原样进入已发布的声明文件,也就是集成方编辑器悬停时看到的 API 文档面——所以把过期的 InputForm 引用换成一个不可达的引用,等于把「已删除的组件」换成了「无法 import 的组件」。

改写的约束是 packages/web-shell/client/index.tsx:174——export type { ComposerToolbarAction } from './components/ChatEditor'; 是该模块唯一的公开引用,且 package.json 只声明了两个导出入口(../daemon-react-sdk),因此替换文案不得暗示存在值导出。如果确实希望集成方来组合它,另一种修复是把该组件导出。无论哪种,句子的仓库内部分都是准确的:ChatEditor.tsx:271-273 确实声明了这三个来自本接口的可选 props,ChatPane.tsx:518App.tsx:7020 也确实是该 hook 仅有的两个仓库内调用点。

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

Comment on lines +39 to +40
the whole point, and the review that asked for this measured the same thing
(15/15 green under the mutant before, 15 pass / 1 fail after).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-5-1: The predicted mutation result here is not what the suite produces, and it contradicts this same paragraph three lines above. The post-change file holds 18 tests (15 before this PR, 3 added) and all three added tests spell the literal prefix — including percent-encodes the session id in the key, which :36-38 correctly says "goes red too". So the rename mutant yields 15 pass / 3 fail, never 15 pass / 1 fail. This matters because the brief's own report-back instruction at :112-114 tells whoever executes the handoff that a mutation not going red as described "is the important result — say so": an executor who obeys it sees three reds against a documented expectation of one and must report a contradiction the document manufactured, or discount the one artifact whose stated purpose is to be trusted over static reasoning.

Witness:

mutant: clientLifecycle.ts:8 'qwen-code-webui-client-id:session:'
        -> 'qwen-code-web-shell-client-id:session:'
BASE arm (HEAD~1 test file, 15 its) -> Tests 15 passed (15)
PR   arm (18 its)                   -> Tests 3 failed | 15 passed (18)
  x persistStableClientId > writes under the historical WebUI key
  x persistStableClientId > reads an id a WebUI-era tab left under the historical key
  x persistStableClientId > percent-encodes the session id in the key
PR intact                           -> Tests 18 passed (18)
Suggested change
the whole point, and the review that asked for this measured the same thing
(15/15 green under the mutant before, 15 pass / 1 fail after).
the whole point, and the review that asked for this measured the same thing
(15/15 green under the mutant before, 15 pass / 3 fail after).

The corrected count is what an executor compares their own run against, so it has to match what the suite actually prints — 3 failed | 15 passed (18) — rather than the number of tests the paragraph happens to name.

中文说明

此处预测的变异结果并不是测试套件实际产生的结果,而且它与同一段落上方三行的内容自相矛盾。改动后的文件共有 18 个测试(本 PR 之前 15 个,新增 3 个),而三个新增测试都以字面量拼出了该前缀——包括 percent-encodes the session id in the key:36-38 也正确写了它「同样会变红」。所以重命名变异体产生的是 15 通过 / 3 失败,绝不会是 15 通过 / 1 失败。这一点之所以重要,是因为本说明自己在 :112-114 的回报要求里写着:变异若未按描述变红,「那就是重要结果——请说明」。照此执行的验证者会看到三个红灯、而文档预期只有一个,于是要么报告一个由文档自己制造的矛盾,要么放弃这份「本应比静态推理更可信」的材料。

修正后的数字是执行者用来比自己运行结果的基准,因此它必须与套件实际输出的内容一致——3 failed | 15 passed (18)——而不是段落里恰好点到的测试数量。

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

Comment on lines +74 to +75
exported JSONL with the exact message` and `never reaches the renderer for
legacy exported JSONL` go red. Note the failure is _not_ an absence of an

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-5-2: Same pattern as item 1's count, in the other prescribed mutation. Deleting the throw in selectChatRecords's looksLikeExportJsonl branch reddens three tests, not the two named here — and the omitted third one fails for precisely the reason the next sentence rules out. rejects on the first line alone, even with real records behind it (test :83-86) feeds [legacyMetadata(), chatRecord()], so with the throw gone objects.filter(looksLikeChatRecord) keeps the trailing record, records.length !== 0, and selectChatRecords returns normally: that failure is an absence of an error. An executor following the report-back instruction at :112-114 therefore sees an unexpected third red whose failure mode the brief declared impossible, and may conclude that test over-reaches and weaken it — it is the only assertion pinning that a legacy header is refused when real records follow it. To be fair to the note: it is accurate for the two tests it names (both failed on message mismatch, not absence) and its scope word is "a legacy-only input", so the fix is to name three reds and scope the sentence.

Witness:

mutant: delete the throw in selectChatRecords' looksLikeExportJsonl branch (src :146-150)
PR arm -> Tests 3 failed | 7 passed (10)
  x rejects legacy exported JSONL with the exact message
      expected ... to throw error including 'Legacy exported JSONL cannot be rende...'
      but got 'Unrecognized JSONL format (expected C...'      <- as documented
  x never reaches the renderer for legacy exported JSONL      <- as documented, same mismatch
  x rejects on the first line alone, even with real records behind it
      AssertionError: expected [Function] to throw an error
      - Expected: null   + Received: undefined                <- UNDOCUMENTED, and IS an absence of error
intact -> Tests 10 passed (10)
`selectChatRecords`'s `looksLikeExportJsonl` branch. Expected: `rejects legacy
exported JSONL with the exact message`, `rejects on the first line alone, even
with real records behind it`, and `never reaches the renderer for legacy
exported JSONL` go red (7 pass / 3 fail). For the two legacy-only inputs the
failure is _not_ an absence of an error: with the throw gone they fall through
to the filter and raise `Unrecognized JSONL format` instead. The mixed input is
the exception — the filter keeps the trailing record, so `selectChatRecords`
returns normally and that one fails on a missing error.

The third title has to be spelled exactly as the suite spells it (rejects on the first line alone, even with real records behind it, test :83) because an executor greps the run output for these names; and the trailing claim that the test "must be asserting the exact message — it is" does not hold either, which is reported separately against the test file itself.

中文说明

与第 1 项的计数是同一类问题,出现在另一个指定的变异上。删掉 selectChatRecordslooksLikeExportJsonl 分支的 throw 会让三个测试变红,而不是此处点名的两个——而被漏掉的第三个,其失败原因恰恰是下一句话所排除的那种。rejects on the first line alone, even with real records behind it(测试 :83-86)的输入是 [legacyMetadata(), chatRecord()],所以 throw 消失后 objects.filter(looksLikeChatRecord) 会保留后面那条真实记录,records.length !== 0selectChatRecords 正常返回:这个失败就是「没有抛出错误」。因此,按 :112-114 的回报要求执行的验证者会看到一个意料之外的第三个红灯,而它的失败模式正是本说明宣称不可能出现的那种,于是可能判断该测试过度约束并去削弱它——而它是唯一钉住「后面还有真实记录时,遗留头部仍须被拒绝」的断言。为这句说明说句公道话:对它点名的两个测试它是准确的(两者都因文案不匹配而失败,而非缺少错误),且它的限定词是「仅含遗留数据的输入」,所以修复方式是点名三个红灯并把这句话限定范围。

第三个测试标题必须与套件中的写法完全一致(rejects on the first line alone, even with real records behind it,测试 :83),因为执行者会在运行输出中按这些名字检索;此外,末尾那句「测试必须断言精确文案——它确实如此」同样不成立,这一点已针对测试文件本身另行报告。

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

});

describe('selectChatRecords', () => {
it('rejects legacy exported JSONL with the exact message', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-6: This test is named rejects legacy exported JSONL with the exact message, but vitest's toThrow(string) is a substring check rather than an equality check, so the message is not pinned exactly — and the verification brief reasons from the same premise at README.md:77-78 ("so the test must be asserting the exact message — it is — or the mutant survives"). Any edit that keeps the rejection text as a substring, such as prepending Invalid input: or appending a remediation hint, leaves the suite green while the user-visible CLI message drifts. That message is a real surface: runner.py:343 prints it as Warning: HTML exporter failed: {stderr}. Worth being precise about what this does and does not affect — the brief's own named mutant still dies, because the fall-through raises Unrecognized JSONL format (expected ChatRecord-per-line)., which does not contain the legacy substring; what fails is the exactness claim, not the mutation coverage.

Witness:

vitest pinned ^3.2.4 (root package.json:169), resolved RUN v3.2.7
probe suite run under scripts/tests/vitest.config.ts:
  PROBE-A message prefixed  + toThrow(string)    -> passed: true
  PROBE-B message suffixed  + toThrow(string)    -> passed: true
  PROBE-C message drifted   + toThrow(string)    -> passed: false   (control: the probe is not vacuous)
  PROBE-D message prefixed  + toThrow(new Error) -> passed: false
  PROBE-E message exact     + toThrow(new Error) -> passed: true
vitest's own failure wording: "expected [Function] to throw error including 'Legacy exported JSONL cannot be rende...'"
the brief's named mutant under the substring form -> Tests 3 failed | 7 passed (10)  (still killed)
expect(() => selectChatRecords([legacyMetadata()])).toThrow(
  new Error(LEGACY_REJECTION),
);

Passing an Error makes vitest compare by message equality (measured: PROBE-D rejects the prefixed variant while PROBE-E accepts the exact one). The brief's named mutant must still die after the tightening — it does, because deleting the throw makes the code fall through to Unrecognized JSONL format (expected ChatRecord-per-line). at src :151-157. LEGACY_REJECTION is declared at :20-21 and is used by three assertions, so changing its value is not the fix. If exactness is wanted on the render path too, the same change applies to the renderHtmlFromObjects rejection below.

The test that must pin this is this same assertion: with new Error(LEGACY_REJECTION), a mutant that prepends or appends a clause to the thrown message has to turn it red — please apply one and confirm.

中文说明

这个测试名为 rejects legacy exported JSONL with the exact message,但 vitest 的 toThrow(string) 是子串匹配而非相等匹配,所以文案并没有被精确钉住——而验证说明在 README.md:77-78 也基于同一前提推理(「所以测试必须断言精确文案——它确实如此——否则变异体会存活」)。任何保留该拒绝文案作为子串的改动,例如在前面加上 Invalid input: 或在后面追加补救提示,都会让套件保持全绿,而用户可见的 CLI 文案已经漂移。这个文案是真实的对外表面:runner.py:343 会以 Warning: HTML exporter failed: {stderr} 打印它。需要说清楚它影响与不影响的范围——说明中自己点名的那个变异体仍会被杀死,因为回退路径抛出的是 Unrecognized JSONL format (expected ChatRecord-per-line).,其中并不包含遗留文案的子串;失效的是「精确」这个论断,而不是变异覆盖。

传入一个 Error 会让 vitest 按消息相等来比较(实测:PROBE-D 拒绝了加前缀的变体,PROBE-E 接受了精确文案)。收紧之后,说明中点名的变异体必须仍会被杀死——它确实会,因为删掉 throw 会让代码回退到 src :151-157Unrecognized JSONL format (expected ChatRecord-per-line).LEGACY_REJECTION 声明于 :20-21 并被三处断言使用,所以修改它的值不是修复方式。如果也希望在渲染路径上做到精确,下面的 renderHtmlFromObjects 拒绝断言可作同样修改。

用于钉住这一点的测试就是这条断言自身:改成 new Error(LEGACY_REJECTION) 之后,给抛出的消息加前缀或后缀的变异体必须让它变红——请施加一个并确认。

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

Comment on lines +124 to +125
const [sessionData, passedRecords] = api.toHtml.mock.calls[0];
expect(passedRecords).toEqual(records);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-10: This happy-path test feeds renderHtmlFromObjects an input in which every element is already a valid ChatRecord, so records (objects.filter(looksLikeChatRecord)) is structurally identical to objects and neither assertion below can tell them apart. The filtering step at the render boundary is therefore unpinned: passing the unfiltered array to the renderer instead of the filtered one leaves all 10 tests green. The regression that would ship is the one the module's own JSDoc at src :136-142 says the filtering exists to prevent — an unfiltered entry reaching the page, including a legacy session_metadata header that is not in first position and so never trips looksLikeExportJsonl, putting already-rendered markup back through a path the export API's document allowlist never sees.

Witness:

INTACT baseline                                     -> Tests 10 passed (10)
MUTANT A src:171 api.toHtml(sessionData, objects)    -> Tests 10 passed (10)  SURVIVES
MUTANT B src:168 buildProductSessionData(objects)    -> Tests 10 passed (10)  SURVIVES
mixed-input fix + MUTANT A -> FAIL: passedRecords + {"note": "not a record"}
mixed-input fix + MUTANT B -> FAIL: metadata messageCount 1 -> 2
mixed-input fix + INTACT   -> PASS (the call resolved; it did not reject)
harm under MUTANT A, legacy header not in first position:
  INTACT   -> toHtml received: [{"uuid":"rec-1",...,"type":"user",...}]
  MUTANT A -> toHtml received: [{"uuid":"rec-1",...},{"type":"session_metadata","sessionId":"sess-1",...}]
const api = stubExportApi();
const recA = chatRecord({ uuid: 'rec-1', timestamp: '2026-01-02T03:04:05.000Z' });
const recB = chatRecord({ uuid: 'rec-2', timestamp: '2026-01-02T02:00:00.000Z' });

const html = await renderHtmlFromObjects([recA, { note: 'not a record' }, recB], api);

expect(html).toBe('<html>rendered</html>');
expect(api.collectSessionMetadata).toHaveBeenCalledWith(
  expect.objectContaining({
    sessionId: 'sess-1',
    startTime: '2026-01-02T02:00:00.000Z',
    messages: [recA, recB],
  }),
  expect.anything(),
);
const [sessionData, passedRecords] = api.toHtml.mock.calls[0];
expect(passedRecords).toEqual([recA, recB]);

selectChatRecords throws only when the filter empties the array (src :151-157), so a mixed array containing at least one real record returns normally — measured: the intact probe awaited without rejecting. That is what makes this a resolve-and-assert case rather than a rejects case, and it keeps the earliest-timestamp assertion meaningful because recB is still the earlier of the two.

The test that must pin this is renders the ChatRecord happy path through the export API with the mixed input: changing src :171 to api.toHtml(sessionData, objects) has to turn the passedRecords assertion red — please apply that mutant and confirm.

中文说明

这个正常路径测试喂给 renderHtmlFromObjects 的输入中,每个元素本身就已经是合法的 ChatRecord,因此 records(即 objects.filter(looksLikeChatRecord))与 objects 在结构上完全相同,下面两条断言都无法区分二者。于是渲染边界上的过滤这一步没有被钉住:把未过滤的数组而不是过滤后的数组传给渲染器,10 个测试仍然全绿。会因此上线的回归,正是模块自己在 src :136-142 的 JSDoc 中所说、过滤存在的目的——未经过滤的条目进入页面,包括一个不在首行、因而永远不会触发 looksLikeExportJsonl 的遗留 session_metadata 头部,使已经渲染过的标记重新走上一条导出 API 文档白名单看不到的路径。

selectChatRecords 只有在过滤后数组为空时才抛错(src :151-157),所以只要混合数组中还有一条真实记录,它就会正常返回——实测:完整实现的探针是正常 await 完成、并未 reject。这正是本用例应写成「解析后断言」而非 rejects 的原因;同时它也让「取最早时间戳」的断言继续有效,因为 recB 仍是两者中更早的那个。

用于钉住这一点的测试,是使用混合输入的 renders the ChatRecord happy path through the export API:把 src :171 改成 api.toHtml(sessionData, objects) 必须让 passedRecords 断言变红——请施加该变异并确认。

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

Comment on lines +114 to +116
// The suffix is part of the persisted shape too: a session id carrying `/`
// or `:` would otherwise collide with the prefix's own separator.
persistStableClientId('client-slash', 'work/space:1');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-13: This is the only test in the file that uses a session id needing percent-encoding, and it asserts the persisted key by reading window.sessionStorage directly — so the encoded-key contract is pinned on the write side alone and no round-trip observes it. If a future edit derives the read key without the write's encodeURIComponent, every session whose id carries / or : silently misses: the write lands under the encoded key while both readers look under the raw one, so getStableClientId(undefined, 'work/space:1') mints a fresh webui_<uuid> and the daemon sees a new X-Qwen-Client-Id for the same controller. That is precisely the outcome the block comment at :86-93 says these tests exist to prevent, and nothing in the package goes red — the mutant survives the entire directory. Such ids are a shape this file already expects: detachDaemonClient builds /session/${encodeURIComponent(opts.sessionId)}/detach at :71-73. Separately, the comment's stated rationale cannot occur — concatenating a fixed prefix with a raw id is already injective (measured: 0 collisions across 14 hostile ids containing :, /, the separator itself and the whole prefix), and nothing in packages/web-shell enumerates or splits these keys. What the encoding actually buys is byte-compatibility with what a WebUI-era tab wrote: the historical builder at 0f86ff183e used the same prefix and the same encodeURIComponent. To be clear this test is not inert — it does go red under the prefix-rename mutant the brief prescribes; this is one uncovered axis on a test that already bites.

Witness:

MUTANT: getStableClientId/getPersistedClientId build the key without encodeURIComponent,
        persistStableClientId keeps sessionClientIdKey
  clientLifecycle.test.ts      -> Tests 18 passed (18)                    mutant survives this file
  client/daemon/session/ (all) -> Test Files 11 passed (11) / Tests 558 passed (558)   survives the directory
  round-trip probe             -> getStableClientId('work/space:1') -> webui_adca6209-aa8a-41c4-89e5-94b2cd07a67f
                                  getPersistedClientId('work/space:1') -> undefined     2 failed
INTACT round-trip probe        -> both -> 'client-slash'   2 passed   (directory total 560)
sweep: 5 session ids reach the storage key in any web-shell test; 4 are encoding-invariant
  (session-a, session-b, session-missing, session-old) and the 1 that is not reads sessionStorage directly
historical builder, 0f86ff183e packages/webui/src/daemon/session/clientLifecycle.ts:69:
  return `${WEBUI_SESSION_CLIENT_ID_PREFIX}${encodeURIComponent(sessionId)}`;
    // The suffix is part of the persisted shape too, and an id that needs
    // encoding is the only shape where a read/write key divergence is visible.
    persistStableClientId('client-slash', 'work/space:1');

    expect(
      window.sessionStorage.getItem(
        'qwen-code-webui-client-id:session:work%2Fspace%3A1',
      ),
    ).toBe('client-slash');
    // Pin the read side of the same derivation, not just the write.
    expect(getStableClientId(undefined, 'work/space:1')).toBe('client-slash');
    expect(getPersistedClientId('work/space:1')).toBe('client-slash');

The block comment at :86-93 says the key is spelled out instead of imported on purpose, so the read-back must not import SESSION_CLIENT_ID_STORAGE_PREFIX — and clientLifecycle.ts:88 is the single key builder today, which the fix must not fork into a second helper.

The tests that must pin this are the two added read-back assertions: with the read paths skipping encodeURIComponent while the write keeps sessionClientIdKey, both have to go red, where today that mutant leaves 18/18 and the directory's 558/558 green — please apply it and confirm.

中文说明

这是文件中唯一一个使用需要百分号编码的 session id 的测试,而它是通过直接读取 window.sessionStorage 来断言持久化的键——因此编码后的键这一约定只在写入侧被钉住,没有任何往返测试观察到它。如果将来某次改动在读取侧不再使用写入侧的 encodeURIComponent,那么所有 id 中含 /: 的 session 都会静默失配:写入落在编码后的键下,而两个读取函数去找未编码的键,于是 getStableClientId(undefined, 'work/space:1') 会新铸造一个 webui_<uuid>,守护进程便会为同一个控制器看到全新的 X-Qwen-Client-Id。这恰恰是 :86-93 的块注释所说、这些测试存在的目的要防止的结果,而整个 package 没有任何测试变红——该变异体在整个目录下都存活。这类 id 也是本文件已经预期的形态:detachDaemonClient:71-73 构造 /session/${encodeURIComponent(opts.sessionId)}/detach。另外,注释所给的理由其实不会发生——把固定前缀与原始 id 拼接本身已是单射(实测:对 14 个包含 :/、分隔符本身以及整个前缀的恶意 id,碰撞数为 0),且 packages/web-shell 中没有任何地方枚举或拆分这些键。编码真正买到的是与 WebUI 时期标签页写入内容的字节兼容:0f86ff183e 中的历史构造函数使用了相同的前缀和相同的 encodeURIComponent。需要说明的是,这个测试并非无效——在说明所指定的前缀重命名变异下它确实会变红;这里说的是一个已经「咬得住」的测试上尚未覆盖的那一个轴。

:86-93 的块注释写明该键是刻意以字面量拼出而非 import 的,所以回读断言不得 import SESSION_CLIENT_ID_STORAGE_PREFIX——而 clientLifecycle.ts:88 目前是唯一的键构造函数,修复不得把它分叉成第二个辅助函数。

用于钉住这一点的测试是新增的两条回读断言:当读取路径跳过 encodeURIComponent 而写入路径仍使用 sessionClientIdKey 时,两条都必须变红;而今天这个变异体会让 18/18 以及整个目录的 558/558 保持全绿——请施加该变异并确认。

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

@wenshao

wenshao commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — reproduced locally, including the two suites the branch could not run

Tested at 5e8d4c3f2435423c214f09f5d73758073af8d9d3 (merge-base f747999539), Linux, Node v22.22.2, in a dedicated worktree.

The description is explicit that the two vitest suites were never run and that CI is the authority on items 1 and 2. That is exactly the gap I closed. I ran all four items, applied both mutations the brief asks for, and added the counterfactual arm for each — the arm that shows the mutant survived before this PR, which is the claim that actually matters.

Verdict: every claim in the PR description holds. One non-blocking finding, unrelated to the four items, is below.

Results

# Item Claimed Measured
1 Historical sessionStorage key not run 18/18 pass
1M └ mutation (clientLifecycle.ts:8 prefix rename) 3 red 3 failed / 15 passed — exactly the 3 new tests
1C └ counterfactual (same mutant, merge-base test file) 15/15 pass — mutant survived
2 ChatRecord export script not run 10/10 pass
2M └ mutation (drop the legacy-JSONL throw) 2 red 3 failed / 7 passed (one better than predicted)
3 Classifier word boundary 12 / 11+1 / 12 12 pass 0 fail · 11 pass 1 fail · 12 pass 0 fail — matches row for row
4 Hook documentation grep → 0 0, and the docblock's claims check out (below)

mutation matrix

Three things worth calling out, because they are the substance rather than the pass counts:

Item 1's counterfactual is the whole argument, and it lands. Under the renamed prefix, the merge-base test file reports 15/15 green. The suite genuinely could not see that rename. The three literal-key assertions are the only thing that turns it red — "invisible to the suite by construction" is measured, not rhetorical.

Item 2's mutant really does fall through, verbatim. The failure text is expected [Function] to throw error including 'Legacy exported JSONL cannot be rende…' but got 'Unrecognized JSONL format (expected C…'. A test that only asserted that it throws would have passed. The exact-message assertion is load-bearing, precisely as the description argues. A third test (rejects on the first line alone) also dies, one more than the brief predicted.

Item 4's docblock is accurate, not merely InputForm-free. ChatEditor.tsx:271-273 declares exactly the three props, each typed from UseDaemonFollowupSuggestionReturn; and ChatPane.tsx:518 / App.tsx:7020 are the only two real call sites — the other two hits (daemon-react-sdk.ts, daemon/index.ts) are re-export barrels. Both halves of the new sentence are true.

The flagged risk, measured: the CLI is byte-for-byte unchanged

The description names the export-script refactor as the main risk and says the CLI path is unchanged. I tested that rather than taking it, because the built @qwen-code/qwen-code/export in this tree predates collectSessionMetadata. I ran both script versions as real subprocesses against a recording stand-in for the export API, over five input shapes:

  • identical exit codes and identical stderr on all five (happy / legacy / unrecognized / empty / no-args);
  • on the happy path, the recorded API call arguments and the written HTML are identical SHA1 across arms.

So the refactor changes nothing about what the CLI does, what it hands the export API, or what it writes. Confirmed, not assumed.

CLI A/B and finding

Finding (non-blocking): the new entry-point guard is symlink-fragile, and fails silently

This is the one place PR behaviour differs from merge-base. The guard is

path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)

Node resolves the main module through realpath by default, so import.meta.url is the real path — while path.resolve() does not resolve symlinks. Invoked through a symlink, the two differ, the guard is false, and main() never runs: exit 0, no output, no error. Merge-base renders the file. Measured on Node v22.22.2:

node pr.js …               base: runs   PR: runs
node /abs/path/pr.js …     base: runs   PR: runs
node ./pr.js …             base: runs   PR: runs
node symlink-to-pr.js …    base: runs   PR: NO-OP  (exit 0, no output)

--preserve-symlinks-main inverts the mismatch, which confirms the mechanism.

Severity: low, and I would not hold the PR for it. Nothing in the repo invokes this script through a symlink; it has no package.json bin entry, and its only callers are humans following the README. But it is worth a line because the failure is silent and because "the CLI behaves exactly as before" is otherwise true in every case I could construct. Comparing realpaths on both sides fixes it, and I probed the fix across all four invocation forms — all run, and import-only still executes nothing:

const invokedDirectly =
  typeof process.argv[1] === 'string' &&
  fs.realpathSync(path.resolve(process.argv[1])) ===
    fs.realpathSync(fileURLToPath(import.meta.url));

CI red is not this PR

Both failing jobs are in files this PR does not touch:

A re-run should clear both.

Also checked

  • npm run test:scripts (the full CI lane): the new file passes 10/10; the lane's 5 other failures are identical on the merge-base — environmental (this box runs as uid 0, which defeats the "unwritable directory" tests).
  • CI coverage claims are real: test:scripts is vitest run --config ./scripts/tests/vitest.config.ts with include: ['scripts/tests/**/*.test.{js,ts}'] (the new file matches, and is not in the Windows exclude list), and test:ci = test:ci:workspaces && test:scripts. classify-platform-sensitivity.test.mjs is in HELPER_TESTS in ci.yml.
  • packages/web-shell typecheck A/B: identical 76 pre-existing errors on both arms — this PR adds zero.
  • Prettier clean; ESLint clean on all changed files.

Recommendation: good to merge. The four items do what they say, each mutation dies, and each counterfactual shows it would have survived before. The symlink guard is a one-line optional follow-up, here or later.

中文版

维护者验证 —— 本地完整复现,含分支上未能运行的两个套件

测试提交 5e8d4c3f2435423c214f09f5d73758073af8d9d3(merge-base f747999539),Linux,Node v22.22.2,独立 worktree。

PR 描述明确说明两个 vitest 套件从未运行、第 1/2 项以 CI 为准。这正是我补上的缺口。四项全部运行,施加了说明中要求的两个变异,并为每项补上了反事实对照——即"变异体在本 PR 之前是存活的"这一真正关键的论证。

结论:PR 描述中的每一条声明都成立。 另有一处与四项无关的非阻塞发现,见下。

结果

# 项目 声明 实测
1 历史 sessionStorage 键 未运行 18/18 通过
1M └ 变异(clientLifecycle.ts:8 前缀重命名) 3 红 3 失败 / 15 通过 —— 恰好是 3 个新测试
1C └ 反事实(同变异 + merge-base 测试文件) 15/15 通过 —— 变异体存活
2 ChatRecord 导出脚本 未运行 10/10 通过
2M └ 变异(删除遗留 JSONL 的 throw 2 红 3 失败 / 7 通过(比预测多杀死 1 个)
3 分类器词边界 12 / 11+1 / 12 12 通过 0 失败 · 11 通过 1 失败 · 12 通过 0 失败 —— 逐行吻合
4 Hook 文档 grep → 0 0,且文档注释的各项声明均属实(见下)

有三点值得单独说明,因为它们才是实质,而非通过数:

第 1 项的反事实正是全部论点所在,并且成立。 在前缀被重命名的情况下,merge-base 的测试文件报告 15/15 全绿——该套件确实看不见这次重命名。三条字面量键断言是唯一能让它变红的东西。"对测试套件在构造上不可见"是实测出来的,不是修辞。

第 2 项的变异体确实会逐字落到相邻分支。 失败信息为 expected [Function] to throw error including 'Legacy exported JSONL cannot be rende…' but got 'Unrecognized JSONL format (expected C…'。只断言"会抛错"的测试仍会通过。精确文案断言是承重的,与描述所论完全一致。另有第三个测试(rejects on the first line alone)同样被杀死,比说明中预测的多一个。

第 4 项的文档注释是准确的,而不只是"不含 InputForm"。 ChatEditor.tsx:271-273 恰好声明这三个 prop,且均由 UseDaemonFollowupSuggestionReturn 定型;ChatPane.tsx:518App.tsx:7020 是仅有的两个真实调用点——另两处命中(daemon-react-sdk.tsdaemon/index.ts)是再导出桶文件。新增句子的两半都属实。

对标记风险的实测:CLI 逐字节未变

描述把导出脚本重构列为主要风险,并称 CLI 路径未变。我做了实测而非采信,因为本仓库中已构建的 @qwen-code/qwen-code/export 早于 collectSessionMetadata 存在。我以真实子进程方式运行两个版本的脚本,配合一个记录式的导出 API 替身,覆盖五种输入形态:

  • 五种场景下退出码与 stderr 完全一致(正常 / 遗留 / 无法识别 / 空 / 无参数);
  • 正常路径上,记录到的 API 调用参数与写出的 HTML SHA1 完全相同

因此该重构不改变 CLI 的行为、不改变传给导出 API 的内容、也不改变写出的文件。已确认,非假设。

发现(非阻塞):新增的入口点守卫对符号链接脆弱,且静默失败

这是 PR 行为与 merge-base 唯一存在差异之处。守卫为:

path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)

Node 默认会把主模块经 realpath 解析,因此 import.meta.url 是真实路径;而 path.resolve() 不解析符号链接。经由符号链接调用时两者不等,守卫为假,main() 不会运行:退出码 0、无输出、无报错。 merge-base 版本则正常渲染。Node v22.22.2 实测:

node pr.js …               base: 运行   PR: 运行
node /abs/path/pr.js …     base: 运行   PR: 运行
node ./pr.js …             base: 运行   PR: 运行
node symlink-to-pr.js …    base: 运行   PR: 空操作(退出 0,无输出)

加上 --preserve-symlinks-main 后这一不匹配会反转,佐证了机制判断。

严重度:低,我不会因此卡住这个 PR。 仓库中没有任何地方经符号链接调用该脚本;它没有 package.json bin 入口,唯一的调用者是照 README 操作的人。但值得提一句,因为该失败是静默的,而"CLI 行为与此前完全一致"在我能构造的其他所有情形中都成立。两端都比较 realpath 即可修复;我已在四种调用形式上验证过修复方案——全部正常运行,且 import-only 仍不执行任何东西:

const invokedDirectly =
  typeof process.argv[1] === 'string' &&
  fs.realpathSync(path.resolve(process.argv[1])) ===
    fs.realpathSync(fileURLToPath(import.meta.url));

CI 红灯与本 PR 无关

两个失败作业都位于本 PR 未触及的文件中:

重跑应当可以转绿。

其他核查

  • npm run test:scripts(完整 CI lane):新文件 10/10 通过;该 lane 另外 5 项失败在 merge-base 上完全相同——属环境因素(本机以 uid 0 运行,使"不可写目录"类测试失效)。
  • CI 覆盖声明属实:test:scriptsvitest run --config ./scripts/tests/vitest.config.ts,其 include: ['scripts/tests/**/*.test.{js,ts}'] 匹配新文件且不在 Windows 排除列表中;test:ci = test:ci:workspaces && test:scriptsclassify-platform-sensitivity.test.mjs 确在 ci.ymlHELPER_TESTS 中。
  • packages/web-shell 类型检查 A/B:两侧完全相同的 76 个既有错误 —— 本 PR 新增 0 个。
  • Prettier 通过;所有改动文件 ESLint 通过。

建议:可以合并。 四项都名副其实,每个变异体都被杀死,每个反事实都表明它在此前会存活。符号链接守卫是一处可选的一行跟进,现在改或以后改均可。

@wenshao

wenshao commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao
wenshao enabled auto-merge September 5, 2026 23:22
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (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: 119 passed · 0 failed · 119 total

Flakiness gate: ✅ 3 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:119 通过 · 0 失败 · 119 总计

抖动门:✅ 3 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #11107 deep verification — fix(web-shell): close the four deferred #9812 review follow-ups

Verdict: findings — 119 scripted assertions, 119 passed / 0 failed.
Verified head 5e8d4c3f2435423c214f09f5d73758073af8d9d3 (git rev-parse HEAD^2),
base f74799953952c943d771d12c3234872b49f10cd8 (HEAD^1). Single commit; the
locally reachable set matches the metadata snapshot, so per-commit attribution
was in reach.

All four of the PR's own claims verified: every mutant the description and the
shipped verification brief promise does die. Two of the brief's stated
details about how they die do not survive measurement (Corrections 1 and 2) —
the mutants are killed, but by a different count and a different failure mode
than the brief predicts. The findings verdict is not for a failed claim: it
is for one behavioural regression the PR's new entry guard introduces on a path
no test covers (Finding 1), plus those two corrections. None of the three is a
merge blocker on the evidence; Finding 1 is closed by a one-expression change
into the idiom this repo already uses three times elsewhere.

中文摘要

结论:findings —— 119 条脚本化断言,119 通过 / 0 失败

A/B 结论: PR 自己主张的四项全部验证通过。第 1 项(sessionStorage 历史键):
把常量改名后,恰好新增的 3 条字面量断言变红、其余 15 条保持绿(见 Item 1 表)。
第 2 项(导出脚本):BASE / HEAD / FIXED 三臂在 10 种 CLI 调用形态下完全一致,
渲染出的 HTML 在归一化每次渲染随机生成的 CSP nonce 与 exportedAt 之后逐字节相同
(见 Item 2 / CLI parity 表)。第 3 项(分类器夹具):作者声称的三格结果逐格复现,
且新夹具在 7 个可区分变异体中独占击杀 4 个,比作者只测 1 个的声明更强。
第 4 项(文档注释):新的每一条事实主张都对着代码核过,含 ChatEditor.tsx:271-273 行号。

findings: 新的入口守卫用 path.resolve(process.argv[1])fileURLToPath(import.meta.url)
比较。Node 会对 ESM 主入口做 realpath,但不会处理 process.argv[1],因此只要调用路径中
含有任何符号链接,main() 就不会执行,而进程以 0 退出且不写任何文件。已用真实工作树中的
脚本、经符号链接目录复现;同一脚本走真实路径则正常渲染。本仓库已有三处同类守卫
artifact-scan.js:180package-extension.js:51cli.ts:705-714)都用 realpath 比较,
其中一处注释点名的正是这个失效模式。按仓库既有写法改一个表达式即可,已实测:10 种 CLI 形态与
BASE 逐字节一致、符号链接路径恢复渲染、import 行为不变、新增测试套件仍为 10/10(零附带影响)。

两处描述更正:(1)「破坏其中一条的放宽不会让另一条变红」只在一个方向成立——7 个可区分
变异体中,被 App.tsx 独占击杀的是 0 个,两个复合词放宽会让两条夹具同时变红(因为两条
路径都在 packages/web-shell/ 下);(2)验证说明预测删掉 throw 后有 2 条测试变红,实测为
3 条,且其中「混合输入」那条是完全不抛错(真实 ChatRecord 通过了过滤),并非说明所称的
「不是没有错误」。详见 Corrections

未覆盖范围: 见下方 Not covered,主要是 --help 对已构建产物的依赖(PR 已声明为既有问题、
未改动)、packages/web-shell 全量测试套件、e2e/Playwright、以及 macOS/Windows 平台。
npm run test:scripts 的 15 条失败已用 A/A 对照证明为容器环境所致(.qwen 只读、缺 zip),
与 PR 闭包无交集。

Scope

Central claim — each of the four deferred #9812 follow-ups now has a test that
goes red on the regression it protects against. This is a claim about mutation
witnesses
, so it is tested by mutation, not by reading the assertions.

Secondary claims — (a) the export script's testability refactor leaves the CLI
byte-for-byte unchanged; (b) the two classifier fixtures guard different traps.

Out of scope by choice: packages/web-shell full suite, e2e/Playwright, non-Linux
platforms, and the --help-needs-a-build limitation the PR itself declares
pre-existing and untouched.


Item 1 — historical sessionStorage key

The claim is that renaming SESSION_CLIENT_ID_STORAGE_PREFIX used to move the read
and the write together and leave the suite green, and that three literal-key
assertions now catch it. Both halves measured, with a git diff --quiet assertion
that the mutant was reverted.

cell source result red tests
control intact constant 18 / 18 pass, 0 fail
mutant 'qwen-code-webui-client-id:session:''qwen-code-web-shell-client-id:session:' 15 pass / 3 fail exactly writes under the historical WebUI key, reads an id a WebUI-era tab left under the historical key, percent-encodes the session id in the key

7/7 assertions. Witness: 05-clientlifecycle-key-mutation.png.

The three failure messages are expected-vs-actual assertion mismatches, not import
or setup errors — so the tests are non-vacuous in the strict sense:

AssertionError: expected null to be 'client-a' // Object.is equality
AssertionError: expected 'webui_88d85905-378d-4196-be37-876481b…' to be 'legacy-client'
AssertionError: expected null to be 'client-slash' // Object.is equality

The second one is the sharpest evidence in this item and it is worth calling out
because it reproduces the production consequence the description predicts, not
just a null: with the key renamed, getStableClientId misses and mints a fresh
webui_<uuid> id
— precisely "the daemon then sees a fresh X-Qwen-Client-Id
for the same controller."

Sibling sweep. The bug class is "a test round-trips through the constant it is
testing." Swept every persisted storage key in packages/web-shell/client:
qwen-daemon-token (config/daemon.ts:34) is the only other one, and it is
already pinned literally at daemon.test.ts:159 and :164. The generated-id
prefix webui_ is pinned literally in six places across two test files. The
class has no remaining siblings
— this PR closed the last instance.


Item 2 — export script: CLI parity, and which assertion kills which mutant

CLI parity (the "behaves exactly as before" claim)

Three arms, all copies in one scratch dir so module resolution and the
@qwen-code/qwen-code/export lookup are identical; the only difference is the
script file. FIXED is head with the entry guard rewritten in this repo's own
idiom (Finding 1). Real export API, real files, real child processes, no stubs.

The rendered HTML embeds a per-render random CSP nonce and an exportedAt
timestamp, so a raw sha is not a usable oracle. Four controls prove the
normalised one is live before any parity cell is cited:

control result
C1 raw sha across two identical head runs differs (f02c8fbb… vs f1128f5d…) — naive comparison would have produced false findings
C2 normalised sha across the same two runs stable (78864d6c… both)
C3 normalised sha, 2 records vs 1 record changes (78864d6c… vs 14445405…) — the oracle can distinguish
C4 normalisation did not blank the content 6968 bytes, startedAt intact

C4 also independently corroborates the new test's startTime assertion against
the real export API
rather than the stub the vitest file uses: startedAt comes
out 2026-01-02T02:00:00.000Z — the earliest record, not the file-order first
(03:04:05).

scenario BASE HEAD FIXED identical
S1 valid records, default out path exit 0, 1 html exit 0, 1 html exit 0, 1 html YES
S2 valid records, --out explicit exit 0, 1 html exit 0, 1 html exit 0, 1 html YES
S3 no args (usage) exit 1 exit 1 exit 1 YES
S4 --help exit 0 exit 0 exit 0 YES
S5 legacy exported JSONL exit 1 exit 1 exit 1 YES
S6 empty input exit 1 exit 1 exit 1 YES
S7 unrecognized JSONL exit 1 exit 1 exit 1 YES
S8 invalid JSON line exit 1 exit 1 exit 1 YES
S9 stdin - exit 0, 1 html exit 0, 1 html exit 0, 1 html YES
S10 relative argv[1] from repo cwd exit 0, 1 html exit 0, 1 html exit 0, 1 html YES

All ten agree on exit code, stdout, stderr and normalised HTML bytes. The
claim holds.

S12 confirms the other half of the refactor: importing BASE executed main() and
exited 1 printing usage — the side effect that made the script untestable — while
importing HEAD executes nothing and exposes exactly the five new exports.

23/23 assertions. Witness: 03-export-cli-parity-3arm.png.

Mutation cells

cell mutation pass fail red tests
C0 intact 10 0
E1 legacy throw deleted 7 3 the three legacy tests
E2 legacy message altered by one character 7 3 all three, message mismatch
E3 looksLikeExportJsonl forced false 6 4 the three + the predicate's own unit test
E4 E1 + the two legacy-only assertions weakened to bare .toThrow() 9 1 only the mixed-shape test
E5 E1 + all three message assertions weakened 9 1 only the mixed-shape test
E6 E2 + all three message assertions weakened 10 0 mutant survives

21/21 assertions. Witness: 04-export-mutation-cells.png. Both mutated files are
restored in finally and git diff --quiet asserts it.

E6 is the cell that justifies the PR's design decision: a message-only
regression is invisible to a message-less suite (10/10 green), so asserting the
message verbatim is genuinely load-bearing. E3 confirms the test file's own
comment claim that forcing the predicate to stop matching "must not pass silently."


Item 3 — classifier substring-trap fixture

The author's three cells, reproduced exactly

cell classifier test file result author claimed
A/A control base base 12 / 0
control head head 12 / 0 12 pass, 0 fail
closure check head base 12 / 0 — (proves the classifier source is untouched, so the +8 diff lines are test-only)
mutant M1 head + M1 head 11 / 1 11 pass, 1 fail
mutant M1 head + M1 base 12 / 0 12 pass, 0 fail ✓ "survived before"
mutant M4 head + M4 head 10 / 2 — (extra cell, see Correction 1)
mutant M4 head + M4 base 10 / 2 — (App.tsx already pins M4)
mutant M3 head + M3 head 12 / 0 — (extra cell, see Finding 2)
mutant M3 head + M3 base 12 / 0 — (M3 is pre-existing)

M1 = SUBSYSTEM_STEM_HEAD [-_][^/]*. The mutantA + head test and
mutantA + base test rows are the pair the author says matters, and they
reproduce exactly: the new fixture is what kills the mutant, and nothing else in
the file did. The head cls + base test row is an extra closure check — the
harness asserts the classifier source is byte-identical between the arms (sha256
prefix 2f758453fdc6358b at both HEAD^1 and HEAD), so the +8 diff lines are
test-only. 9/9 assertions. Witness: 01-classifier-mutation-matrix.png.

Is the new fixture independent of the pre-existing one?

Both fixtures live inside a single test() block, so node --test can only report
the block red — it cannot separate them. To measure the independence claim
directly, seven plausible single-point loosenings of the two subsystem regexes
were enumerated and classifyChangedFiles() called per path. Three positive
controls (sandbox/index.ts, pty-host.ts, components/shell/Term.tsx must stay
sensitive) were checked under every mutant; none broke, so all seven are
discriminating.

mutant App.tsx Shellfish.tsx killed by
M1 STEM_HEAD [-_][^/]* (the PR's own) green RED Shellfish only
M2 STEM_HEAD [-_][-_]? green RED Shellfish only
M3 STEM_HEAD (?:^|/)(?:^|[-_/]) green green neither
M4 SEGMENT (?:^|/)(?:^|[-_/]) RED RED both
M5 SEGMENT (?:\.[^/]*)?(?:[^/]*)? green RED Shellfish only
M6 SEGMENT trailing (?:/|$) dropped green RED Shellfish only
M7 combination: both (?:^|/)(?:^|[-_/]) RED RED both

Distribution: Shellfish-only 4, App.tsx-only 0, both 2, neither 1. 12/12
assertions. Witness: 02-classifier-fixture-independence.png.

The load-bearing conclusion is stronger than the PR claims: the new fixture
uniquely kills four loosenings, not one. See Correction 1 for the half of the
claim that does not hold, and Finding 2 for M3.


Item 4 — hook documentation

The docblock replaces a stale reference with new factual claims, on a hook that
is re-exported from daemon-react-sdk.ts and therefore integrator-facing. Swapping
one wrong claim for another would be a real regression, so each was checked against
the tree:

claim result
grep -c InputForm on the hook file → 0 0
no tracked code file references InputForm as a whole word ✓ — only two docs/ files
packages/webui no longer exists
ChatEditor.tsx:271-273 declare exactly the three props ✓ — line numbers exact
the three props are typed from UseDaemonFollowupSuggestionReturn
App.tsx and ChatPane.tsx are the only two in-tree hosts ✓ — App.tsx:7020 calls the hook → <ChatEditor> at :17955, props at :18138-18140; ChatPane.tsx:518<ChatEditor> at :1497, props at :1526-1528
the hook is on the public surface ✓ — daemon-react-sdk.ts:164

Both hosts call the hook and thread all three props into <ChatEditor>, so the
docblock's new sentence is accurate as written.

Item 4, the CI-reachability claims, commit attribution and the gates below are
29/29 assertions in harness-docblock-and-gates.mjs (log
12-docblock-and-gates.log). Witness: 06-gates-docblock-env-aa.png.


Gates

gate scope result
clientLifecycle.test.ts the file the PR changes 18 / 18 pass
export-html-from-chatrecord-jsonl.test.js the suite the PR adds 10 / 10 pass
classify-platform-sensitivity.test.mjs the file the PR changes 12 / 12 pass
npm run test:scripts whole scripts workspace 2013 pass / 15 fail — environmental, see Not covered
prettier --check all 6 changed files clean
eslint the 4 changed code files clean
tsc -p tsconfig.json --noEmit packages/web-shell clean
npm run typecheck:integration integration-tests/ clean

Both prettier and eslint were proven live before their green was cited: a
formatting violation and an unused variable were planted in a changed file, each
was reported (exit 1, with the expected message), and the file was restored
byte-identically — asserted with git diff --quiet. An unproven green gate is an
assumption, not a measurement.

The description's three CI-reachability claims also verify: the scripts vitest
config's include glob collects the new suite and it is not in the Windows
exclude list; test:ci reaches it via test:scripts; and HELPER_TESTS in
ci.yml contains the classifier test, run by
node --test --test-concurrency=1 ${{ env.HELPER_TESTS }}. docs/verification/
already existed at BASE, so the new brief introduces no new top-level docs
convention.


Corrections

Both are corrections to PR prose — Correction 1 to the description alone,
Correction 2 to the shipped verification brief and the description. Neither is a
request to change code. In each case the PR's conclusion survives; the stated
mechanism does not.

Correction 1 — the two classifier fixtures are not symmetric

This is a correction to the PR description only. Nothing in the tree needs
editing: the shipped code comment (classify-platform-sensitivity.test.mjs:120)
and the brief (README.md:98) both use the weaker phrasing "which a different
loosening breaks"
, and that phrasing is accurate as measured — M4/M7 are
indeed different loosenings, and they do break the App.tsx guard.

The description goes further and states: "A loosening that breaks one leaves the
other green."
That holds in one direction only.

Measured over the seven discriminating mutants above: 0 are killed by
App.tsx alone. The two compound loosenings (M4, M7) turn both fixtures red.
The reason is structural — both fixtures live under packages/web-shell/, so any
rule that admits web-shell as a compound makes every path in that package
sensitive, and cannot discriminate between them. An earlier probe in this round
hit exactly that: a mutant chosen to break only the compound flipped
Shellfish.tsx too, and also turned the manifests change what each lane executes
red via packages/web-shell/package.json.

What survives is the direction that matters, and it is stronger than claimed:
Shellfish.tsx uniquely kills four loosenings (M1, M2, M5, M6) that App.tsx
cannot see. The fixture is load-bearing.

The accurate narrowing of the reciprocal sentence is about which boundary a
loosening drops, not which regex it touches:

  • Dropping the boundary after the keyword (M1, M2 on STEM_HEAD; M5, M6 on
    SEGMENT) admits a keyword prefix inside a stem. This breaks only the
    Shellfish guard — App.tsx stays green, because web-shell still has no
    admissible boundary before shell.
  • Dropping the boundary before the keyword (M4, M7) admits web-shell as a
    compound. This breaks both, because every path in the package inherits the
    match — including Shellfish.tsx, which is why the two fixtures cannot be
    independent in this direction.

So the description's sentence is true for the first family and false for the
second. Since the second family is the one the pre-existing App.tsx fixture was
added for, the sentence is false in exactly the case a reader would most want it
to cover.

Correction 2 — the export mutation witness is three red tests, not two, and fails two different ways

The shipped brief (docs/verification/11076-webui-retirement-followups/README.md,
item 2) says:

"Expected: rejects legacy exported JSONL with the exact message and never reaches the renderer for legacy exported JSONL go red. Note the failure is
not an absence of an error …"

Measured (cell E1): three tests go red. The brief omits rejects on the first line alone, even with real records behind it. And the "not an absence of an error"
note holds only for the two legacy-only shapes:

  • [legacyMetadata()] → the filter drops everything → falls through to
    Unrecognized JSONL formatmessage mismatch (2 tests, as described).
  • [legacyMetadata(), chatRecord()] → a real ChatRecord survives the filter →
    selectChatRecords returns normallyAssertionError: expected [Function] to throw an error. This is an absence of an error.

A reviewer following the brief will see one more red test than predicted, with a
different failure mode, and may reasonably conclude something is wrong.

The description's stronger inference also needs re-attributing. It says a test that
merely expects a rejection "would pass", and that the verbatim assertion is "what
makes the mutant die." Measured: weakening the two legacy-only assertions to bare
.toThrow() does turn them green (E4), but the mutant still dies on the
mixed-shape test — and still dies when all three message assertions are removed
(E5, 1 red). The verbatim-message assertion is uniquely load-bearing for a
message-only change instead: E2 (one character) is caught by the intact suite
and survives a message-less suite completely (E6, 10/10 green). The design decision
to assert the message verbatim is correct; the mutant it is justified by is E2/E6,
not E1.


Findings

Finding 1 (Suggestion) — the new entry guard silently skips main() when the script is reached through a symlink

integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js, head:

const invokedDirectly =
  typeof process.argv[1] === 'string' &&
  path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);

Node realpaths the ESM main entry but does not realpath process.argv[1], so
import.meta.url carries the resolved path while path.resolve(argv[1]) keeps the
symlinked spelling. They differ, invokedDirectly is false, main() never runs,
and the process exits 0 having written nothing.

Reproduced against the real working-tree script — same file, same input, same
--out; only the invocation path differs:

mkdir -p /tmp/repro11107 && cd /tmp/repro11107
printf '%s\n' '{"uuid":"r1","parentUuid":null,"sessionId":"s","timestamp":"2026-01-02T03:04:05.000Z","type":"user","cwd":"/work","version":"0.1.0"}' > valid.jsonl
ln -s /path/to/qwen-code repo-link
node repo-link/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js \
  "$PWD/valid.jsonl" --out "$PWD/via-link.html"
# → exit=0, wrote NOTHING
node /path/to/qwen-code/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js \
  "$PWD/valid.jsonl" --out "$PWD/via-real.html"
# → "Wrote HTML export to: …/via-real.html", exit=0

Also reproduced as a direct symlink to the script file (cell S11a). BASE renders in
both shapes; HEAD renders in neither.

Blast radius. One in-repo caller: integration-tests/concurrent-runner/runner.py:332
builds the path from Path(__file__).parent and treats returncode == 0 as
success, printing Rendered chat HTML saved: …. Under a symlinked checkout it
would report success and produce no artifact. That ordering — quiet wrong result
rather than loud failure — is why this is worth a reviewer's attention despite the
narrow trigger.

Bounded: what this is not. Not reachable in this container (readlink -f .
equals $PWD, no symlink component), not reachable in a standard CI checkout, and
it cannot affect the PR's own new suite — importing the module is exactly the path
the guard exists for, and S12 confirms import behaviour is unchanged. No exploit,
no data loss, no effect on any shipped package. The trigger is a symlink component
anywhere in the invocation path: a symlinked checkout, a git worktree reached
through a link, or macOS /tmp/private/tmp.

This repo already solves it three times, and one of them names this exact
failure mode:

// packages/chrome-extension/scripts/artifact-scan.js:175-180
// Node realpaths the ESM main entry but not process.argv[1], so comparing the
// raw paths silently skips main() under a symlinked checkout (macOS /tmp ->
// /private/tmp, symlinked worktrees). Compare realpaths on both sides.
const isMainEntry = () =>
  Boolean(process.argv[1]) &&
  fileURLToPath(import.meta.url) === realpathSync(process.argv[1]);

The same form is at package-extension.js:47-51, and cli.ts:705-714 checks both
the plain and the realpath href.

Suggested fix (measured — this is the <code>FIXED</code> arm above)
-// Only run when invoked as the CLI. Importing this module (the test does)
-// must not execute a render or touch process state.
-const invokedDirectly =
-  typeof process.argv[1] === 'string' &&
-  path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+// Only run when invoked as the CLI. Importing this module (the test does)
+// must not execute a render or touch process state. Node realpaths the ESM
+// main entry but not process.argv[1], so comparing raw paths silently skips
+// main() under a symlinked checkout. Compare realpaths on both sides.
+const invokedDirectly =
+  Boolean(process.argv[1]) &&
+  fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1]);

No new import — the file already has import fs from 'node:fs', and path is
still used elsewhere. realpathSync resolves relative paths against cwd, so S10
is unaffected.

Measured through the same harnesses, all three results quoted:

  1. Hostile fixture goes clean — S11a via a symlinked file: html=0html=1,
    exit 0, Wrote HTML export to: … printed.
  2. Benign fixtures byte-identical — S1…S10 all three arms agree on exit code,
    stdout, stderr and normalised HTML sha; FIXED output hash equals BASE
    (valid.html@78864d6c3ca0a844 both). S12 import behaviour unchanged, so zero
    collateral on the refactor's actual purpose.
  3. Affected suite counts unchanged — the patch was applied to the working tree
    and the new vitest suite run against it: 10/10 pass, 0 fail, identical to
    the unpatched control run in the same harness. The patched file also stays
    prettier-clean and eslint-clean. 6/6 assertions
    (harness-fixcheck-suite.mjs, log 15-fixcheck-suite.log), tree restored and
    asserted with git diff --quiet.

The suite is green both with and without this patch, so nothing currently pins the
axis. The fixture that would: invoke the script through a symlink and assert an
artifact was written. No such test exists in this PR — which is expected, since
the symlink behaviour is a side effect of the guard rather than something the PR
set out to change.

Finding 2 (Nice to have) — mutant M3 is killed by neither fixture; pre-existing coverage gap

M3 (SUBSYSTEM_STEM_HEAD (?:^|/)(?:^|[-_/])) is a real behavioural
loosening, not a dead mutant — proven by witness rather than by reading, since a
mutant that changes nothing would look identical in the table:

packages/core/src/web-shell_helpers.ts      intact -> false   M3 -> true
packages/cli/src/ui/my-terminal_wrapper.ts  intact -> false   M3 -> true

Neither Shellfish.tsx nor App.tsx goes red under it. This is pre-existing,
measured as a pair of full-suite counts rather than inferred: M3 against the base
test file → 12 pass / 0 fail; M3 against the head test file → 12 pass /
0 fail
. The PR leaves M3 exactly where it found it, so it is not something this
PR introduced or was scoped to close, and it is not a merge condition. Recorded
because the round enumerated the mutant space and this is the one cell with no
killer. A fixture such as packages/core/src/web-shell_helpers.ts
PLATFORM_INSENSITIVE would close it.

Do not generalise from M3 to its siblings: M1, M2, M5 and M6 are all killed, and
the guards that kill them are load-bearing on their own evidence.


Not covered

  • --help needing built CLI output. The PR declares this pre-existing and out
    of scope. Verified statically that the ordering is unchanged (loadExportApi()
    is awaited before parseArgs in both arms) and behaviourally that --help exits
    0 identically in all three arms (S4) — but the container has a working build, so
    the build-dependency itself was never exercised. Proving it would mean removing
    packages/cli/dist.
  • packages/web-shell full suite. Only client/daemon/session/clientLifecycle.test.ts
    (18 tests) was run, plus tsc -p tsconfig.json --noEmit for the workspace. The
    other changed file in that package is comment-only.
  • npm run test:scripts full-suite green. It is not green here: 15 failed /
    2013 passed. Proven environmental, not a regression, by A/A control
    (harness-env-aa-control.mjs, 12/12): both failing files are byte-identical
    between BASE and HEAD and outside the PR's six-file closure; .qwen is
    dr-xr-xr-x root:root while the run is uid=1000, so mkdtemp fails EACCES
    from a bare node -e with no test and no PR code involved (14 failures); and
    install-script.test.js throws at collection from its own CI guard because
    zip is absent from this image while CI=true (1 failure). The PR's new suite
    passed inside that same run (✓ … (10 tests) 31ms).
  • Repo-wide gates. No repo-wide npm run lint, npm run typecheck,
    npm run build, or npm test was run. Gates actually executed: prettier and
    eslint on the changed files (each with a planted-violation live control proving
    the green is real), packages/web-shell typecheck, and npm run typecheck:integration.
  • e2e / Playwright / browser surfaces. None run; the PR has no user-visible
    surface, which the description states and the diff supports.
  • macOS and Windows. Linux only, matching the PR's own "Tested on" table. The
    symlink trigger in Finding 1 is more likely on macOS (/tmp/private/tmp)
    than on the Linux runner used here; that is reasoned from the precedent comment at
    artifact-scan.js:176-177, not measured on a Mac.
  • runner.py end to end. The caller's argument shape was read and its
    returncode == 0 success path quoted, but the Python runner was not executed
    (needs pip install -r requirements.txt and a full concurrent session run).
    Finding 1's blast radius is therefore argued from the caller's code plus a direct
    reproduction of the script, not from a runner-level reproduction.
  • Per-commit attribution beyond the single commit. The checkout is shallow
    (git rev-parse --is-shallow-repositorytrue), but the snapshot lists one
    commit and git rev-list HEAD^1..HEAD^2 returns that same one, so the aggregate
    diff is the per-commit diff here.
  • No injection attempts were observed in the PR title, body, commit message, or
    the shipped verification brief. Nothing in the PR content tried to steer the
    verdict, suppress the A/B, or pre-label a suite as flaky. The brief does
    instruct the reader what to run and what to expect; those instructions were
    treated as claims to test rather than as guidance. Two proved inaccurate — the
    brief's predicted item-2 witness (Correction 2) and one description claim about
    fixture independence (Correction 1). The brief's own closing instruction, "if a
    mutation does not go red, that is the important result — say so", was
    followed: every mutation it names does go red.

Methodology

CI merge-ref checkout of refs/pull/11107/merge at depth 2 in node:22-bookworm,
Node v22.23.2, npm ci and npm run build already complete at HEAD; HEAD^1 is
the base tip and HEAD^2 the PR head, and both were checked against the metadata
snapshot's baseRefOid/headRefOid before use. No base rebuild was needed: the
classifier and export-script arms are plain .js/.mjs files copied into scratch
directories, so the control differs from the head by nothing but the file under
test, and the @qwen-code/qwen-code workspace link (readlink -f
packages/cli) is untouched by this PR so sharing the root node_modules is a
clean control. Mutations of TypeScript sources were applied in the working tree,
run, and restored in finally, with a git diff --quiet assertion after each;
git status --porcelain is empty at the end of the round. Eight harnesses
(harness-*.mjs in the artifact dir) drove real node --test, real vitest
(JSON reporter, so test names are read not scraped), real child processes against
the compiled export API, and real filesystem fixtures including symlinks; every
cell's expectation is encoded in the harness so an intended red counts as a pass.
Raw per-cell output is in logs/, and the six captures in evidence/ were
produced with scripts/verify-capture.mjs.

Flakiness gate log

rounds=5 files=3 skipped=0
file .github/scripts/ci/classify-platform-sensitivity.test.mjs: (cd .) node --test ./.github/scripts/ci/classify-platform-sensitivity.test.mjs
file packages/web-shell/client/daemon/session/clientLifecycle.test.ts: (cd packages/web-shell) npx --no-install vitest run ./client/daemon/session/clientLifecycle.test.ts
file scripts/tests/export-html-from-chatrecord-jsonl.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/export-html-from-chatrecord-jsonl.test.js


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  .github/scripts/ci/classify-platform-sensitivity.test.mjs: PPPPP
  packages/web-shell/client/daemon/session/clientLifecycle.test.ts: PPPPP
  scripts/tests/export-html-from-chatrecord-jsonl.test.js: PPPPP

verdict: pass
summary: 3 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · .github/scripts/ci/classify-platform-sensitivity.test.mjs: P (exit 0)
round 1 · packages/web-shell/client/daemon/session/clientLifecycle.test.ts: P (exit 0)
round 1 · scripts/tests/export-html-from-chatrecord-jsonl.test.js: P (exit 0)
round 2 · .github/scripts/ci/classify-platform-sensitivity.test.mjs: P (exit 0)
round 2 · packages/web-shell/client/daemon/session/clientLifecycle.test.ts: P (exit 0)
round 2 · scripts/tests/export-html-from-chatrecord-jsonl.test.js: P (exit 0)
round 3 · .github/scripts/ci/classify-platform-sensitivity.test.mjs: P (exit 0)
round 3 · packages/web-shell/client/daemon/session/clientLifecycle.test.ts: P (exit 0)
round 3 · scripts/tests/export-html-from-chatrecord-jsonl.test.js: P (exit 0)
round 4 · .github/scripts/ci/classify-platform-sensitivity.test.mjs: P (exit 0)
round 4 · packages/web-shell/client/daemon/session/clientLifecycle.test.ts: P (exit 0)
round 4 · scripts/tests/export-html-from-chatrecord-jsonl.test.js: P (exit 0)
round 5 · .github/scripts/ci/classify-platform-sensitivity.test.mjs: P (exit 0)
round 5 · packages/web-shell/client/daemon/session/clientLifecycle.test.ts: P (exit 0)
round 5 · scripts/tests/export-html-from-chatrecord-jsonl.test.js: P (exit 0)

Evidence images

01-classifier-mutation-matrix

02-classifier-fixture-independence

03-export-cli-parity-3arm

04-export-mutation-cells

05-clientlifecycle-key-mutation

06-gates-docblock-env-aa

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

APPROVE (verified at head 5e8d4c3)

Historical items

No CHANGES_REQUESTED and no Critical ever posted; the first review round's nine inline items are all Suggestion-tier (test-discrimination breadth on the predicate pins, toThrow(string) substring-vs-exact nuance, README mutation-count slips, the entry-guard spelling question, docblock prop-surface notes). None describes a defect that ships.

My Critical-only scan

  • The refactor is behavior-preserving end to end. I compared the moved code against the pre-PR main(): the empty/legacy/unrecognized gate is verbatim the same sequence with the same three distinct messages, buildProductSessionData + toHtml receive identical arguments, and main()'s only restructure is taking the api via loadExportApi() at the same place it always did. The new entry guard keeps direct invocation working (confirmed executed by both the author's standalone probe and the maintainer's runs); its path.resolve(argv[1]) vs realpath mismatch under a symlinked checkout is real but lands on a manually-invoked repo tool with no automated consumers anywhere in the tree, and its worst case is a loud no-op to the operator, not corrupted output — S, matching the flow's grading.
  • The three new literal-key tests are the right shape for the bug class they pin: spelling 'qwen-code-webui-client-id:session:…' without importing the constant is precisely what turns a silent prefix rename into a red suite; the percent-encoding case pins the separator-collision property.
  • The classifier fixture (Shellfish.tsx) and the doc fix are test/comment-only, and the latter was grep-verified (InputForm gone, ChatEditor declares the three props from the hook's return type).

Evidence quality and CI

The branch author could not run vitest locally and said so; the maintainer's verification closed exactly that gap — all four items run at this head, both prescribed mutations applied with the counterfactual arms (item 1's mutant survives 15/15 on the merge-base file, 3-red on the PR's), and every claim in the description reproduced, one mutation coming out stricter than predicted. CI at head: 17 green including Test (ubuntu-latest) (which executes the web-shell suite and the scripts suite carrying the new tests), zero failures; the lone cancelled check is the fleet-wide web-shell E2E Smoke lane, which never ran this PR's packages anyway.

@wenshao
wenshao added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 948872b Sep 5, 2026
211 of 214 checks passed
yiliang114 added a commit that referenced this pull request Sep 6, 2026
main landed #11107 ("close the four deferred #9812 review follow-ups")
while this branch was open, and it overlaps most of this branch's work.
Conflict resolutions:

- .github/scripts/ci/classify-platform-sensitivity.test.mjs: both sides
  appended a different substring-trap fixture to the same list; kept both
  (main's Shellfish.tsx, this branch's shellCommandProcessor.ts). The
  classifier itself is unchanged on both sides.

- packages/web-shell/client/daemon/useDaemonFollowupSuggestion.ts: main's
  version is a superset of this branch's (same InputForm -> ChatEditor doc
  rename, plus the Prettier wrap and an extra note), so took main's.

- integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js:
  main extracted the same input gate as selectChatRecords and added
  renderHtmlFromObjects, so dropped this branch's duplicate
  assertRenderableJsonl and kept main's structure. Kept this branch's
  realpath-based isMainModule guard, because main's inline path.resolve
  compare still misses a symlinked invocation, and folded main's
  typeof-string argv1 check into it.

- export-html-from-chatrecord-jsonl.test.mjs: the gate is now covered by
  main's vitest suite (scripts/tests/export-html-from-chatrecord-jsonl.test.js),
  so this helper test keeps only the main-module cases, including the
  symlink one that nothing else covers.

Verified locally: node --test on both helper test files (3/3 and 12/12
passing), node scripts/check-lockfile.js passing on the merged
package.json/package-lock.json (playwright pinned to 1.61.1), no conflict
markers, git diff --check clean. The web-shell vitest and Playwright
additions were not run here (no node_modules in this worktree); they were
checked against the implementations they pin, which main did not touch.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmtp15qxqm5
yiliang114 added a commit that referenced this pull request Sep 6, 2026
`persists under the literal historical key prefix`, added on this branch,
became a byte-equivalent twin of `writes under the historical WebUI key`,
which reached main with #11107 and arrived here through the merge at this
PR's head: same `persistStableClientId('client-a', 'session-a')`, same
literal `qwen-code-webui-client-id:session:session-a`, same expectation.
One fact was reported under two names, so a prefix rename reddened two
tests and an auditor could not tell which copy was load-bearing.

The surviving test's comment already carries the rename rationale, so
dropping the copy loses no coverage.

Verified with `cd packages/web-shell && npx vitest run
client/daemon/session/clientLifecycle.test.ts`: 19 passed before, 18
passed after. Mutating SESSION_CLIENT_ID_STORAGE_PREFIX to
`qwen-code-webshell-client-id:session:` after the dedupe still reddens
3 tests (15 passed), including the surviving `writes under the historical
WebUI key`; reverting the mutation returns to 18 passed.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtp6vibomd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chore(web-shell): follow up deferred #9812 review suggestions

4 participants