fix(vscode): restore pre-cutover conversation history in the panel - #11495
Conversation
The WebShell cutover (#9811) switched the companion's history list to the daemon catalog filtered by sourceType 'vscode'. Sessions recorded before creator attribution existed carry no source metadata, so upgrading to v0.23.x emptied the panel history even though every transcript is still on disk (#11489). Claim the panel's own legacy sessions back instead of widening the filter: the host ships its globalState conversation ids as an allowlist, the panel intersects it with the daemon's default (unattributed) catalog, and opening one of them lazily stamps the 'vscode' source through the existing restore-time attribution path. globalState stays untouched for downgrade safety, and unattributed CLI/browser sessions stay out of the panel.
Review feedback: the allowlist never converges for ids that can no longer match (other workspaces' conversations, deleted transcripts, sessions already stamped by a restore), so keying the scan to every history-dropdown open re-paged the default catalog indefinitely. Gate it on a per-bootstrap done flag instead; failures still retry on the next open. Also unpin the scan page size from the test.
|
✅ Qwen Triage finished — CI landed green on ✅ Qwen Triage 已完成 —— |
|
Thanks for the PR! Template looks good ✓ — every required section is filled in, including the Tested-on table and the Risk & Scope bullets. Problem: observed, not theoretical. #11489 is a real P1 report with a downgrade/upgrade cross-check (135 conversations still in Direction: aligned, and the part I like is that it does not add a migration. I traced the mechanism instead of taking the description's word for it, and every hop already exists on
So this PR wires the host into infrastructure that was built for exactly this case, and changes no daemon/serve/SDK API. Reading Size: this is a cross-package change (companion + web-shell), so Stage 0 applies. 149 production lines against 314 test lines and nothing generated. Under the 500-line bar, Approach: the scope feels right and I could not find a materially simpler path. Dropping the
Risk: no elevated risk signals — no Stage 1e high-risk path matched. Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ —— 所有必填小节都写了,包括测试平台表格和风险与范围。 问题:是已观测到的真实问题,不是理论性加固。#11489 是 P1 报告,报告者做了降级/升级交叉验证( 方向:对齐。我最认可的一点是它没有引入迁移逻辑。我逐跳核对了机制而不是只看描述,
所以本 PR 是把宿主接到本就为此场景准备的基础设施上,且不改 daemon/serve/SDK 的任何 API。 规模:跨包改动(companion + web-shell),触发 Stage 0。生产代码 149 行,测试 314 行,无生成/schema 文件。低于 500 行阈值,类型为 方案:范围合理,我没有找到明显更简的路径。去掉
风险:无升级风险信号——未命中 Stage 1e 的高风险路径。 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
🖼️ web-shell visual previewRendered against a mock daemon (no real backend): the PR base vs this PR head Screenshots · before / afterℹ️ No screenshot changed against the PR base — but this PR edits 2 render-shaping files:
Either the change has no visual effect (logic, plumbing, a state the scenarios never reach), or no scenario renders this UI — in which case the preview cannot see it, and an empty result is a coverage gap rather than a clean bill of health. To make it visible, add a scenario to Full-resolution recordings (.webm) are attached to the workflow run. — Qwen Code · web-shell visuals |
Code reviewBefore reading the diff I wrote down what I would have done from the title and the motivation alone: the panel filters the daemon catalog by No critical blockers. One suggestion I'd act on, and two notes. The first history load now waits for the scan. In The early exit almost never fires, and nothing retires the allowlist. Both covered in the gate comment; together they mean the bounded worst case is the typical case, repeated on every bootstrap indefinitely. Not a defect — the record shape leaves no workspace to scope by, and keeping Three other Things I verified rather than assumed, since most of them are outside the diff:
sequenceDiagram
participant P1 as VS Code extension host
participant P2 as Panel webview
participant P3 as Daemon session catalog
P1->>P1: read legacy globalState (read-only)
P1->>P2: bootstrap with legacyConversationIds
P2->>P3: list page, sourceType vscode
P3-->>P2: attributed sessions
P2->>P3: list pages, sourceType default (max 10)
P3-->>P2: unattributed, CLI and browser sessions
P2->>P2: keep allowlisted rows with no sourceType
P2->>P3: open session, restore with sourceType vscode
P3->>P3: stamp vscode only if source is missing
On the tests: the three new behaviors are pinned and the assertions test the right things. The second half of the EmbeddedApp test — two more dropdown opens, then Test evidenceThis was an unattended CI run, so nothing was built, executed, or installed here — no PR-derived code ran. The evidence below is the PR's own CI, read through the API for the reviewed commit. Not verified: any real extension-host or daemon behavior, for the reason given under the next heading. At review time Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 What the suite cannot show, and the lane that would. The EmbeddedApp test mocks Sandboxed verification would settle this: 中文说明代码审查在读 diff 之前,我只根据标题和动机写下了自己的方案:面板按 没有阻塞性问题。 一条建议我会真的改,另有两条说明。 首次加载历史现在要等扫描完成。 提前退出几乎不会触发,且白名单永不退役。 两点都在 gate 评论里说过;合起来意味着有上限的最坏情况就是常态,并且每次 bootstrap 都会重演、永不结束。这不是缺陷——记录结构里没有可用于限定的 workspace,保持 另外三处 以下是我实际核对过(而非假设)的点,其中大部分在 diff 之外:
关于测试:三个新行为都被钉住了,断言也断在了正确的地方。EmbeddedApp 测试的后半段——再开两次下拉框,然后 测试证据本次为无人值守 CI 运行,因此这里没有构建、执行或安装任何东西——没有运行任何来自 PR 的代码。下面的证据是 PR 自己的 CI,通过 API 读取被审查 commit 的结果。未验证:任何真实的插件宿主或 daemon 行为,原因见下一节。 审查时 (上方表格中的检查名与结论即为 CI 现状,由 finalize 任务在 CI 结束后原地更新。) 测试套件无法证明的部分,以及能证明它的路径。 EmbeddedApp 测试 mock 了 沙箱验证可以定这件事: — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 4/5 — the wiring is correct at every hop I could check statically and it adds no new mechanism; the missing fifth is that nobody has yet watched a real daemon hand back a real pre-cutover transcript. Stepping back: the best thing about this PR is what it does not contain. There is no migration, no new daemon route, no SDK surface, nothing written to a store the user might still downgrade into. I went in expecting a recovery subsystem and found 149 production lines of wiring onto branches that already existed for exactly this case — including a server-side comment that says out loud that unattributed sessions belong to the default catalog. If I pick this up in six months it reads as "the host tells the daemon who it is on restore", which is a sentence I can hold in my head. That is the shape that ages well. I also went looking for the way this breaks something, since a change to the restore request has a nasty failure mode available to it. The one that would have been serious is a 400 on restore — What I would still want before calling this done, in descending order of how much it matters:
None of those are reasons to hold the PR back, and I am not approving because I ran out of objections — I have three, and all three are things you can fix in a follow-up or wave off with a sentence. CI on this commit was still running when I finished ( 中文说明置信度:4/5 —— 凡是能静态核对的每一跳,接线都是正确的,而且它没有引入任何新机制;差的这一分在于:还没有人看着真实 daemon 把一份真实的切换前 transcript 交回来。 退一步看:这个 PR 最好的地方是它没有包含什么。没有迁移,没有新的 daemon 路由,没有新的 SDK 接口,也没有向用户可能还要降级回去的存储写入任何东西。我本来以为会看到一个恢复子系统,结果是 149 行生产代码,接到了本就为这个场景准备的分支上——服务端甚至有一句注释明确写着无归因会话属于 default 目录。半年后我再拿起它,读到的是"宿主在恢复时告诉 daemon 自己是谁",这是一句我能装进脑子里的话。这种形态才经得起时间。 我也专门去找它会弄坏什么,因为改动恢复请求有一个很难看的失败模式可用。真正严重的那个是恢复请求 400—— 在称之为完成之前,我仍然希望做到以下几点,按重要性递减:
这些都不是拦下这个 PR 的理由,我也不是因为找不到反对意见才批准——我有三条,而这三条都可以在后续 PR 里修掉,或者用一句话带过。 我完成审查时该 commit 的 CI 仍在运行( — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
|
Qwen Code review timed out. Qwen review timed out after 21600 seconds (of the 360-minute budget). This run already used the maximum 360 minute timeout. See workflow logs. |
yiliang114
left a comment
There was a problem hiding this comment.
Verified the fix end-to-end against #11489's root cause — the chain holds at every link:
- Bootstrap:
WebViewProviderreads the legacyglobalStatestore read-only and ships onlygetRestorableDaemonSessionId-filtered ids (conv_*/temp*drafts excluded) onwebShellBootstrap. Matches the reporter's downgrade probe: the data was never lost, just filtered out. - List: the server already files unattributed sessions under the
defaultcatalog (packages/cli/src/serve/server/session-list.ts—filter.sourceType === 'default' && session.sourceType === undefined), so the allowlist intersect recovers exactly the pre-cutover companion sessions, while the client-sidesession.sourceType === undefinedcheck keepsdefault-stamped sessions (scheduled task runs etc.) out. CLI sessions without attribution are excluded by the allowlist itself. - Restore: the session route's
restoreRequestMetadataprefers the transcript-persisted source and only falls back to the client-supplied one when none exists, andapplyRestoreSourceIfMissingstamps only whenentry.sourceType === undefined— existing attribution is never overwritten, and one open permanently re-homes the session into the vscode catalog.
Local verification at 35d96aa (worktree build + targeted runs):
packages/vscode-ide-companion:EmbeddedApp.test.tsx+WebViewProvider.test.ts— 92 passed.packages/web-shell:DaemonSessionProvider.test.tsx— 320 passed.- Mutation check: removing the
legacyScanDoneRefguard turns the new scans-once-per-bootstrap assertion red, so the convergence test isn't vacuous; restoring the guard goes green again.
Nothing blocking from my side. One non-blocking observation: the legacy scan is bounded to the newest ~1000 default-catalog entries (10 pages × 100), so a workspace with more unattributed sessions than that would stop recovering the oldest ones — the bound is documented in the PR and comfortable for realistic histories.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
2 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- R1-13 history page withheld behind the legacy scan — already reported (issue comment 5605192247, triage stage 2; repeated as item 2 in 5605207884)
- R1-14 recovery never retired, re-paid on every bootstrap — already reported (issue comment 5605192247, triage stage 2; repeated as item 3 in 5605207884)
Test Plan (not a blocker): src/webview/EmbeddedApp.test.tsx — no such file or directory; src/webview/providers/WebViewProvider.test.ts — no such file or directory; client/daemon/session/DaemonSessionProvider.test.tsx — no such file or directory.
中文说明
已审查——无阻断问题。 建议见行内评论。
本轮确认的 2 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
Test Plan(非阻断):src/webview/EmbeddedApp.test.tsx — no such file or directory; src/webview/providers/WebViewProvider.test.ts — no such file or directory; client/daemon/session/DaemonSessionProvider.test.tsx — no such file or directory。
— qwen3.8-max via Qwen Code /review (v0.23.2)
| // legacy ids it still has in globalState; the panel then claims exactly | ||
| // those sessions back from the daemon's default catalog — without | ||
| // surfacing unattributed CLI sessions or browser-stamped ones. | ||
| sdkMocks.listWorkspaceSessionsPage.mockImplementation( |
There was a problem hiding this comment.
[Suggestion] This mockImplementation outlives the test that installs it. The file's beforeEach calls vi.clearAllMocks() (:156-161), which clears call history but does not remove implementations — only mockReset() / vi.resetAllMocks() does. Since this is the last test in describe('EmbeddedApp host wiring') (the block ends at :958), the fake catalog (vscode-1 for the vscode filter; legacy-1/cli-1/web-1 for default) plus the branching implementation stay installed for all of describe('web shell permission decision messages') from :961 onward and for anything appended later. A future test in that block which opens the history dropdown inherits three sessions it never arranged for, and passes or fails for reasons nowhere in its own body. Nothing is red today — listWorkspaceSessionsPage is not referenced after :950 — so this is a latent hazard with a concrete cost rather than a live failure; the file's own leakage-safe convention is mockResolvedValueOnce at :762. Add sdkMocks.listWorkspaceSessionsPage.mockReset() to the beforeEach, or wrap this test body in try { … } finally { sdkMocks.listWorkspaceSessionsPage.mockReset(); }.
Witness:
probe in an isolated vitest project (this repo's vitest 3.2.7) mirroring this file's shape —
hoisted sdk mock, beforeEach cleanup, implementation installed by the last test of block A
and observed from block B:
ARM 1 vi.clearAllMocks() (what :157 does)
implementationStillInstalled: true, callHistoryClearedByBeforeEach: 0
vscodePage.sessions: [vscode-1] defaultPage.sessions: [legacy-1, cli-1]
Tests 2 passed (2)
ARM 2 vi.resetAllMocks() (the suggested fix family)
probe FLIPS: expect(implInstalled).toBe(true) fails
Tests 1 failed | 1 passed (2)
The cleanup in this file is entirely explicit, so the reset has to be added rather than configured: beforeEach at :156-161 calls vi.clearAllMocks() only, and packages/vscode-ide-companion/vitest.config.ts sets none of restoreMocks, mockReset or clearMocks.
merges allowlisted pre-cutover sessions into the history list must stay green once the reset is added — it depends on the implementation being live for its own duration — so the mutation to run is removing the reset again and confirming the leakage returns.
中文说明
这个 mockImplementation 的生命周期超出了安装它的测试。本文件的 beforeEach 调用的是 vi.clearAllMocks()(:156-161),它只清空调用历史,并不会移除 implementation——只有 mockReset() / vi.resetAllMocks() 才会。由于这是 describe('EmbeddedApp host wiring') 里的最后一个测试(该 block 到 :958 结束),这份假目录(vscode 过滤返回 vscode-1;default 返回 legacy-1/cli-1/web-1)连同按 sourceType 分支的 implementation,会继续留在 :961 之后的整个 describe('web shell permission decision messages') 以及之后新增的任何测试里。该 block 中若有测试打开历史下拉框,就会继承三个它从未安排的会话,并因自身代码里找不到原因的理由通过或失败。今天没有测试变红——:950 之后再没有引用 listWorkspaceSessionsPage——所以这是一个有明确代价的潜在隐患,而不是已经发生的失败;本文件自己的防泄漏写法是 :762 的 mockResolvedValueOnce。建议在 beforeEach 中加上 sdkMocks.listWorkspaceSessionsPage.mockReset(),或把本测试主体包进 try { … } finally { sdkMocks.listWorkspaceSessionsPage.mockReset(); }。
证据:在隔离的 vitest 工程(本仓库的 vitest 3.2.7)中按本文件形态做探针——hoisted sdk mock、beforeEach 清理、由 block A 最后一个测试安装 implementation 并从 block B 观察。第一组用 vi.clearAllMocks()(即 :157 的做法):implementation 仍然安装着(implementationStillInstalled: true),调用历史被清空,后续 block 仍拿到 vscode-1 与 legacy-1/cli-1,2 个测试全绿。第二组改用 vi.resetAllMocks()(即建议的修复族):探针翻转,expect(implInstalled).toBe(true) 失败,1 失败 1 通过。
约束:本文件的 mock 清理完全是显式的,所以这个 reset 必须手写加上而不能靠配置——:156-161 的 beforeEach 只调用了 vi.clearAllMocks(),而 packages/vscode-ide-companion/vitest.config.ts 没有设置 restoreMocks、mockReset 或 clearMocks 中的任何一个。
验收:加上 reset 之后 merges allowlisted pre-cutover sessions into the history list 必须仍然是绿的(它依赖 implementation 在自身执行期间有效),因此要跑的变异是再次移除 reset,确认泄漏重新出现。
— qwen3.8-max via Qwen Code /review (v0.23.2)
| workspaceCwd: '/workspace', | ||
| sessionId: 'session-1', | ||
| hostKind: 'panel', | ||
| legacyConversationIds: ['legacy-1', 'never-recorded'], |
There was a problem hiding this comment.
[Suggestion] The scan's session.sourceType === undefined discriminator — the line that keeps CLI and browser sessions out of the panel history — is not pinned by any test, so it can be deleted with the whole package suite unchanged. In this fixture the only allowlisted id present in the default catalog (legacy-1) carries no sourceType, so cli-1 and web-1 are excluded by the allowlist alone and their toBeNull() assertions at :917-918 pass either way. Removing the clause from EmbeddedApp.tsx:88 then ships the regression it exists for: a pre-cutover conversation that another surface stamped default gets pulled into the panel's vscode history — a session owned elsewhere appearing as restorable in VS Code — and 519 passing tests would not notice. The stamp is one-way (bridge.ts:7276-7282 only fills a missing source), and session-list.ts:311-315 files unattributed rows into the same default bucket the scan queries, so a stamped legacy row is producible by any host that sets sessionSourceType. Adding web-1 to the allowlist makes the exclusion assertion that already exists do the pinning; the short-circuit means an allowlisted-but-stamped id never leaves remaining.
| legacyConversationIds: ['legacy-1', 'never-recorded'], | |
| legacyConversationIds: ['legacy-1', 'web-1', 'never-recorded'], |
Witness:
four arms measured in an isolated copy of the reviewed commit:
pristine -> Tests 20 passed (20)
delete the sourceType clause -> Tests 20 passed (20) mutation SURVIVES
whole package, pristine vs mutant -> byte-identical failure sets
Tests 5 failed | 519 passed | 1 skipped (525)
(all 5 in src/ide-server.test.ts, environment-induced)
suggested fixture alone -> Tests 20 passed (20), and the
).toHaveLength(1); convergence assertion still green
suggested fixture + mutation -> Tests 1 failed | 19 passed (20)
expected <div role="option" …> to be null
at :918 for data-session-id="web-1"
if (!result.nextCursor) break; at EmbeddedApp.tsx:94 is what keeps the ).toHaveLength(1); convergence assertion at one request — measured to still hold with this fixture change, because nextCursor: undefined trips the break after one page even though web-1 now stays in remaining forever; pairing the same change with a nextCursor would turn that assertion into ten calls.
The mutation to run is deleting session.sourceType === undefined && from EmbeddedApp.tsx:88: with this fixture the web-1 toBeNull() assertion at :918 must go red, and it must go green again once the clause is restored.
中文说明
扫描里的 session.sourceType === undefined 判断——也就是把 CLI 与浏览器会话挡在面板历史之外的那一行——没有任何测试钉住它,因此把它删掉整个包的测试套件也不会有变化。在这份 fixture 里,default 目录中唯一出现在白名单里的 id(legacy-1)本身不带 sourceType,所以 cli-1 和 web-1 只靠白名单就被排除了,:917-918 的 toBeNull() 断言在两种情况下都会通过。一旦从 EmbeddedApp.tsx:88 去掉这个条件,就会放走它本来要防的回归:一个被其他界面标记为 default 的切换前会话会被拉进面板的 vscode 历史里——一个属于别处的会话在 VS Code 中显示为可恢复——而 519 个通过的测试不会察觉。这个标记是单向的(bridge.ts:7276-7282 只补缺失的 source),且 session-list.ts:311-315 把无归因行归入扫描所查询的同一个 default 桶,所以任何设置了 sessionSourceType 的宿主都可能造出一条已标记的遗留行。把 web-1 加进白名单,就能让已经存在的排除断言真正起到钉住作用;由于短路求值,一个在白名单里但已被标记的 id 永远不会离开 remaining。
证据:在被审 commit 的隔离副本中测了四组。原始代码:20 个测试全绿。删掉 sourceType 条件:仍然 20 个全绿,变异存活。整包对比原始与变异:失败集合逐字节相同(Tests 5 failed | 519 passed | 1 skipped (525),5 个都在 src/ide-server.test.ts,由副本环境导致)。只加建议的 fixture:20 个全绿,且 ).toHaveLength(1); 收敛断言仍然是绿的。建议 fixture + 变异:Tests 1 failed | 19 passed (20),:918 处 data-session-id="web-1" 报 expected <div role="option" …> to be null。
约束:EmbeddedApp.tsx:94 的 if (!result.nextCursor) break; 是让 ).toHaveLength(1); 收敛断言保持一次请求的原因——实测在这样改 fixture 之后依然成立,因为 nextCursor: undefined 会在一页之后触发 break,即便 web-1 从此永远留在 remaining 里;如果把同样的改动和 nextCursor 一起加,该断言就会变成十次调用。
验收:要跑的变异是从 EmbeddedApp.tsx:88 删掉 session.sourceType === undefined &&——加上这份 fixture 后,:918 的 web-1 toBeNull() 断言必须变红,恢复该条件后必须重新变绿。
— qwen3.8-max via Qwen Code /review (v0.23.2)
| const LEGACY_SESSION_SCAN_PAGE_SIZE = 100; | ||
| const LEGACY_SESSION_SCAN_MAX_PAGES = 10; |
There was a problem hiding this comment.
[Suggestion] This window is taken from the wrong end of the catalog, and giving up is recorded as success. The metadata-filtered listing sorts strictly newest-first (session-list.ts:799-805, b.activityTime - a.activityTime) and the default bucket holds every unattributed row plus every default-stamped CLI, browser and scheduled-task session in the workspace — while the conversations this scan exists to recover are the oldest rows in it. A workspace that has accumulated more than ~1000 newer default-catalog sessions (a long-time CLI user in the same folder, which is exactly the population that has pre-cutover panel history) never reaches them: the loop exits on LEGACY_SESSION_SCAN_MAX_PAGES, legacyScanDoneRef.current = true at :527 still runs because the helper returned normally, and those conversations stay absent from the panel history with no error, no log and no retry — indistinguishable from "there was nothing to recover", for the users with the most to recover. The allowlist is never pruned, so every bootstrap repeats the same doomed walk. Note the bound cannot be widened by page size: MAX_SESSION_PAGE_SIZE = 100 (session-list.ts:37, applied at :1352 before the sourceType branch) clamps every page, so 100 is already the ceiling and the SDK's Math.min(1000, …) is a non-binding pre-clamp. Latch convergence only when the scan actually converged (remaining.size === 0, or the catalog exhausted via !result.nextCursor), and carry the cursor across dropdown opens so a deep catalog is walked incrementally instead of never; better still, push the intersection server-side (an id-set or unattributed filter on the workspace listing) so one gathered scan returns exactly the allowlisted rows. Failing both, stop paging once a page's oldest updatedAt predates the oldest allowlisted Conversation.updatedAt, and surface the unresolved remainder so hitting the cap is observable.
Witness:
witness: not run — settling this needs a live daemon with a seeded default catalog
over 1000 rows (review drive), beyond this pass's budget. The deciding facts are
quoted from unchanged server code at the reviewed commit:
session-list.ts:37 const MAX_SESSION_PAGE_SIZE = 100;
session-list.ts:1352 const pageSize = Math.min(Math.max(requestedSize, 1), MAX_SESSION_PAGE_SIZE);
session-list.ts:803 const byTime = b.activityTime - a.activityTime;
EmbeddedApp.tsx:527 legacyScanDoneRef.current = true; (success path only)
corroborating run on the unmodified tree: v8 coverage `stmt lines 84: hits=1` —
the suite observes exactly one page, so neither the cap nor the remaining.size exit runs.
Any new filter parameter must stay behind the same capability preflight the existing source filter uses (requireCapability('session_source_metadata') in DaemonClient.ts) and must preserve the legacy fallback (filter.sourceType === 'default' && session.sourceType === undefined) at session-list.ts:314.
The test that must go red is a new case in EmbeddedApp.test.tsx whose default mock returns non-matching pages with a live nextCursor chain and the allowlisted session beyond the cap: it must assert either that the row is eventually rendered or that the exhaustion is reported — today the cap is latched as converged and neither happens.
中文说明
这个窗口取的是目录的错误一端,而且"放弃"被记录成了"成功"。带元数据过滤的列表严格按时间倒序排列(session-list.ts:799-805,b.activityTime - a.activityTime),而 default 桶里既有全部无归因行,也有该 workspace 中所有被标记为 default 的 CLI、浏览器与定时任务会话——但本次扫描要恢复的会话恰恰是其中最旧的那些。如果一个 workspace 累积了超过约 1000 条更新的 default 目录会话(长期在同一目录使用 CLI 的用户,正是最可能有切换前面板历史的人群),扫描永远走不到它们:循环在 LEGACY_SESSION_SCAN_MAX_PAGES 处退出,:527 的 legacyScanDoneRef.current = true 依然会执行(因为 helper 正常返回),于是这些会话在面板历史里持续缺席,没有错误、没有日志、也不会重试——与"本来就没有可恢复的会话"完全无法区分,而且发生在最有东西可恢复的用户身上。白名单从不被清理,所以每次 bootstrap 都会重复这趟注定无果的遍历。另外,这个上限无法靠加大页大小来放宽:MAX_SESSION_PAGE_SIZE = 100(session-list.ts:37,在 :1352 于 sourceType 分支之前生效)会钳制每一页,所以 100 已经是天花板,SDK 的 Math.min(1000, …) 只是不具约束力的前置钳制。建议只在扫描真正收敛时(remaining.size === 0,或通过 !result.nextCursor 判定目录已走完)才置位收敛标记,并把 cursor 跨下拉框打开保留下来,让很深的目录能被增量走完而不是永远走不到;更好的做法是把求交集下推到服务端(在 workspace 列表接口上加 id 集合或 unattributed 过滤),让一次聚合扫描就返回恰好命中白名单的行。若两者都不做,也可以在一页中最旧的 updatedAt 早于白名单中最旧的 Conversation.updatedAt 时停止翻页,并把未解析的剩余 id 暴露出来,使"触到上限"这件事可观测。
证据:未运行——要定这件事需要一个已注入超过 1000 条 default 目录行的真实 daemon(review drive),超出本轮预算。决定性事实引自被审 commit 上未改动的服务端代码:session-list.ts:37 的 const MAX_SESSION_PAGE_SIZE = 100;、:1352 的 const pageSize = Math.min(Math.max(requestedSize, 1), MAX_SESSION_PAGE_SIZE);、:803 的 const byTime = b.activityTime - a.activityTime;,以及 EmbeddedApp.tsx:527 只在成功路径执行的 legacyScanDoneRef.current = true;。在未修改的代码树上的旁证运行:v8 覆盖率 stmt lines 84: hits=1——测试套件只观察到一页,所以上限与 remaining.size 出口都没有被执行过。
约束:任何新增的过滤参数都必须留在既有 source 过滤所用的同一道能力预检之后(DaemonClient.ts 中的 requireCapability('session_source_metadata')),并且必须保留 session-list.ts:314 的遗留回退 (filter.sourceType === 'default' && session.sourceType === undefined)。
验收:必须变红的是 EmbeddedApp.test.tsx 中一个新用例——其 default mock 返回带 nextCursor 链条的不命中分页,而命中白名单的会话位于上限之外;它必须断言该行最终被渲染出来,或断言"已走完/未解析"被上报。今天上限被当作已收敛置位,两件事都没有发生。
— qwen3.8-max via Qwen Code /review (v0.23.2)
| if (!result.nextCursor) break; | ||
| cursor = result.nextCursor; |
There was a problem hiding this comment.
[Suggestion] No test executes this cursor advance, so the paging path — and the bound the whole feature is reasoned about — is unpinned. Every nextCursor in the added fixture is undefined, the loop body runs once, and cursor = result.nextCursor; never executes: deleting it cannot change any observation, and CI stays green while real users with more than 100 default-catalog rows get page 1 re-read up to ten times — ten times the catalog cost, the history list blocked behind it, and a window that never advances to the older sessions the scan exists to recover. Dropping LEGACY_SESSION_SCAN_PAGE_SIZE from 100 to 10 would shrink the reachable window from 1000 catalog entries to 100, also green, because the only request-shape assertion in the test pins archiveState and not pageSize. Add a case whose default mock returns two pages (nextCursor: 'p2' then undefined) with the allowlisted id on page 2, asserting the id renders, that the second call carried cursor: 'p2', and that no third page is requested once the allowlist is satisfied.
Witness:
v8 statement coverage of EmbeddedApp.tsx under EmbeddedApp.test.tsx,
unmodified tree, suite green (92 tests):
stmt lines 94-94: hits=1 if (!result.nextCursor) break;
stmt lines 95-95: hits=0 cursor = result.nextCursor;
stmt lines 96-96: hits=0 loop back-edge — never a second iteration
population swept: `nextCursor` occurs 4x in EmbeddedApp.test.tsx
(:770, :839, :865, :868) and every one is undefined; loadLegacyAllowlistedSessions
is module-local with a single call site, so no other suite can reach the loop.
LEGACY_SESSION_SCAN_PAGE_SIZE = 100 already equals the server's MAX_SESSION_PAGE_SIZE (session-list.ts:37), so a fixture that raises the page size would exercise a shape the daemon clamps away; and the existing ).toHaveLength(1); convergence assertion must stay green.
The new case must go red when cursor = result.nextCursor is deleted (page 2 is never fetched, so the id is never found) and red when the remaining.size > 0 loop condition is dropped (an extra page is requested after convergence).
中文说明
没有任何测试执行到这里的 cursor 推进,因此翻页路径——以及整个功能据以推理的那个上限——都没有被钉住。新增 fixture 里每个 nextCursor 都是 undefined,循环体只跑一次,cursor = result.nextCursor; 从未执行:删掉它不会改变任何可观察结果,CI 依然全绿;而现实中 default 目录超过 100 行的用户,会让第 1 页被重复读取最多十次——十倍的目录开销、被卡在后面的历史列表,以及一个永远不会推进到扫描本该恢复的更旧会话的窗口。把 LEGACY_SESSION_SCAN_PAGE_SIZE 从 100 改成 10,可触达窗口会从 1000 条目录项缩到 100 条,同样全绿,因为测试里唯一关于请求形状的断言钉的是 archiveState 而不是 pageSize。建议新增一个用例:default mock 返回两页(先 nextCursor: 'p2',再 undefined),命中白名单的 id 位于第 2 页,断言该 id 被渲染、第二次调用携带了 cursor: 'p2',并且白名单满足后不会请求第三页。
证据:在未修改的代码树上,用 EmbeddedApp.test.tsx 对 EmbeddedApp.tsx 采集 v8 语句覆盖率(套件全绿,92 个测试):stmt lines 94-94: hits=1(if (!result.nextCursor) break;)、stmt lines 95-95: hits=0(cursor = result.nextCursor;)、stmt lines 96-96: hits=0(循环回边——从未进入第二次迭代)。总体排查:nextCursor 在 EmbeddedApp.test.tsx 中出现 4 次(:770、:839、:865、:868),全部是 undefined;loadLegacyAllowlistedSessions 是模块内函数且只有一个调用点,所以没有其他套件能触达这个循环。
约束:LEGACY_SESSION_SCAN_PAGE_SIZE = 100 已经等于服务端的 MAX_SESSION_PAGE_SIZE(session-list.ts:37),所以抬高页大小的 fixture 会演练一个被 daemon 钳制掉的形状;同时既有的 ).toHaveLength(1); 收敛断言必须保持绿色。
验收:删掉 cursor = result.nextCursor 时新用例必须变红(第 2 页永远不会被取到,id 也就永远找不到);去掉 remaining.size > 0 循环条件时也必须变红(收敛之后还会多请求一页)。
— qwen3.8-max via Qwen Code /review (v0.23.2)
| legacyScanDoneRef.current = false; | ||
| }, [runtime?.legacyConversationIds]); |
There was a problem hiding this comment.
[Suggestion] Nothing pins this effect, and as shipped nothing can fire it. Reverting the hunk on its own left the whole packages/vscode-ide-companion suite green, so the behaviour the comment promises ("A re-bootstrap delivers a fresh allowlist — reopen the scan for it") is unprotected. Tracing that promise at the commit shows it is also unreachable: webShellBootstrap has exactly one poster (WebViewProvider.ts:2096) and webShellReady exactly one (EmbeddedApp.tsx:1007); every dependency of that effect (:1008-1015) is mount-stable — clearInsight is a useCallback with [], closeOpenPermissionDiffs and updateTranscript depend only on the module-level vscode singleton, handleShellError on [clearInsight, t] — and t cannot be rebuilt without a remount (:369-370, with /language in VSCODE_HIDDEN_SLASH_COMMANDS); readRuntimeConfig() (:284-300) carries no legacyConversationIds, so no scan can be in flight when the identity changes once; and a remount recreates both the ref and sessions. So the effect and its comment document a recovery path nothing exercises: a future host that re-bootstraps in place would depend on a guard no test protects, and a reader today cannot tell whether the latch is ever re-opened. Either add a test that dispatches a second webShellBootstrap with a fresh legacyConversationIds and asserts a second sourceType: 'default' page call plus rendering of the new legacy row, or drop the effect and its comment until an in-place re-bootstrap path exists.
Witness:
test-efficacy hunk probe (harnessValidated: true):
hunk index 3, header @@ -372,6 +441,11 @@ -> verdict: survived
(reverting this hunk alone left the whole packages/vscode-ide-companion suite green)
reachability quoted at the commit:
EmbeddedApp.tsx:1007 the only webShellReady poster, last statement of its effect
EmbeddedApp.tsx:369-370 const language = useMemo(readLanguage, []);
const t = useMemo(() => createChromeStrings(language), [language]);
WebViewProvider.ts:2096 the only webShellBootstrap poster
EmbeddedApp.test.tsx:949-954 asserts ).toHaveLength(1); for the sourceType === 'default' calls across three dropdown opens, so a new test must keep the scan closed on ordinary re-opens and re-open only on a changed allowlist, or that convergence assertion breaks.
The test to add must go red when this useEffect is deleted — today deleting it changes nothing, which is exactly what the hunk probe measured.
中文说明
没有任何测试钉住这个 effect,而且按当前代码它也不可能被触发。单独回退这个 hunk 之后,整个 packages/vscode-ide-companion 套件依然全绿,所以注释所承诺的行为("再一次 bootstrap 会带来新的白名单——为它重新打开扫描")没有任何保护。在 commit 上追踪这个承诺还会发现它不可达:webShellBootstrap 只有一个发送点(WebViewProvider.ts:2096),webShellReady 也只有一个(EmbeddedApp.tsx:1007);该 effect 的每个依赖(:1008-1015)都是按挂载稳定的——clearInsight 是依赖为 [] 的 useCallback,closeOpenPermissionDiffs 与 updateTranscript 只依赖模块级 vscode 单例,handleShellError 依赖 [clearInsight, t]——而 t 在不重新挂载的情况下无法被重建(:369-370,且 /language 在 VSCODE_HIDDEN_SLASH_COMMANDS 中);readRuntimeConfig()(:284-300)不带 legacyConversationIds,所以在这个数组标识唯一一次变化时不可能有扫描在飞行中;而重新挂载会同时重建该 ref 与 sessions。因此这个 effect 和它的注释描述的是一条没有任何代码会走的恢复路径:将来若有宿主真的原地重新 bootstrap,就会依赖一个没有测试保护的守卫,而今天的读者也无法判断这个闩是否会被重新打开。建议要么补一个测试——派发第二次 webShellBootstrap 并带上新的 legacyConversationIds,断言发起了第二次 sourceType: 'default' 分页调用、且新的遗留行被渲染——要么在原地重 bootstrap 的路径真实存在之前,先删掉这个 effect 和它的注释。
证据:test-efficacy 的 hunk 探针(harnessValidated: true)——hunk 序号 3,头部 @@ -372,6 +441,11 @@,结论 survived(单独回退这个 hunk 后整个 packages/vscode-ide-companion 套件仍然全绿)。可达性引自 commit:EmbeddedApp.tsx:1007 是唯一的 webShellReady 发送点,且是其 effect 的最后一句;EmbeddedApp.tsx:369-370 是 const language = useMemo(readLanguage, []); 与 const t = useMemo(() => createChromeStrings(language), [language]);;WebViewProvider.ts:2096 是唯一的 webShellBootstrap 发送点。
约束:EmbeddedApp.test.tsx:949-954 断言三次打开下拉框时 sourceType === 'default' 的调用数 ).toHaveLength(1);,所以新测试必须让扫描在普通重开时保持关闭、只在白名单变化时才重新打开,否则该收敛断言会被破坏。
验收:新增的测试必须在删除这个 useEffect 时变红——今天删除它不会有任何变化,而这正是 hunk 探针实测到的结果。
— qwen3.8-max via Qwen Code /review (v0.23.2)
| ); | ||
| // The legacy store doubles as the downgrade/recovery path; the bootstrap | ||
| // must stay read-only against it. | ||
| expect(conversationStoreMocks.getAllConversations).toHaveBeenCalled(); |
There was a problem hiding this comment.
[Suggestion] This assertion cannot fail, so the property the comment above it claims is certified by nothing. The payload assertion at :2431-2437 already requires legacyConversationIds: ['550e8400-…'], whose only producer is that read, so toHaveBeenCalled() adds no discriminating power — it verifies a read happened, not that no write happened, while the comment names this store as the downgrade/recovery path that "must stay read-only". A future change that prunes or re-saves legacy conversations during bootstrap would destroy that path and keep this test green. The constructible case today is a write through one of the two mocked mutators (createConversation/addMessage succeed, so both assertions stay green while the real store would have been written); the mocked class exposes only four members (:342-353) while the real store has ten, so deleteConversation, upsertConversation, renameConversationId, replaceMessages, truncateFromUserTurn and setCurrentConversationId are not even observable here. data: expect.objectContaining(...) likewise passes if a future change ships conversation bodies into the webview, so the "only ids cross the bridge" comment is uncertified too. Hoist the mutating methods into conversationStoreMocks the way getAllConversations is hoisted (:334-340) and assert not.toHaveBeenCalled(), and pin the ids-only property with an exact-keys assertion or expect(bootstrap?.data).not.toHaveProperty('legacyConversations') — or drop the comment sentence nothing certifies.
Witness:
witness: not run — the settling mutation (insert a call to a mocked mutator into the
bootstrap before the postMessage and re-run this file, expecting green) needs an edit;
review scratch-tree refused on this repository (unreadable includeIf content-filter
config) and the review worktree is read-only for this pass.
Both assertions and the mock's member list are quoted from the committed file:
:2431-2437 payload assertion requiring legacyConversationIds: ['550e8400-…']
:2440 expect(conversationStoreMocks.getAllConversations).toHaveBeenCalled();
:342-353 mocked members: createConversation, addMessage,
getCurrentConversationId, getAllConversations
conversationStore.ts:67, :88, :137, :157, :200, :212 the six real mutators absent
Suite green unmodified (72 tests).
createConversation/addMessage are per-instance class-field vi.fn()s (:344-349), so a not.toHaveBeenCalled() assertion requires hoisting them into conversationStoreMocks first — asserting on a fresh per-instance vi.fn() would always pass and prove nothing. The file already uses that hoisted shape at :935, :999, :1288-1289 and :1469.
ships restorable legacy conversation ids in the bootstrap payload must go red if the bootstrap ever calls a mutating ConversationStore method or adds a snapshot-bearing field to data.
中文说明
这个断言不可能失败,因此它上面那句注释所声称的性质其实没有任何东西在证明。:2431-2437 的 payload 断言已经要求 legacyConversationIds: ['550e8400-…'],而它唯一的生产者就是这次读取,所以 toHaveBeenCalled() 没有任何区分能力——它验证的是"读发生过",而不是"没有发生写",可注释却把这个存储称为必须"保持只读"的降级/恢复路径。将来若有改动在 bootstrap 期间清理或重写遗留会话,会摧毁这条路径而这个测试依然是绿的。今天可以构造出来的情形是通过两个被 mock 的变更方法写入(createConversation/addMessage 会成功返回,于是两个断言都保持绿色,而真实存储其实已被写入);被 mock 的类只暴露四个成员(:342-353),而真实存储有十个,所以 deleteConversation、upsertConversation、renameConversationId、replaceMessages、truncateFromUserTurn 和 setCurrentConversationId 在这里甚至不可观测。同样地,data: expect.objectContaining(...) 在未来把会话正文送进 webview 时也会通过,所以"只有 id 过桥"这句注释也是没有证明的。建议像 getAllConversations 那样(:334-340)把这些变更方法 hoist 进 conversationStoreMocks 并断言 not.toHaveBeenCalled(),同时用精确键断言或 expect(bootstrap?.data).not.toHaveProperty('legacyConversations') 把"只有 id"这一性质钉住——或者删掉那句无人证明的注释。
证据:未运行——能定这件事的变异(在 postMessage 之前向 bootstrap 里插入一次对被 mock 的变更方法的调用,然后重跑本文件,预期仍绿)需要修改代码;review scratch-tree 在本仓库拒绝执行(无法读完的 includeIf 内容过滤配置),而本轮中审查用的 worktree 是只读的。两个断言与 mock 的成员列表都引自已提交的文件::2431-2437 是要求 legacyConversationIds: ['550e8400-…'] 的 payload 断言;:2440 是 expect(conversationStoreMocks.getAllConversations).toHaveBeenCalled();;:342-353 是被 mock 的成员 createConversation、addMessage、getCurrentConversationId、getAllConversations;conversationStore.ts:67, :88, :137, :157, :200, :212 是缺失的六个真实变更方法。未修改状态下套件全绿(72 个测试)。
约束:createConversation/addMessage 是每实例的类字段 vi.fn()(:344-349),所以 not.toHaveBeenCalled() 断言必须先把它们 hoist 进 conversationStoreMocks——对一个新建的每实例 vi.fn() 做断言永远通过,什么也证明不了。本文件在 :935、:999、:1288-1289 和 :1469 已经使用了这种 hoist 写法。
验收:如果 bootstrap 将来调用了 ConversationStore 的任何变更方法,或往 data 里加入了携带快照的字段,ships restorable legacy conversation ids in the bootstrap payload 必须变红。
— qwen3.8-max via Qwen Code /review (v0.23.2)
| logger.warn( | ||
| '[WebViewProvider] Failed to read legacy conversations:', |
There was a problem hiding this comment.
[Suggestion] Nothing exercises this catch, so removing it would ship a failed panel instead of a working one. Both added tests mock getAllConversations to resolve (:2405, :2444, plus the beforeEach default at :2352-2353) and there is no mockRejectedValue anywhere in the file, so the branch that degrades a broken legacy store to "no allowlist, panel works" never runs. Make the store throw on read — a globalState entry written by an older companion whose shape no longer deserializes, or a quota/read error — and with this catch present the bootstrap still posts webShellBootstrap with no legacyConversationIds; delete it and the same throw reaches the enclosing bootstrap catch at :2159-2170, which logs "Failed to start WebShell daemon" and posts webShellBootstrapError, so the user's chat panel renders a failure instead of a working panel with no legacy history. Add a case with conversationStoreMocks.getAllConversations.mockRejectedValue(new Error('legacy store corrupt')) asserting that webShellBootstrap is still posted, that legacyConversationIds is absent, and that no webShellBootstrapError was sent.
Witness:
v8 statement coverage of WebViewProvider.ts under WebViewProvider.test.ts,
unmodified tree (72 tests green):
stmt lines 2009: hits=15 await this.conversationStore.getAllConversations()
stmt lines 2015: hits=1 legacyConversationIds = legacyIds;
stmt lines 2018: hits=0 logger.warn(
stmt lines 2019-2021: hits=0
stmt lines 2022: hits=0 } — end of the inner catch
The inner catch body never executes, so removing the try/catch cannot fail any test.
The outer bootstrap catch posts type: 'webShellBootstrapError' (:2164), so the new assertion must check that message type is absent — otherwise it passes for the degraded-to-broken behaviour it exists to exclude.
That new case must go red when this inner try/catch at :2007-2022 is removed, with the bootstrap replaced by webShellBootstrapError.
中文说明
没有任何测试演练到这个 catch,因此把它删掉将会交付一个失败的面板,而不是一个可用的面板。两个新增测试都把 getAllConversations mock 成 resolve(:2405、:2444,以及 :2352-2353 的 beforeEach 默认值),文件里也完全没有 mockRejectedValue,所以"遗留存储损坏时降级为没有白名单但面板可用"这条分支从未执行。让存储在读时抛错——比如旧版插件写入、如今已无法反序列化的 globalState 条目,或配额/读取错误——在保留这个 catch 的情况下,bootstrap 仍会发出 webShellBootstrap,只是不带 legacyConversationIds;而删掉它之后,同一个异常会传到 :2159-2170 外层 bootstrap 的 catch,记录 "Failed to start WebShell daemon" 并发出 webShellBootstrapError,于是用户的聊天面板会渲染成失败,而不是一个可用但没有遗留历史的面板。建议补一个用例:conversationStoreMocks.getAllConversations.mockRejectedValue(new Error('legacy store corrupt')),断言 webShellBootstrap 仍然被发出、legacyConversationIds 缺席、且没有发出 webShellBootstrapError。
证据:在未修改的代码树上,用 WebViewProvider.test.ts 对 WebViewProvider.ts 采集 v8 语句覆盖率(72 个测试全绿):stmt lines 2009: hits=15(await this.conversationStore.getAllConversations())、stmt lines 2015: hits=1(legacyConversationIds = legacyIds;)、stmt lines 2018: hits=0(logger.warn()、stmt lines 2019-2021: hits=0、stmt lines 2022: hits=0(内层 catch 的结尾)。内层 catch 的主体从未执行,所以移除这个 try/catch 不可能让任何测试失败。
约束:外层 bootstrap 的 catch 会发出 type: 'webShellBootstrapError'(:2164),所以新断言必须检查该消息类型不存在——否则它会对"由降级变成彻底损坏"的行为照样通过,而那正是它要排除的。
验收:移除 :2007-2022 这个内层 try/catch 时,新用例必须变红,此时 bootstrap 会被 webShellBootstrapError 取代。
— qwen3.8-max via Qwen Code /review (v0.23.2)
| <DaemonSessionProvider | ||
| key="main-session" | ||
| sessionId={effectiveSessionId} | ||
| sessionSourceType={webShellProps.sessionSourceType} |
There was a problem hiding this comment.
[Suggestion] This one line is the whole connection between the host's sessionSourceType and DaemonSessionProvider, and no test at any level covers it. EmbeddedApp.test.tsx mocks @qwen-code/web-shell to a component returning null and only asserts the prop reaches that mock (:176), while the new DaemonSessionProvider tests pass sessionSourceType straight into renderWithProvider, bypassing this line; WorkspaceSessionProvider.test.tsx already records every provider prop in mocks.providerProps (:32) but never mentions sessionSourceType. Delete this line and restore requests carry no sourceType, applyRestoreSourceIfMissing (bridge.ts:7276) returns without stamping, and the half of the feature that makes the recovery durable never happens — the reopened conversation stays unattributed, the vscode-scoped history query never returns it, and the panel must re-run its catalog scan on every bootstrap forever. The whole suite stays green. Add a case rendering the provider with webShellProps={{ sessionSourceType: 'vscode' }} and asserting expect(mocks.providerProps.at(-1)).toMatchObject({ sessionSourceType: 'vscode' }), plus the negative (webShellProps={{}} forwards undefined).
Witness:
mutation probe in an isolated copy of the reviewed commit:
INTACT (line 334 present) -> WorkspaceSessionProvider.test.tsx (28 tests) Tests 28 passed (28)
MUTANT (sed -i '334d') -> Tests 28 passed (28) no flip
CONTROL (sed -i '333d', the adjacent sessionId prop)
-> Tests 9 failed | 19 passed (28) comparator can report a difference
FULL SUITE under the mutant -> Test Files 1 failed | 296 passed (297)
(the only failures were build-artifact.test.ts ENOENT from
the copy's excluded dist/, which pass 17/17 once linked)
CANDIDATE FIX added -> Tests 29 passed (29) intact; on the mutant:
expected { sessionId: 'session-a', …(8) } to match object
{ sessionSourceType: 'vscode' } Tests 1 failed | 28 passed (29)
Assert against mocks.providerProps.at(-1) in the existing style (:134) — mocks.providerProps is pushed by the mocked provider at :37-41 and reset in beforeEach at :97, so capturing props a second way would duplicate an existing mechanism.
That new case must go red when this line is deleted, which no current test does; the candidate fix above was measured to flip exactly that way.
中文说明
这一行是宿主的 sessionSourceType 与 DaemonSessionProvider 之间的全部连接,而任何层级都没有测试覆盖它。EmbeddedApp.test.tsx 把 @qwen-code/web-shell mock 成一个返回 null 的组件,只断言该 prop 到达了这个 mock(:176);新增的 DaemonSessionProvider 测试则把 sessionSourceType 直接传给 renderWithProvider,绕过了这一行;WorkspaceSessionProvider.test.tsx 已经把每个 provider prop 记录进 mocks.providerProps(:32),却从未提到 sessionSourceType。删掉这一行之后,恢复请求就不带 sourceType,applyRestoreSourceIfMissing(bridge.ts:7276)直接返回而不打标记,让恢复得以持久的那一半功能就永远不会发生——被重新打开的会话仍是无归因的,按 vscode 过滤的历史查询永远不会返回它,面板也就必须在每次 bootstrap 时重跑目录扫描。而整个测试套件依然全绿。建议补一个用例:以 webShellProps={{ sessionSourceType: 'vscode' }} 渲染该 provider,并断言 expect(mocks.providerProps.at(-1)).toMatchObject({ sessionSourceType: 'vscode' }),再加一个反向用例(webShellProps={{}} 时转发 undefined)。
证据:在被审 commit 的隔离副本中做变异探针。原始(保留 334 行):WorkspaceSessionProvider.test.tsx (28 tests) Tests 28 passed (28)。变异(sed -i '334d'):Tests 28 passed (28),没有翻转。对照(sed -i '333d',删掉相邻的 sessionId prop):Tests 9 failed | 19 passed (28),说明对照手段能够报出差异。变异下跑全套:Test Files 1 failed | 296 passed (297)(唯一的失败是副本排除了 dist/ 导致的 build-artifact.test.ts ENOENT,链接 dist 后 17/17 通过)。加上候选修复后:原始状态 Tests 29 passed (29);在变异下报 expected { sessionId: 'session-a', …(8) } to match object { sessionSourceType: 'vscode' },Tests 1 failed | 28 passed (29)。
约束:请按既有写法断言 mocks.providerProps.at(-1)(:134)——mocks.providerProps 由 :37-41 的被 mock provider 推入、并在 :97 的 beforeEach 中重置,所以用第二种方式捕获 props 会重复已有机制。
验收:删除这一行时新用例必须变红,而目前没有任何测试会;上面的候选修复已实测正好以这种方式翻转。
— qwen3.8-max via Qwen Code /review (v0.23.2)
| ...(effectSessionContext?.kind !== 'standalone' && | ||
| sessionSourceTypeRef.current !== undefined | ||
| ? { sourceType: sessionSourceTypeRef.current } | ||
| : {}), |
There was a problem hiding this comment.
[Suggestion] Restore-time attribution is unconditional for any workspace restore, so a host that sets this public prop permanently claims unattributed sessions it has no provenance for. The guard is on the session-context kind, not on whether the host has any evidence it created the session being restored — and the contrast with this PR's own scan is sharp: the scan gates its claim on a provenance record (ids the extension itself wrote to globalState, EmbeddedApp.tsx:85-91), while the restore path gates on nothing. sessionSourceType is a public prop of the published @qwen-code/web-shell, and the UI it ships lists sessions with sourceType: 'default' (useScopedSessions.ts:45,64, useOtherWorkspaceSessions.ts:48), a filter the daemon satisfies with unattributed rows too (session-list.ts:309-314); those rows open through App.tsx:12897 sessionActions.loadSession(...), the same action that now carries the host's source type. So any consumer that sets the prop and lets a user pick a pre-attribution CLI conversation from ResumeDialog, SessionOverviewPanel or SplitView stamps it — irreversibly, since apply-if-missing never overwrites (bridge.ts:7276-7282) and nothing on the restore path clears a source. The session leaves the default catalog for good and disappears from the browser Web Shell's history, resume dialog and session overviews, while the CLI user who created it sees it vanish. The VS Code panel avoids this only by configuration — sidebar={false} (EmbeddedApp.tsx:1535) and 'resume' in VSCODE_HIDDEN_SLASH_COMMANDS (:162), with hidden commands submitted as prompts rather than executed — so one entry removed from that list makes it reachable there too: the invariant holds by host configuration, not by construction. Consider making reclaim an explicit per-restore decision: keep sessionSourceType for creation and attach sourceType to a restore only when the caller marks it a legacy reclaim (for example loadSession(id, { claimUnattributedSource: true }) threaded into restoreRequest), with the panel setting it for ids it received in legacyConversationIds and for its persisted runtime.sessionId. Alternatively widen the panel's scan to accept a default stamp for ids its allowlist vouches for, since the allowlist — not the missing stamp — is the provenance evidence.
Witness:
probe in an isolated copy — a plain loadSession('legacy-unattributed') from a provider
with sessionSourceType: 'vscode' and no reclaim flag:
Tests 1 passed | 319 skipped (320)
MockDaemonSessionClient.load last called with
{ workspaceCwd: '/mock-workspace', timeoutMs: 70_000, sourceType: 'vscode' }
permanence read at the commit:
routes/session.ts:3866-3875 folds in the caller's restoreSource whenever
hasPersistedSource is false
bridge.ts:7276-7282 if (entry.sourceType !== undefined || req.sourceType === undefined)
return undefined; → apply-if-missing, no client can un-stamp
the browser arm of this claim was checked and REJECTED: main.tsx:210-247 builds
webShellProps without sessionSourceType, so browser restores forward undefined.
bridge.ts:7276 means no client-side fix can un-stamp an already-claimed session, and EmbeddedApp.tsx:504 queries sourceType: VSCODE_SESSION_SOURCE_TYPE, so the fix must still stamp the panel's own allowlisted restores — dropping attribution wholesale would leave recovered conversations dependent on the legacy scan at every bootstrap.
The test to add mirrors this diff's own standalone assertion (expect.not.objectContaining({ sourceType: expect.anything() })) for a plain loadSession('legacy-unattributed') from a provider with sessionSourceType: 'vscode' and no reclaim flag; it must go red if the gating is removed, while the flagged variant still asserts sourceType: 'vscode' is sent.
中文说明
恢复时的归因对任何工作区恢复都是无条件生效的,因此设置了这个公开 prop 的宿主会永久认领它并无出处依据的无归因会话。守卫判断的是 session context 的类型,而不是宿主是否有任何证据表明被恢复的会话是它创建的——这与本 PR 自己的扫描形成鲜明对比:扫描把认领建立在一份出处记录上(插件自己写进 globalState 的 id,EmbeddedApp.tsx:85-91),而恢复路径什么也不校验。sessionSourceType 是已发布包 @qwen-code/web-shell 的公开 prop,而它自带的界面会以 sourceType: 'default' 列出会话(useScopedSessions.ts:45,64、useOtherWorkspaceSessions.ts:48),daemon 对这个过滤条件同样会用无归因行来满足(session-list.ts:309-314);这些行通过 App.tsx:12897 sessionActions.loadSession(...) 打开,正是现在会携带宿主 source type 的那个 action。所以任何设置了该 prop 的宿主,只要让用户从 ResumeDialog、SessionOverviewPanel 或 SplitView 里选中一个归因机制出现之前的 CLI 会话,就会给它打上标记——而且不可逆,因为"缺失才补"从不覆盖(bridge.ts:7276-7282),恢复路径上也没有任何东西会清除 source。该会话从此永久离开 default 目录,并从浏览器 Web Shell 的历史、恢复对话框与会话总览中消失,而创建它的 CLI 用户会看到自己的会话凭空不见。VS Code 面板之所以避开这一点,靠的只是配置——sidebar={false}(EmbeddedApp.tsx:1535)以及 'resume' 位于 VSCODE_HIDDEN_SLASH_COMMANDS(:162),且隐藏命令是作为 prompt 提交而非执行——所以只要从那个列表里去掉一项,面板里也会变得可达:这条不变量是靠宿主配置成立的,不是靠结构成立的。建议把"认领"改成每次恢复的显式决定:sessionSourceType 仍用于创建,而只有当调用方标记这是一次遗留认领时(例如 loadSession(id, { claimUnattributedSource: true }) 并透传进 restoreRequest)才给恢复请求附上 sourceType,面板则只对它在 legacyConversationIds 中收到的 id 以及自己持久化的 runtime.sessionId 设置该标记。另一种做法是放宽面板的扫描,对白名单担保的 id 接受 default 标记,因为真正的出处证据是白名单,而不是"缺少标记"这件事。
证据:在隔离副本中探针——从一个 sessionSourceType: 'vscode' 且没有认领标记的 provider 发起普通的 loadSession('legacy-unattributed'):Tests 1 passed | 319 skipped (320),MockDaemonSessionClient.load 最后一次被调用时的参数是 { workspaceCwd: '/mock-workspace', timeoutMs: 70_000, sourceType: 'vscode' }。不可逆性引自 commit:routes/session.ts:3866-3875 在 hasPersistedSource 为 false 时把调用方的 restoreSource 合入;bridge.ts:7276-7282 是 if (entry.sourceType !== undefined || req.sourceType === undefined) return undefined;,即缺失才补,任何客户端都无法撤销标记。本条主张中关于浏览器的那一半已被核查并否定:main.tsx:210-247 构造的 webShellProps 不含 sessionSourceType,所以浏览器恢复转发的是 undefined。
约束:bridge.ts:7276 意味着任何客户端侧修复都无法撤销已被认领会话的标记,而 EmbeddedApp.tsx:504 查询的是 sourceType: VSCODE_SESSION_SOURCE_TYPE,所以修复仍必须为面板自己白名单内的恢复打标记——完全去掉归因会让恢复出来的会话在每次 bootstrap 时都依赖遗留扫描。
验收:要补的测试参照本 diff 自己的 standalone 断言(expect.not.objectContaining({ sourceType: expect.anything() })),针对从 sessionSourceType: 'vscode' 且无认领标记的 provider 发起的普通 loadSession('legacy-unattributed');去掉该门控时它必须变红,而带标记的变体仍要断言 sourceType: 'vscode' 被发出。
— qwen3.8-max via Qwen Code /review (v0.23.2)
| /** Stable client identity to reuse for session-scoped daemon requests. */ | ||
| clientId?: string; | ||
| /** | ||
| * Creator attribution forwarded on workspace session load/resume. The |
There was a problem hiding this comment.
[Suggestion] This prop is a free-form string that now rides on every workspace load/resume into a daemon field which is pattern-validated, reserved-source-checked and read as restore routing — and neither this JSDoc nor the public prop doc states any of it. A maintainer adding the next host wires sessionSourceType="JetBrains IDE" (or "VS Code"): parseRequestedSessionSource runs on the restore route before any restore work (routes/session.ts:3681-3682) and answers 400 invalid_session_source, and the SDK degrades gracefully only for a missing capability, not a rejected value (DaemonClient.ts:3793-3803 rethrows), so every session load and resume in that host fails and the user sees a session-load error rather than missing attribution. A host that picks a valid-but-foreign value gets worse: 'channel' passes validation, isChannelRestore (routes/session.ts:3876-3877) then flips the restore onto the channel path — worktree-context suppression at :3884 plus the ownership-lock redirect at :3921/:3932 — and the value is persisted onto pre-existing sessions the host never created. Nothing is broken today (the only in-tree caller passes 'vscode'); the hazard is that the constraint lives three packages away from the prop it governs, and the doc integrators actually read — WebShellProps.sessionSourceType at App.tsx:1141-1148 — still says "Creator attribution recorded on sessions this shell creates", which this line makes false for restore. State the contract there and here (forwarded on workspace load/resume; recorded on any restored session with no persisted source; must match the daemon's pattern and must not be reserved, because an invalid value fails the whole restore; and a host thereby claims any unattributed session it restores), and add it to packages/web-shell/README.md, where the prop is currently absent — or narrow the type / validate before it reaches restoreRequest.
Witness:
sweep of 21 plausible host labels through the real validators, in the route's own order
(routes/session.ts:747-780, SESSION_SOURCE_TYPE_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/):
vscode -> accepted (the only in-tree caller)
JetBrains IDE / VS Code / Zed / Cursor IDE / 1editor / _private
-> 400 invalid_session_source
standalone -> 400 reserved_session_source
channel / default / web-shell / zed / jetbrains / trae
-> accepted
rejected count: 7 / 21
routing half verified at the commit:
routes/session.ts:3876-3877 const isChannelRestore = restoreRequestMetadata.sourceType === 'channel';
gates :3884 (worktree-context suppression), :3921 / :3932 (ownership-lock redirect,
WorktreeSessionSupersededError); restoreRequestMetadata is spread into the bridge
restore calls at :4000 / :4015, where bridge.ts:7276-7282 persists it.
The error text lives at packages/acp-bridge/src/session-source.ts:49 and reserved values are rejected at routes/session.ts:750-780; any exclusion must not weaken the standalone omission this diff relies on (BridgeStandaloneRestoreSessionRequest omits the field, bridgeTypes.ts:278-281, pinned by the omits restore-time attribution for standalone sessions test).
If a guard or normalization is added rather than documentation, a DaemonSessionProvider.test.tsx case asserting an invalid sessionSourceType is dropped from the load request pins it — removing the guard must make the request carry the value again.
中文说明
这个 prop 是一个自由格式的 string,如今会随每一次工作区 load/resume 进入一个 daemon 字段,而该字段既要过格式校验、又要过保留 source 校验,还会被当作恢复路由来读——可无论是这段 JSDoc 还是公开的 prop 文档,都没有说明其中任何一点。维护者接入下一个宿主时写下 sessionSourceType="JetBrains IDE"(或 "VS Code"):parseRequestedSessionSource 会在恢复路由做任何恢复工作之前执行(routes/session.ts:3681-3682)并返回 400 invalid_session_source,而 SDK 只对"能力缺失"优雅降级、不会对"值被拒绝"降级(DaemonClient.ts:3793-3803 会重新抛出),于是该宿主里每一次会话加载与恢复都失败,用户看到的是会话加载错误,而不是"归因缺失"。选了一个合法但属于别处的值会更糟:'channel' 能通过校验,随后 isChannelRestore(routes/session.ts:3876-3877)会把恢复切到 channel 路径——:3884 抑制 worktree 上下文,:3921/:3932 触发归属锁重定向——并且这个值会被持久化到宿主从未创建过的既有会话上。今天没有任何东西是坏的(仓库内唯一的调用方传的是 'vscode');风险在于这条约束与它所约束的 prop 相隔三个包,而集成方真正会读的文档——App.tsx:1141-1148 的 WebShellProps.sessionSourceType——仍然写着"记录在本 shell 创建的会话上的创建者归因",这一行让它在恢复语义上变成了假的。建议在这里和那里都写清契约(会在工作区 load/resume 时转发;会被记录到任何没有持久化 source 的被恢复会话上;必须符合 daemon 的格式且不能是保留值,否则整个恢复会失败;并且宿主因此会认领它恢复的任何无归因会话),并补进目前没有提到该 prop 的 packages/web-shell/README.md——或者收窄类型、在到达 restoreRequest 之前做校验。
证据:把 21 个可能的宿主标签按恢复路由自身的顺序(routes/session.ts:747-780,SESSION_SOURCE_TYPE_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/)过一遍真实校验函数:vscode 通过(仓库内唯一调用方);JetBrains IDE、VS Code、Zed、Cursor IDE、1editor、_private 返回 400 invalid_session_source;standalone 返回 400 reserved_session_source;channel、default、web-shell、zed、jetbrains、trae 通过;拒绝数 7/21。路由那一半在 commit 上已核实:routes/session.ts:3876-3877 的 const isChannelRestore = restoreRequestMetadata.sourceType === 'channel'; 控制 :3884(抑制 worktree 上下文)与 :3921/:3932(归属锁重定向、WorktreeSessionSupersededError);restoreRequestMetadata 会在 :4000/:4015 被展开进 bridge 的恢复调用,并由 bridge.ts:7276-7282 持久化。
约束:错误文案在 packages/acp-bridge/src/session-source.ts:49,保留值在 routes/session.ts:750-780 被拒绝;任何排除逻辑都不得削弱本 diff 依赖的 standalone 省略(BridgeStandaloneRestoreSessionRequest 省略该字段,bridgeTypes.ts:278-281,并由 omits restore-time attribution for standalone sessions 测试钉住)。
验收:如果加的是守卫或归一化而不是文档,那么 DaemonSessionProvider.test.tsx 中一个断言非法 sessionSourceType 不会出现在 load 请求里的用例即可钉住它——移除该守卫后,请求必须重新携带该值。
— qwen3.8-max via Qwen Code /review (v0.23.2)
|
Follow-up with a real-daemon verification, no mocks: ran
The server-side contract this PR relies on behaves exactly as assumed against real files on disk. |
chiga0
left a comment
There was a problem hiding this comment.
No blocking findings.
Approval blockers: none.
Triage: Standard — contained feature fix touching extension host → webview → daemon chain, no persisted format changes, no wire protocol changes.
What I checked:
-
Allowlist contract (WebViewProvider → EmbeddedApp):
WebViewProvider.getAllConversations()readsglobalStateread-only;getRestorableDaemonSessionIdfilters outconv_*/temp*drafts; only real daemon IDs cross the bridge. No writes toglobalState— downgrade keeps working. -
Legacy scan correctness (
loadLegacyAllowlistedSessions): requestssourceType: 'default'which the server maps to unattributed sessions (confirmed by author's real-daemon verification: the JSONsourceTypekey is genuinely absent for these, sosession.sourceType === undefinedis the correct filter). Sessions explicitly stampeddefault(CLI/Web Shell) havesourceType: 'default'(defined) and are correctly excluded by the=== undefinedcheck. Workspace scoping viaworkspaceByCwdprevents cross-workspace CLI session leakage. -
Source attribution restoration:
DaemonSessionProviderforwardssessionSourceTypevia ref (mirrors prop without triggering effect), only for non-standalone contexts. TheeffectSessionContext?.kind !== 'standalone'guard correctly prevents standalone requests from carrying source attribution — confirmed by the negative test. The daemon'sapplyRestoreSourceIfMissingnever overwrites existing attribution — confirmed by author's real-daemon test (vscode-stamped session + cli sourceType request → stays vscode). -
Scan convergence:
legacyScanDoneRefprevents re-scanning after first successful scan; reset onruntime?.legacyConversationIdschange (fresh bootstrap). The worst case is 10 pages × 100 = 1000 catalog reads, bounded and one-shot. -
Error handling: Legacy scan failure → fail-open (empty legacy list, ordinary vscode history unaffected);
conversationStoreread failure → warn and ship no legacy IDs. Neither path blocks the panel from loading. -
Test validity:
EmbeddedApp.test.tsxasserts the panel shows exactly the vscode + legacy pair while excluding CLI and browser sessions — mutation of the new guard (removing!legacyScanDoneRef.current) is claimed to turn the new assertion red, confirming the assertion is load-bearing.DaemonSessionProvider.test.tsxhas both a positive test (forwards sourceType) and negative test (standalone omits sourceType). -
Cross-check vs existing reviews:
qwen-code-ci-botapproved (LGTM). Author's real-daemon verification at the current head confirms the server-side contract (sourceType absent in JSON for unattributed sessions; load with sourceType moves session between catalogs; existing attribution never overwritten). No outstanding blockers from other reviewers.
Unreviewed dimensions: Windows runtime behavior (no host available), no real E2E harness in the companion package. Not approval blockers — the author's real-daemon verification against an isolated QWEN_HOME covers the server-side contract with real files.
Reviewed with AI assistance.
|
Released in v0.23.3. |
Dropping the sourceType filter (#11574) also re-admitted machine-owned catalog rows — channel conversations, scheduled-task keepalives, side-task branches, and sub-agent children — which rendered as ordinary chats and carried the panel's rename/permanent-delete actions with no source-ownership guard on the daemon side. Filter those rows before they reach the dropdown and fetch until a full page of presentable sessions is collected, since client-side filtering shortens each page while the raw cursor still advances. Also pin the removal of the #11495 legacy allowlist on both sides of the bootstrap payload contract, and correct the session-source docs/tests that still asserted the reversed source-scoped decision. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-closeout/jmtwii8zex8
What this PR does
Upgrading the VS Code companion from v0.21.x to v0.23.x made the panel's conversation history come up empty even though every transcript was still on disk. Root cause: the WebShell UI cutover (#9811) moved the panel history to the daemon's session catalog filtered by
sourceType: 'vscode', and sessions recorded before creator attribution existed carry no source metadata at all, so the filter excluded every pre-upgrade conversation.This PR restores those conversations without weakening the source separation the cutover introduced:
globalStateconversation store read-only and ships only the restorable conversation ids (entries that were renamed to a real daemon session id;conv_*/temp*drafts excluded) to the webview as an allowlist.DaemonSessionProvidernow forwards the host's existingsessionSourceTypeon workspace load/resume, and the daemon's existing apply-if-missing restore attribution (applyRestoreSourceIfMissing) stamps itvscode— after one open the session is permanently back in the vscode catalog.globalStateis never written (downgrade keeps working), CLI terminal and browser Web Shell sessions stay out of the panel, and there are no daemon/serve/SDK API changes — the fix rides the existing restore-time attribution field.Why it's needed
Users upgrading from v0.21.x to v0.23.x lose their entire visible conversation history in the panel (P1, reporter lost 69 conversations with messages). No data was deleted — the new catalog query filtered it out — so the fix only needs to make the existing transcripts visible and resumable again, not migrate anything.
Reviewer Test Plan
How to verify
Component tests cover both new boundaries end to end:
packages/vscode-ide-companion:npx vitest run src/webview/EmbeddedApp.test.tsx src/webview/providers/WebViewProvider.test.ts— bootstrap ships only restorable legacy ids (conv_*/temp*excluded, store untouched); the history list merges allowlisted unattributed sessions from the default catalog while excluding non-allowlisted CLI sessions and browser-stamped ones; the legacy scan runs once per bootstrap (mutation-checked: removing the guard turns the new assertion red).packages/web-shell:npx vitest run client/daemon/session/DaemonSessionProvider.test.tsx— workspace restore requests now carry the hostsessionSourceType, and standalone restores provably do not (the standalone request type omits source attribution).Manual spot-check for reviewers who want the real thing: install a v0.21.x companion, have a few conversations, upgrade to a build from this branch, open the panel history — the old conversations are listed again, and opening one keeps it in the history after a reload (now via the vscode catalog). CLI sessions from the same workspace must not appear.
Evidence (Before & After)
Before:
EmbeddedApp.loadSessionHistoryqueried onlysourceType: 'vscode'; a pre-upgrade session hassourceType === undefined, so the panel rendered an empty history while~/.qwen/tmp/<project-hash>/chats/*.jsonlstill held every transcript (reporter's downgrade probe in the issue confirms the data survives). After: the new EmbeddedApp test seeds a vscode session, an unattributed allowlisted legacy session, an unattributed CLI session, and a browser-stamped session, and asserts the panel shows exactly the vscode + legacy pair.Tested on
Environment (optional)
Unit/component tests only:
env -i npx vitest runper package (clean-env to isolate from a running Qwen Code session).Risk & Scope
conv_*drafts have no daemon transcript and stay unlisted;agent-host.dbis not involved (it is not the transcript store).globalStatedata is never modified, and restore-time attribution only fills in missing source metadata through the existing apply-if-missing path — it never overwrites an existing source.Linked Issues
Fixes #11489
中文说明
这个 PR 做了什么
将 VS Code 插件从 v0.21.x 升级到 v0.23.x 后,面板的会话历史会变成空白,尽管所有 transcript 仍然完好地保存在磁盘上。根因:WebShell UI 切换(#9811)把面板历史列表改成了按
sourceType: 'vscode'过滤的 daemon 会话目录查询,而在来源标记机制存在之前录制的会话没有任何 source 元数据,因此过滤器把所有升级前的会话全部排除在外。本 PR 在不削弱切换引入的来源隔离的前提下恢复这些会话:
globalState会话存储,只把可恢复的会话 id(已被重命名为真实 daemon 会话 id 的条目;conv_*/temp*草稿被排除)作为白名单发给 webview。DaemonSessionProvider现在会在工作区会话的 load/resume 请求中转发宿主已有的sessionSourceType,daemon 现有的"缺失才补"的恢复归因(applyRestoreSourceIfMissing)会把它标记为vscode——打开一次之后,该会话就永久回到 vscode 目录中。globalState全程不被写入(降级依然可用),CLI 终端会话和浏览器 Web Shell 会话不会进入面板,且没有 daemon/serve/SDK 的 API 变更——修复完全复用现有的恢复时归因字段。为什么需要
从 v0.21.x 升级到 v0.23.x 的用户会在面板中丢失全部可见的会话历史(P1,报告者丢失了 69 条带消息的会话)。数据从未被删除——只是被新的目录查询过滤掉了——所以修复只需要让现有 transcript 重新可见、可继续,不需要任何数据迁移。
评审验证计划
如何验证
组件级测试端到端覆盖了两个新边界:
packages/vscode-ide-companion:npx vitest run src/webview/EmbeddedApp.test.tsx src/webview/providers/WebViewProvider.test.ts——bootstrap 只携带可恢复的遗留 id(排除conv_*/temp*,且不写存储);历史列表会从 default 目录合并白名单命中的无标记会话,同时排除未在白名单中的 CLI 会话和已打浏览器标记的会话;遗留扫描每次 bootstrap 只跑一次(已做变异验证:去掉守卫后新断言会变红)。packages/web-shell:npx vitest run client/daemon/session/DaemonSessionProvider.test.tsx——工作区会话的恢复请求现在携带宿主的sessionSourceType,且 standalone 恢复被证明不会携带(standalone 请求类型本身不含来源归因)。想做真实环境抽查的评审:安装 v0.21.x 插件并进行几轮会话,升级到本分支构建,打开面板历史——旧会话重新出现,打开其中一个后重新加载依然存在(这次走 vscode 目录)。同一工作区的 CLI 会话不应出现。
前后对比证据
修复前:
EmbeddedApp.loadSessionHistory只查询sourceType: 'vscode';升级前的会话sourceType === undefined,因此面板渲染出空历史,而~/.qwen/tmp/<project-hash>/chats/*.jsonl里仍保存着全部 transcript(issue 中报告者的降级探测证实数据完好)。修复后:新的 EmbeddedApp 测试构造了一个 vscode 会话、一个白名单命中的无标记遗留会话、一个无标记 CLI 会话和一个浏览器标记会话,并断言面板恰好只显示前两者。测试平台
环境(可选)
仅单元/组件测试:每个包内用
env -i npx vitest run(干净环境以隔离正在运行的 Qwen Code 会话)。风险与范围
conv_*草稿存在过的会话没有 daemon transcript,保持不可见;agent-host.db与本修复无关(它不是 transcript 存储)。globalState数据从不被修改;恢复时归因只通过现有的"缺失才补"路径填充缺失的来源元数据,绝不覆盖已有来源。关联 Issue
Fixes #11489