fix(web-shell): keep the legacy session provider mounted during host catch-up - #9178
fix(web-shell): keep the legacy session provider mounted during host catch-up#9178wenshao wants to merge 1 commit into
Conversation
…catch-up On legacy daemons (no client_identity feature), reporting a lazily created session back to the host re-keyed DaemonSessionProvider while the admission chain was still attaching that very session. The remount detached the freshly created session, silently dropped the first prompt, and snapped the UI back to the previous workspace's session — so a "New task" click on workspace B visibly produced workspace A's state. Track the session the mounted legacy provider actually serves and keep the provider key stable when the host merely catches up to it; genuine target switches still remount as before. Adds unit coverage for the catch-up suppression and the observed-session reset, plus a multi-workspace e2e spec covering legacy and transactional daemons across busy/idle sessions, SSE traffic, turn-completion timings, and daemon latency.
E2E test reportNew spec Coverage matrix:
Wire-level oracles per round: exactly one Without the provider change the two legacy tests fail with the created session double-loaded and double-detached, no prompt request sent, and the header snapping back to workspace A. Full web-shell chromium suite: 53/53 passed (1.2m). Unit tests for the changed provider: 12/12, both new tests mutation-verified to fail when the fix is removed. |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
@wenshao The regression this PR fixes was real — the before/after wire trace and the Playwright tests that fail without the provider change are solid evidence. But main moved under it this morning, and the ground it patches is gone:
- #9129 (merged today,
c5bf2224) reverted #8882's transactional switching model wholesale. The legacy/transactional remount-key machinery this PR modifies — theobserveSessionStatetransactional branch, the committed-target refs, the computedDaemonSessionProviderkey — was deleted by that revert. Current main renders<DaemonSessionProvider key="main-session">with a static key. - The bug's root cause was the computed key flipping when the host echoed a lazily-created session back into the controlled props, remounting the provider mid-admission. With a static key there is no identity-change remount at all, so this mechanism can no longer occur on main. The branch also conflicts with main on exactly these files (
WorkspaceSessionProvider.tsxwas rewritten by the revert hours before this PR opened;mockDaemon.tsmoved on Aug 12–13 too). - No
pull_requestCI has run on the head commit — a mergeable fork PR (e.g. #9174) gets the full Test/Integration/Desktop suite, so CI should start once the branch can rebase. Until then the new 12-test spec has no signal from the repo's own runners.
Before this goes further: could you re-verify on current main whether the user-visible symptom still reproduces (legacy daemon without client_identity, two workspaces, New task on B while A is busy, first prompt)?
- If it does — presumably through
DaemonSessionProvider's internal prop-change handling now, rather than a provider remount — the fix would need to be retargeted there. The new e2e spec, with its per-session SSE scoping, busy-session modeling, and wire-level oracles, looks like it still applies and would be a great starting point. - If it doesn't, closing this in favor of #9129's simpler loading-skeleton model is the right call.
中文说明
@wenshao 这个 PR 修复的回归确实存在——before/after 请求轨迹和"不带修复即失败"的 Playwright 测试都是很扎实的证据。但 main 今天上午已经变化,本 PR 修改的代码基础已不存在:
- #9129(今天合入,
c5bf2224)整体回退了 #8882 的 transactional 切换模型。 本 PR 修改的 legacy/transactional remount-key 机制——observeSessionState的 transactional 分支、committed-target refs、计算得出的DaemonSessionProviderkey——已被该回退删除。当前 main 渲染的是静态 key 的<DaemonSessionProvider key="main-session">。 - 该 bug 的根因是:宿主把懒创建的新会话回写受控 props 时,计算出的 key 发生变化,导致 provider 在 admission 进行到一半时被 remount。静态 key 下不再有"身份变化即 remount"的行为,因此这个机制在 main 上已不可能发生。分支也恰好在这些文件上与 main 冲突(
WorkspaceSessionProvider.tsx在本 PR 提交前几小时被回退重写;mockDaemon.ts在 8 月 12–13 日也有变动)。 - head commit 上没有跑过任何
pull_requestCI——可合并的 fork PR(如 #9174)会有完整的 Test/Integration/Desktop 套件,分支能 rebase 后 CI 应该会启动。在此之前,新增的 12 个测试在仓库自己的 runner 上还没有任何信号。
在继续之前:能否在当前 main 上重新验证用户可见症状是否仍能复现(无 client_identity 的 legacy daemon、两个 workspace、A 忙碌时点击 B 的"新建任务"、提交首条 prompt)?
- 如果仍能复现——大概是通过
DaemonSessionProvider内部的 prop 变更处理而非 provider remount——修复需要改到那里。新增的 e2e spec(含按会话隔离的 SSE、忙碌会话建模和 wire 级 oracle)看起来依然适用,是很好的起点。 - 如果不能复现,那么关闭本 PR、采用 #9129 更简的 loading-skeleton 模型就是正确的选择。
— Qwen Code · qwen3.8-max
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline.
Not explored to full depth (tool budget reached): "PR #9178 fixes a Web Shell bug: on legacy daemons (no…": none — all checks I started were completed within budget.; "PR #9178 fixes a Web Shell bug: on legacy daemons (no…": could not execute the unit tests — the review worktree has no node_modules (vitest fails to resolve vitest/config ) and dependency installation exceeded the …; "PR #9178 fixes a Web Shell bug: on legacy daemons (no…": could not execute the Playwright E2E spec (requires build + Playwright server); E2E oracle assessment is from reading the spec.; "PR #9178 fixes a Web Shell bug: on legacy daemons (no…": did not execute the new Playwright E2E spec (no browser/dev-server in this environment) — the round-2 legacy key-collision path is verified by code reading only…; "PR #9178 fixes a Web Shell bug: on legacy daemons (no…": did not read clearSession / loadSession implementations past the controlled-effect dispatch layer., and 4 more.
中文说明
已审查。 建议见行内评论。
未探索到全部深度(达到工具调用预算):"PR #9178 fixes a Web Shell bug: on legacy daemons (no…":none — all checks I started were completed within budget.;"PR #9178 fixes a Web Shell bug: on legacy daemons (no…":could not execute the unit tests — the review worktree has no node_modules (vitest fails to resolve vitest/config ) and dependency installation exceeded the …;"PR #9178 fixes a Web Shell bug: on legacy daemons (no…":could not execute the Playwright E2E spec (requires build + Playwright server); E2E oracle assessment is from reading the spec.;"PR #9178 fixes a Web Shell bug: on legacy daemons (no…":did not execute the new Playwright E2E spec (no browser/dev-server in this environment) — the round-2 legacy key-collision path is verified by code reading only…;"PR #9178 fixes a Web Shell bug: on legacy daemons (no…":did not read clearSession / loadSession implementations past the controlled-effect dispatch layer.,另有 4 条。
— qwen3.8-max via Qwen Code /review (v0.21.11)
| .toBe(true); | ||
| } | ||
|
|
||
| async function fillComposer(page: Page, text: string): Promise<void> { |
There was a problem hiding this comment.
[Suggestion] This spec-local fillComposer is a character-for-character copy of the already-exported fillComposer in e2e/visuals/harness.ts (same [data-web-shell-composer-editor] .cm-content locator, click, platform-conditional select-all, typing); this adds another inline copy to the functional e2e subtree, which already has ~6. — Failure scenario: when the composer DOM/interaction changes (editor attribute rename, select-all behavior, mobile flow), one copy gets updated and the others silently keep typing into the old shape — the helpers diverge with no compiler signal, producing hard-to-diagnose spec failures in exactly the suite meant to guard the flow. Suggested fix: hoist the harness's fillComposer into e2e/utils/ and import it from both places (a visuals module is an odd dependency for a functional spec), deduplicating the pre-existing copies too.
中文说明
这个 spec 内的 fillComposer 与 e2e/visuals/harness.ts 中已导出的 fillComposer 逐字符相同(同样的 [data-web-shell-composer-editor] .cm-content 定位器、点击、按平台选择全选、输入),functional e2e 子树里已有约 6 份内联副本,这里又加了一份。— 失败场景:当 composer 的 DOM/交互变化时(editor 属性改名、全选行为、移动端流程),某一份副本被更新而其余副本仍按旧形态输入——各副本在没有编译器信号的情况下悄然分叉,恰恰在这条本应守护该流程的套件里产生难以诊断的失败。建议修复:把 harness 的 fillComposer 提升到 e2e/utils/,两处都改为导入(functional spec 依赖 visuals 模块本身也别扭),顺带去掉已有副本。
— qwen3.8-max via Qwen Code /review (v0.21.11)
| return request.method === 'POST' && request.path === '/session'; | ||
| } | ||
|
|
||
| function isPromptRequest(request: DaemonRequestRecord): boolean { |
There was a problem hiding this comment.
[Suggestion] The prompt-route predicate is now duplicated — this isPromptRequest(request) re-encodes the match MockDaemonController.promptRequests() already implements in utils/mockDaemon.ts, and the two filters already differ today: this one is anchored (^\/session\/…) and requires method === 'POST', while promptRequests() is unanchored with no method check. The name also collides with the unrelated body validator isPromptRequest(body) in mockDaemon.ts. — Failure scenario: if the recorded prompt path shape ever changes, waitForPromptAdmission polls this stale predicate for the full 15 s deadline and fails with "prompt not admitted" even though daemon.promptRequests() shows the prompt landed — a misleading failure caused purely by duplicated-regex drift. Suggested fix: export the predicate from utils/mockDaemon.ts (e.g. isPromptRequestRecord), implement promptRequests() with it, and import it here.
中文说明
prompt 路由谓词现在重复了——这个 isPromptRequest(request) 重新编码了 utils/mockDaemon.ts 中 MockDaemonController.promptRequests() 已实现的匹配,而且两个过滤器如今就已不同:这里带锚点(^\/session\/…)且要求 method === 'POST',而 promptRequests() 不带锚点、不检查 method。名字还与 mockDaemon.ts 里无关的请求体校验函数 isPromptRequest(body) 冲突。— 失败场景:如果记录的 prompt 路径形态变化,waitForPromptAdmission 会对着这个过时的谓词轮询满 15 秒然后以 "prompt not admitted" 失败,尽管 daemon.promptRequests() 显示 prompt 已到达——纯粹由重复正则漂移导致的误导性失败。建议修复:从 utils/mockDaemon.ts 导出该谓词(如 isPromptRequestRecord),用它实现 promptRequests(),这里改为导入。
— qwen3.8-max via Qwen Code /review (v0.21.11)
| const legacyProviderKeyCandidate = `${targetWorkspace?.id ?? effectiveWorkspaceId ?? 'primary'}:${effectiveSessionId ?? 'new'}`; | ||
| const suppressLegacyCatchUpRemount = | ||
| transactionalRef.current === false && | ||
| effectiveSessionId !== undefined && |
There was a problem hiding this comment.
[Suggestion] This effectiveSessionId !== undefined guard is untested — deleting it is a one-line mutation that survives every unit test and every E2E scenario this PR adds (measured: the suite stays 12/12 green under the mutant). Without the guard, observedLegacySessionIdRef.current === effectiveSessionId is true whenever both are undefined (nothing observed yet), so a legacy-mode switch from a target whose provider never saw a connected sessionId (load failed, or connection not yet reported) to a new-session target resolves legacyProviderKey to the stale previous key and silently skips the remount legacy daemons require. The code under review is correct — this is a surviving-mutant coverage gap, not a shipping bug. — Failure scenario: a future edit that drops or reorders this guard ships with every test still green and reproduces the wrong-workspace class of bug this PR fixes. Suggested fix: add a legacy-mode unit test — mocks.connection = { status: 'connected' } (no sessionId), renderTarget('session-x', '/work/a'), then renderTarget(undefined, '/work/b') and assert providerMounts increments; this test fails under the mutant and passes with the guard.
中文说明
这个 effectiveSessionId !== undefined 守卫没有被测试覆盖——删掉它是一行变异,能活过本 PR 新增的全部单测和 E2E 场景(实测:变异后套件仍是 12/12 全绿)。没有该守卫时,只要两边都是 undefined(尚未观察到任何会话),observedLegacySessionIdRef.current === effectiveSessionId 就为 true,于是从一个 provider 从未见到已连接 sessionId 的目标(加载失败、或连接尚未上报)切到新建会话目标时,legacyProviderKey 会解析为陈旧的上一个 key,静默跳过 legacy daemon 必需的 remount。被审代码本身是正确的——这是一个存活变异的覆盖缺口,不是会出货的 bug。— 失败场景:未来某次编辑删掉或重排这个守卫,所有测试依旧全绿就能出货,复现的正是本 PR 修复的错 workspace 一类 bug。建议修复:新增一个 legacy 模式单测——mocks.connection = { status: 'connected' }(不带 sessionId),renderTarget('session-x', '/work/a'),再 renderTarget(undefined, '/work/b') 并断言 providerMounts 增加;该测试在变异下失败、带守卫时通过。
— qwen3.8-max via Qwen Code /review (v0.21.11)
| const suppressLegacyCatchUpRemount = | ||
| transactionalRef.current === false && | ||
| effectiveSessionId !== undefined && | ||
| observedLegacySessionIdRef.current === effectiveSessionId; |
There was a problem hiding this comment.
[Suggestion] This suppression silently load-bears an un-pinned SDK contract. The catch-up flow it enables delivers sessionId undefined → already-connected-session in place to the mounted DaemonSessionProvider; that is only safe because the SDK's controlled-session effect no-ops that transition (the sameLogicalTarget early return in packages/webui/src/daemon/session/DaemonSessionProvider.tsx). Nothing in either package documents or tests the contract: WorkspaceSessionProvider.test.tsx mocks DaemonSessionProvider wholesale, and no SDK test covers this exact transition (the nearest asserts a different one). — Failure scenario: if a future refactor of that ~5,400-line effect makes a changed sessionId prop always re-load, every legacy catch-up starts a load/detach cycle against the session whose first prompt was just admitted — resurrecting exactly the dropped-prompt/workspace-snap regression this PR fixes, with no test in either package to catch it. Suggested fix: add an SDK unit test asserting that a controlled sessionId transition from undefined → <currently connected session id> (same workspace) performs no load/detach, and/or leave a one-line comment at the sameLogicalTarget branch naming this dependency.
中文说明
这个抑制逻辑悄悄依赖了一个未被钉住的 SDK 契约。它所启用的 catch-up 流程会把 sessionId 从 undefined → 已连接会话原地交给已挂载的 DaemonSessionProvider;这之所以安全,仅仅因为 SDK 的 controlled-session effect 对该转换不做任何操作(packages/webui/src/daemon/session/DaemonSessionProvider.tsx 里的 sameLogicalTarget 提前返回)。两个包都没有任何文档或测试钉住这个契约:WorkspaceSessionProvider.test.tsx 整体 mock 了 DaemonSessionProvider,SDK 测试也没有覆盖这个精确转换(最接近的一个断言的是另一种转换)。— 失败场景:如果未来对那个约 5400 行 effect 的重构让 "sessionId prop 变了就重新 load",每次 legacy catch-up 都会对刚接收了首条 prompt 的会话发起 load/detach 循环——恰好复活本 PR 修复的丢 prompt/弹回 workspace 回归,且两个包都没有测试能抓到。建议修复:新增一个 SDK 单测,断言受控 sessionId 从 undefined → <当前已连接会话 id>(同 workspace)不产生 load/detach;并/或在 sameLogicalTarget 分支处留一行注释说明这一依赖。
— qwen3.8-max via Qwen Code /review (v0.21.11)
| scenario.createdSessions = { | ||
| ...scenario.createdSessions, | ||
| [sessionId]: workspaceCwd, | ||
| }; |
There was a problem hiding this comment.
[Suggestion] Created sessions never appear in the workspace catalog. The POST /session handler records the new session in createdSessions but never appends it to scenario.sessions, so the workspace-scoped catalog this same PR adds can never list a created session — while the real daemon merges live bridge sessions into its catalog response, so the mock diverges from the real daemon in exactly the dimension under test. — Failure scenario: after round 1 creates a session in workspace B, B's sidebar section renders from a catalog that omits it; a regression that files a lazily-created session into the wrong workspace's sidebar (or drops it) is unobservable by this harness, and any future test step relying on the catalog containing the created session silently operates on an empty list instead of failing loudly. Suggested fix: in this handler, also append a session summary (sessionId, workspaceCwd resolved as above, createdAt: now, clientCount: 0, hasActivePrompt: false) to scenario.sessions.
中文说明
创建的会话永远不会出现在 workspace 目录里。POST /session 处理器把新会话记入 createdSessions,但从不追加到 scenario.sessions,因此本 PR 同一处新增的按 workspace 过滤的目录永远列不出被创建的会话——而真实 daemon 会把 bridge 上的活会话合并进目录响应,mock 恰好在本 PR 受测的维度上与真实 daemon 分叉。— 失败场景:round 1 在 workspace B 创建会话后,B 的侧边栏区块渲染自一个不含该会话的目录;把懒创建的会话归错 workspace 侧边栏(或丢掉)的回归在这个 harness 下不可观测,未来任何依赖目录包含已创建会话的测试步骤都会静默地对空列表操作而不是响亮地失败。建议修复:在此处理器中同时向 scenario.sessions 追加一条会话摘要(sessionId、按上述方式解析的 workspaceCwd、createdAt: now、clientCount: 0、hasActivePrompt: false)。
— qwen3.8-max via Qwen Code /review (v0.21.11)
| await expect | ||
| .poll(() => daemon.requests.some(isCreateRequest), { | ||
| timeout: 15_000, | ||
| }) | ||
| .toBe(true); |
There was a problem hiding this comment.
[Suggestion] This hard poll throws before the soft assertions can collect evidence. In the turnComplete: 'during-admission' variants, if the regression manifests as S2 — the prompt is admitted to session A instead of creating a session, the variant named in this file's own comment — no POST /session ever lands; this expect.poll(...).toBe(true) times out after 15 s and throws before finishSessionA(), waitForPromptAdmission, all four round-1 expect.soft calls, and round 2. — Failure scenario: the failure output is a bare poll timeout instead of the admission evidence the soft-assertion design ("Soft assertions so both rounds run and the full picture lands in the failure output") exists to collect, so the failure cannot distinguish S2 from S1 from a dropped prompt. Suggested fix: replace the hard poll with a bounded boolean wait (like waitForPromptAdmission), or wrap it in try/finally so evidence collection and the soft assertions always run.
中文说明
这个硬轮询会在 soft 断言收集证据之前抛错。在 turnComplete: 'during-admission' 变体中,如果回归以 S2 形态出现——prompt 被 session A 接收而不是创建新会话,正是本文件注释里点名的变体——就不会有任何 POST /session 到达;这个 expect.poll(...).toBe(true) 会在 15 秒后超时抛出,早于 finishSessionA()、waitForPromptAdmission、全部四个 round-1 expect.soft 断言以及 round 2。— 失败场景:失败输出只是一个干巴巴的轮询超时,而不是 soft 断言设计("Soft assertions so both rounds run and the full picture lands in the failure output")本要收集的证据,失败因此无法区分 S2、S1 和 prompt 被丢弃。建议修复:把硬轮询换成有界的布尔等待(类似 waitForPromptAdmission),或用 try/finally 包裹,确保证据收集和 soft 断言总会执行。
— qwen3.8-max via Qwen Code /review (v0.21.11)
| await expect(page.locator(SUBMIT_BUTTON)).toHaveAttribute( | ||
| 'aria-label', | ||
| SEND_LABEL, | ||
| ); | ||
|
|
||
| // ---- Round 2: second click on workspace B's "New task" button. |
There was a problem hiding this comment.
[Suggestion] This hard idle-wait aborts round 2 exactly when round 1 misbehaved. If round 1 misbehaves — S2 (the prompt was admitted to busy session A, whose stream the UI has already detached from, so the turnCompleteEvent sent above is a no-op) or a dropped prompt — the composer never returns to the send state, this auto-retrying expect throws at the Playwright timeout, and round 2 never executes, even though round 1's four soft assertions already recorded failures and the stated intent is that both rounds run. Five turnComplete: 'never' variants traverse this shared code. — Failure scenario: the failure report loses exactly the round-2 evidence the soft-assertion structure was designed to keep. Suggested fix: only require the idle state when round 1 actually behaved (e.g. gate on round1Admitted && createdSessionId), or downgrade the wait to a soft/bounded check so round 2's evidence is still collected.
中文说明
这个硬空闲等待恰好在 round 1 出错时中止 round 2。如果 round 1 出错——S2(prompt 被繁忙的 session A 接收,而 UI 已从 A 的流上 detach,上面发出的 turnCompleteEvent 因此是空操作)或 prompt 被丢弃——composer 永远不会回到发送态,这个自动重试的 expect 会在 Playwright 超时后抛出,round 2 根本不会执行,尽管 round 1 的四个 soft 断言已经记录了失败、且明说的意图是两轮都要跑。五个 turnComplete: 'never' 变体都走这段共享代码。— 失败场景:失败报告恰好丢掉了 soft 断言结构本要保留的 round-2 证据。建议修复:只在 round 1 确实正常时才要求空闲态(例如以 round1Admitted && createdSessionId 为条件),或把该等待降级为 soft/有界检查,让 round 2 的证据仍被收集。
— qwen3.8-max via Qwen Code /review (v0.21.11)
| testInfo: TestInfo, | ||
| traffic: TrafficOptions, | ||
| ): Promise<void> { | ||
| const scenario = buildTwoWorkspaceScenario('transactional', { |
There was a problem hiding this comment.
[Suggestion] The local-prompt scenario hardcodes the transactional daemon. runLocalPromptScenario builds buildTwoWorkspaceScenario('transactional', …) for what this file's comment calls "the most common real-world shape" of a busy session, but the legacy daemon — the only mode whose behavior this PR changes — is never exercised with a client-locally-active prompt (the legacy tests only model busy-via-restored-envelope). — Failure scenario: a regression in the legacy catch-up suppression that manifests only when session A's busy state is client-side (composer cancel state, in-tab prompt bookkeeping, detach-while-own-prompt-in-flight) passes every test in this PR and ships to legacy daemons; the matrix cell legacy × local-prompt is uncovered. Suggested fix: parameterize runLocalPromptScenario on DaemonMode and register at least one legacy variant, mirroring the restored-busy suite which runs both modes.
中文说明
local-prompt 场景写死了 transactional daemon。runLocalPromptScenario 用 buildTwoWorkspaceScenario('transactional', …) 构造本文件注释称为"最常见的真实繁忙形态"的场景,但 legacy daemon——本 PR 唯一改变行为的模式——从未在客户端本地活跃 prompt 下被测试过(legacy 测试只建模经恢复信封的繁忙态)。— 失败场景:legacy catch-up 抑制中只有当 session A 的繁忙态是客户端侧时才显现的回归(composer 取消态、页内 prompt 记账、自己的 prompt 进行中时 detach),能通过本 PR 的全部测试并出货到 legacy daemon;legacy × local-prompt 这个矩阵单元没有覆盖。建议修复:把 runLocalPromptScenario 参数化为 DaemonMode,至少注册一个 legacy 变体,与已覆盖两种模式的 restored-busy 套件对齐。
— qwen3.8-max via Qwen Code /review (v0.21.11)
|
Closing as superseded: #9129 (revert #8882's transactional session switching to the loading-skeleton model) removes the keyed-remount machinery this PR was patching — the main session provider key is now constant, so the host catch-up can no longer remount the provider mid-admission on legacy daemons. Verified by running this PR's multi-workspace new-session e2e spec against bare main: all 12 variants pass, including the two legacy scenarios that fail on the pre-#9129 architecture. The spec and reproduction notes are kept locally for reference. |
What this PR does
On daemons that do not advertise the client identity feature, the Web Shell remounts its session provider whenever the controlled session/workspace target identity changes. When a new session is lazily created for the first prompt, the shell reports that session to the host, and the host's prop update lands while the attach chain for the very same session is still in flight — changing the target identity and remounting the provider mid-admission. The remount detaches the freshly created session, silently drops the first prompt, and snaps the UI back to the previous session's workspace. This PR tracks which session the mounted provider is actually serving and keeps it mounted when the host is merely catching up to that session; genuine target switches still remount exactly as before. The change is scoped to the legacy path — daemons with client identity keep the existing single-provider behavior.
Why it's needed
With more than one workspace registered, clicking "New task" on workspace B while workspace A's session is connected (for example, running a task) and submitting the first prompt visibly produced workspace A's state: the created session was torn down, the typed prompt vanished, and the shell snapped back to A, while a second click behaved differently. Legacy daemons are old but still supported for compatibility, and this made cross-workspace session creation unreliable on them.
Reviewer Test Plan
How to verify
The regression is covered by a new Playwright spec against the mock daemon plus unit tests for the provider key behavior:
cd packages/web-shell && npx playwright test client/e2e/web-shell.multi-workspace-new-session.spec.ts --project=chromium— 12 tests covering legacy and transactional daemons across busy/idle sessions, SSE traffic, turn-completion timings, and daemon latency. Without the provider change, the two legacy tests fail with the created session double-loaded and double-detached, no prompt request ever sent, and the header snapping back to workspace A; with the change all 12 pass.cd packages/web-shell && npx vitest run client/components/WorkspaceSessionProvider.test.tsx— includes a test asserting the host catch-up does not remount the provider, and one asserting a stale observation from a previous provider instance cannot suppress a genuine remount. Both were mutation-verified to fail when the fix is removed.Behavior a reviewer can confirm manually against a legacy daemon (no client_identity): open the standalone Web Shell on a session in workspace A, click "New task" on workspace B in the sidebar, submit a prompt — the new session should be created under B and admit the prompt, instead of snapping back to A.
Evidence (Before & After)
Before (legacy mock daemon, request trace for the first New-task click on B):
POST /sessioncorrectly targets B, but the created session then receives twoloadand twodetachrequests,POST /session/<new>/promptnever fires, and the header falls back to workspace A's session identity. After: a single create → load → attach chain, the prompt admitted to the created session under B, and the second round repeats the same clean sequence.Tested on
Environment (optional)
Playwright e2e against the vite dev server with the in-browser mock daemon; unit tests via vitest. The flow was also exercised against a real built daemon (
node dist/cli.js serve) with two registered workspaces.Risk & Scope
Linked Issues
N/A
中文说明
这个 PR 做了什么
在不广播 client identity 特性的 daemon 上,Web Shell 会在受控的会话/工作区目标身份变化时 remount 会话 provider。当首条 prompt 懒创建新会话时,shell 会把该会话报告给宿主,而宿主的 prop 回传恰好发生在同一个会话的 attach 链路还在进行中的时候——目标身份随之变化,provider 在 admission 进行到一半时被 remount。这次 remount 会 detach 刚创建的会话、静默丢弃首条 prompt,并把 UI 弹回之前会话所在的 workspace。本 PR 记录已挂载 provider 实际服务的会话,当宿主只是"追上"该会话时保持 provider 不动;真正的目标切换仍按原逻辑 remount。改动只作用于 legacy 路径——支持 client identity 的 daemon 保持现有单 provider 行为不变。
为什么需要
注册了多个 workspace 时,如果 workspace A 的会话处于连接状态(例如正在运行任务),点击 workspace B 的"新建任务"并提交首条 prompt,界面会呈现出 workspace A 的状态:刚创建的会话被拆掉、输入的消息消失、页面弹回 A,而第二次点击的行为又不一样。Legacy daemon 虽然老旧但仍受兼容支持,这个问题让它们上面的跨 workspace 新建会话不可靠。
Reviewer 测试计划
如何验证
回归由针对 mock daemon 的新 Playwright spec 和针对 provider key 行为的单测共同覆盖:
cd packages/web-shell && npx playwright test client/e2e/web-shell.multi-workspace-new-session.spec.ts --project=chromium—— 12 个测试,覆盖 legacy 与 transactional daemon、busy/idle 会话、SSE 流量、turn 完成时机与 daemon 延迟。不带本修复时,两个 legacy 测试会失败:创建的会话被双重 load/detach、没有任何 prompt 请求发出、标题栏弹回 workspace A;带上修复后 12 个全部通过。cd packages/web-shell && npx vitest run client/components/WorkspaceSessionProvider.test.tsx—— 包含"宿主 catch-up 不触发 remount"和"上一个 provider 实例的陈旧观察不能抑制真正的 remount"两个测试,二者均做过变异验证(移除修复即失败)。Reviewer 也可以在 legacy daemon(无 client_identity)上手工确认:在 workspace A 的会话里打开 standalone Web Shell,点击侧边栏 workspace B 的"新建任务",发送一条 prompt——新会话应建在 B 下并收到该 prompt,而不是弹回 A。
前后对比证据
修复前(legacy mock daemon,第一次点击 B 新建任务的请求轨迹):
POST /session正确指向 B,但随后创建的会话收到两次load和两次detach,POST /session/<new>/prompt从未发出,标题栏退回 workspace A 的会话身份。修复后:单次 create → load → attach 链路,prompt 正常进入 B 下新建的会话,第二轮重复同样干净的序列。测试平台
环境(可选)
Playwright e2e 跑在 vite dev server + 浏览器内 mock daemon 上;单测用 vitest。该流程也在真实构建的 daemon(
node dist/cli.js serve,注册两个 workspace)上跑过。风险与范围
关联 Issue
N/A