Kaynağa Gözat

test(web): pin the parallel todo plan in the assembled application

The `+N` active count rides ToolRow's non-shrinking `summarySuffix` slot,
but only jsdom package suites covered it: the ACP snapshots render no web
surface and the built-boot smoke asserts no todo row. Add
`apps/web/tests/todo-row.snapshot.ts`, which boots the built client bundles
against the keyless fixture transport and records `summary`, `suffix`, and
the plan strip's header as separate fields, so folding the count back into
the summary string changes the expected output.

The three assembled-jsdom files now share `apps/web/tests/assembled-boot.ts`
instead of each carrying its own copy of the boot entry list, bundle map,
jsdom stubs, and mount call.

Also: name the policy branch in each `allowParallelInProgress` test title so
no case asserting `true` sits under a `false` describe, reword the stale cap
comment in todo-panel.spec.tsx, and record the plan strip's real header
format in the Agent Note (per-status counts, not `<done>/<total> tasks`).
Chinesezjc 1 ay önce
ebeveyn
işleme
0cf7420e52

+ 2 - 2
.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md
-2026-07-26-todo-parallel-in-progress.md: 310763977862cf7b170a8901d636ce824fca304d
-2026-07-26-todo-parallel-in-progress.zh.md: e00c8357cac1dbc9bb82388b3e25304f96e80b3b
+2026-07-26-todo-parallel-in-progress.md: b380ee86154f8436416126725a7bb486f6dc052d
+2026-07-26-todo-parallel-in-progress.zh.md: f007cc5645df211adc8e391f72ad2d90666e9c0f

+ 2 - 2
.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md

@@ -37,7 +37,7 @@ The durable-log invariant deliberately does NOT follow the flag. A log written w
 
 ## The display surfaces are part of the change
 
-Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `0/8 已完成 · <one task>` while seven others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. The panel redesign in [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) has since replaced the collapsed header's named hint with a `<done>/<total> tasks · <n> in progress` count, which reports parallel work correctly and needs no name to truncate; the row is the one site this branch still had to fix.
+Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `0/8 已完成 · <one task>` while seven others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. The panel redesign in [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) has since replaced the collapsed header's named hint with `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted), which reports parallel work correctly and needs no name to truncate; the row is the one site this branch still had to fix.
 
 The row takes `planSummary` in `toolviews/plan-summary.ts`. It names the first active item and counts the rest, so the row reports how many tasks are running instead of implying one. Naming every active item was rejected: the row is a single line, and an unbounded join would overflow it — the count degrades predictably where a list does not. The derivation sits inside the toolviews domain rather than in `contract/`, the inter-domain face: the panel computes its own counts inline and shares nothing with the row, so a contract module would declare a sharing relationship that no longer exists.
 
@@ -47,4 +47,4 @@ The row takes `planSummary` in `toolviews/plan-summary.ts`. It names the first a
 
 ## Consequences
 
-A todo list can now faithfully mirror parallel execution, and every UI renders several active markers at once: the TUI's per-status prefix needed no change, the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands, and the two point-in-time censuses this note previously carried were both stale within days. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered `tool-schemas.<n>.expected.json` files pinning a subagent class, whose schemas the parent scenario does not cover — and `pnpm run test:snapshot:refresh` does it keylessly over the whole corpus. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pins the row summary and the plan strip, and the ACP `todo-write` scenario records a three-todo plan with two active — and each would fail again if its derivation returned to single-active.
+A todo list can now faithfully mirror parallel execution, and every UI renders several active markers at once: the TUI's per-status prefix needed no change, the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands, and the two point-in-time censuses this note previously carried were both stale within days. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered `tool-schemas.<n>.expected.json` files pinning a subagent class, whose schemas the parent scenario does not cover — and `pnpm run test:snapshot:refresh` does it keylessly over the whole corpus. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan. `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pins the row summary and the plan strip over src, the ACP `todo-write` scenario records a three-todo plan with two active, and `apps/web/tests/todo-row.snapshot.ts` pins both surfaces in the assembled application — booted from the built `packages/client/*/lib/client.js` bundles, so it is the one place the keyed registration and the bundled wiring are under test. That last file records `summary`, `suffix`, and the strip's header as separate fields, so folding the `+N` count back into the summary string changes the expected output even though the concatenated text would read the same.

+ 2 - 2
.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md

@@ -37,7 +37,7 @@ Status: implemented
 
 ## 展示面是本次改动的一部分
 
-解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `0/8 已完成 · <一个任务>`,而另外七个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。其后 [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) 的面板重做已把折叠表头的具名提示换成 `<done>/<total> tasks · <n> in progress` 计数,它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。
+解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `0/8 已完成 · <一个任务>`,而另外七个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。其后 [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) 的面板重做已把折叠表头的具名提示换成以 `·` 连接的各状态计数(本地化后形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略),它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。
 
 工具行改用 `toolviews/plan-summary.ts` 中的 `planSummary`。它给出第一个活跃条目,并计数其余活跃项,因此工具行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:工具行是单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。该推导放在 toolviews 域内而非 `contract/`(域间共享面):面板自行内联计算其计数,与工具行不共享任何东西,因此放进 contract 会声明一种已不存在的共享关系。
 
@@ -47,4 +47,4 @@ Status: implemented
 
 ## 后果
 
-现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照 sidecar。此处不记录数量:该集合会随每个新落地的 pin 场景增长,而本 Note 先前记过的两次点时刻计数都在几天内失实。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些 sidecar —— 包括固定 subagent 类工具的编号文件 `tool-schemas.<n>.expected.json`,其 schema 不被父场景覆盖 —— `pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划——`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 固定工具行摘要与计划横条,ACP `todo-write` 场景录制的是三条目、两个活跃的计划——任一推导退回单活跃项,对应的测试都会失败
+现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照 sidecar。此处不记录数量:该集合会随每个新落地的 pin 场景增长,而本 Note 先前记过的两次点时刻计数都在几天内失实。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些 sidecar —— 包括固定 subagent 类工具的编号文件 `tool-schemas.<n>.expected.json`,其 schema 不被父场景覆盖 —— `pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 在 src 上固定工具行摘要与计划横条,ACP `todo-write` 场景录制的是三条目、两个活跃的计划,而 `apps/web/tests/todo-row.snapshot.ts` 在组装后的应用中固定这两个面——它从构建产物 `packages/client/*/lib/client.js` 启动,因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 `summary`、`suffix` 与横条表头记录为独立字段,因此即便拼接后的文本读起来一样,把 `+N` 计数折回摘要字符串也会改变预期输出

+ 126 - 0
apps/web/tests/assembled-boot.ts

@@ -0,0 +1,126 @@
+// Shared scaffolding for the assembled-jsdom snapshots: the real built
+// `packages/client/*/lib/client.js` artifacts booted through AppWebEntry's
+// ModuleLoader path (loadBundle) against the keyless FixtureApiClient
+// transport. Every file that mounts this graph needs the same boot entry list,
+// the same bundle map, the same jsdom globals, and the same mount call, and
+// differs only in what it asserts afterwards, so the scaffolding lives here.
+//
+// Keyless and deterministic: the fixture is the fake server, so nothing here
+// reaches a model or the network.
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { act, cleanup } from '@testing-library/react'
+import { afterEach, beforeEach, vi } from 'vitest'
+import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
+import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
+
+/** Boot entries for the minimal assembled graph, each carrying the workspace directory its bundle is read from. */
+export const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
+  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
+  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
+  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+  {
+    id: '@deepseek-ai/dsh-client-ui-workspace',
+    dir: 'ui-workspace',
+    url: '/plugins/ui-workspace.js',
+    rev: 'fx',
+    inject: [
+      '@deepseek-ai/dsh-client-runtime',
+      '@deepseek-ai/dsh-client-ui-conversation',
+      '@deepseek-ai/dsh-client-ui-sidebar',
+    ],
+  },
+  { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
+]
+
+const bundles = new Map(PLUGINS.map(plugin => [
+  plugin.url,
+  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
+]))
+
+interface FixtureWindow extends Window {
+  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
+  __ModuleLoader__?: unknown
+}
+
+class ResizeObserverStub {
+  observe(): void {}
+  disconnect(): void {}
+  unobserve(): void {}
+}
+
+const win = window as FixtureWindow
+let unmount: (() => void) | undefined
+
+/**
+ * Register the per-test jsdom setup and teardown the assembled boot needs:
+ * English pinned before boot so role/text locators stay deterministic across
+ * localized component migrations (the newEnglishPage e2e convention), the
+ * observers and frame callbacks jsdom lacks, and a full reset of the document,
+ * the boot globals, and the injected plugin styles afterwards.
+ */
+export function installAssembledBootEnv(): void {
+  beforeEach(() => {
+    localStorage.clear()
+    localStorage.setItem('dsh.locale', 'en')
+    document.title = 'DeepSeek Harness'
+    vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+    vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
+      setTimeout(() => { callback(0) }, 0) as unknown as number)
+    vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
+  })
+
+  afterEach(() => {
+    act(() => { unmount?.() })
+    unmount = undefined
+    cleanup()
+    delete win.__DSH_BOOT__
+    delete win.__ModuleLoader__
+    document.body.innerHTML = ''
+    document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
+    document.title = ''
+    history.replaceState(null, '', '/')
+    vi.unstubAllGlobals()
+  })
+}
+
+/**
+ * Mount the assembled application on the fixture transport; the teardown
+ * registered by installAssembledBootEnv disposes it.
+ */
+export function mountAssembledApp(): void {
+  history.replaceState(null, '', '/?fixture')
+  const root = document.createElement('div')
+  root.id = 'root'
+  document.body.appendChild(root)
+  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
+  act(() => {
+    const entry = new AppWebEntry(root, {
+      loadBundle: async (url) => {
+        const code = bundles.get(url)
+        if (code === undefined) throw new Error(`missing built bundle ${url}`)
+        ;(0, eval)(code)
+      },
+    })
+    void entry.run()
+    unmount = () => { entry.dispose() }
+  })
+}
+
+/**
+ * Match a CSS-module class by its logical name.
+ * Module class names carry a per-build hash in one of two schemes —
+ * ui-primitives emits `_<name>_<hash>` (name bounded by underscores),
+ * ui-conversation emits `<hash>_<name>` (name at the end) — and a longer name
+ * containing this one must not match (`line` must not hit `lineNumber`).
+ * @param el - element whose class list is inspected.
+ * @param name - logical (unhashed) module class name.
+ * @returns whether the element carries that module class.
+ */
+export function hasClass(el: Element, name: string): boolean {
+  return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`))
+}

+ 5 - 87
apps/web/tests/built-boot.snapshot.ts

@@ -10,96 +10,14 @@
 // benches over src). This smoke additionally pins the resident interaction
 // fixture's cross-plugin projection because only the built connection/runtime/
 // workspace graph can prove that transport-to-row path end to end.
-import { readFileSync } from 'node:fs'
-import { join } from 'node:path'
-import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
-import { afterEach, beforeEach, expect, it, vi } from 'vitest'
-import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
-import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
+import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { expect, it } from 'vitest'
+import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
 
-const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
-  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  {
-    id: '@deepseek-ai/dsh-client-ui-workspace',
-    dir: 'ui-workspace',
-    url: '/plugins/ui-workspace.js',
-    rev: 'fx',
-    inject: [
-      '@deepseek-ai/dsh-client-runtime',
-      '@deepseek-ai/dsh-client-ui-conversation',
-      '@deepseek-ai/dsh-client-ui-sidebar',
-    ],
-  },
-  { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
-]
-
-const bundles = new Map(PLUGINS.map(plugin => [
-  plugin.url,
-  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
-]))
-
-interface FixtureWindow extends Window {
-  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
-  __ModuleLoader__?: unknown
-}
-
-class ResizeObserverStub {
-  observe(): void {}
-  disconnect(): void {}
-  unobserve(): void {}
-}
-
-const win = window as FixtureWindow
-let unmount: (() => void) | undefined
-
-beforeEach(() => {
-  localStorage.clear()
-  // English pinned before boot: role/text locators stay deterministic across
-  // localized component migrations (the newEnglishPage e2e convention).
-  localStorage.setItem('dsh.locale', 'en')
-  document.title = 'DeepSeek Harness'
-  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
-  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
-    setTimeout(() => { callback(0) }, 0) as unknown as number)
-  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
-})
-
-afterEach(() => {
-  act(() => { unmount?.() })
-  unmount = undefined
-  cleanup()
-  delete win.__DSH_BOOT__
-  delete win.__ModuleLoader__
-  document.body.innerHTML = ''
-  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
-  document.title = ''
-  history.replaceState(null, '', '/')
-  vi.unstubAllGlobals()
-})
+installAssembledBootEnv()
 
 it('boots the built plugin graph and renders a fixture session end to end', async () => {
-  history.replaceState(null, '', '/?fixture')
-  const root = document.createElement('div')
-  root.id = 'root'
-  document.body.appendChild(root)
-  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
-  act(() => {
-    const entry = new AppWebEntry(root, {
-      loadBundle: async (url) => {
-        const code = bundles.get(url)
-        if (code === undefined) throw new Error(`missing built bundle ${url}`)
-        ;(0, eval)(code)
-      },
-    })
-    void entry.run()
-    unmount = () => { entry.dispose() }
-  })
+  mountAssembledApp()
 
   // The sidebar renders from the boot graph: every inject layer activated.
   const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })

+ 7 - 96
apps/web/tests/search-card.snapshot.ts

@@ -14,69 +14,20 @@
 // derivation over the result view, pinned at every render site by the
 // ui-conversation suite; here the fixture turn exercises the assembled card
 // shape and its cap.
-import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { mkdirSync, writeFileSync } from 'node:fs'
 import { dirname, join } from 'node:path'
-import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
-import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
+import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { hasClass, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
 
 const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/search-card/grep-card.expected.txt')
 const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh'
 
-const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
-  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  {
-    id: '@deepseek-ai/dsh-client-ui-workspace',
-    dir: 'ui-workspace',
-    url: '/plugins/ui-workspace.js',
-    rev: 'fx',
-    inject: [
-      '@deepseek-ai/dsh-client-runtime',
-      '@deepseek-ai/dsh-client-ui-conversation',
-      '@deepseek-ai/dsh-client-ui-sidebar',
-    ],
-  },
-  { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
-]
-
-const bundles = new Map(PLUGINS.map(plugin => [
-  plugin.url,
-  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
-]))
-
-interface FixtureWindow extends Window {
-  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
-  __ModuleLoader__?: unknown
-}
-
-class ResizeObserverStub {
-  observe(): void {}
-  disconnect(): void {}
-  unobserve(): void {}
-}
-
-const win = window as FixtureWindow
-let unmount: (() => void) | undefined
+installAssembledBootEnv()
 
 /** Normalize a rendered search card to a stable text shape: the kind, the banner
  *  summary, each file header (path + count), each visible match line, the expand
- *  control label, and the recovery footer. CSS-module class names carry a
- *  per-build hash in one of two schemes — ui-primitives emits `_<name>_<hash>`
- *  (name bounded by underscores), ui-conversation emits `<hash>_<name>` (name at
- *  the end). `hasClass` matches a module class by its logical name under either,
- *  without matching a longer name that contains it (`line` must not hit
- *  `lineNumber`). */
-function hasClass(el: Element, name: string): boolean {
-  return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`))
-}
-
+ *  control label, and the recovery footer. */
 function cardShape(root: Element): string {
   const card = root.querySelector('[data-search]')
   if (card === null) return '<no search card>'
@@ -94,49 +45,9 @@ function cardShape(root: Element): string {
   return lines.join('\n')
 }
 
-beforeEach(() => {
-  localStorage.clear()
-  // English pinned before boot so the sidebar's role/text locators stay
-  // deterministic (the built-boot smoke's convention).
-  localStorage.setItem('dsh.locale', 'en')
-  document.title = 'DeepSeek Harness'
-  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
-  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
-    setTimeout(() => { callback(0) }, 0) as unknown as number)
-  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
-})
-
-afterEach(() => {
-  act(() => { unmount?.() })
-  unmount = undefined
-  cleanup()
-  delete win.__DSH_BOOT__
-  delete win.__ModuleLoader__
-  document.body.innerHTML = ''
-  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
-  document.title = ''
-  history.replaceState(null, '', '/')
-  vi.unstubAllGlobals()
-})
-
 describe('assembled search card', () => {
   it('renders the grep card, its truncation summary, and its capped head/tail slice from the built bundles', async () => {
-    history.replaceState(null, '', '/?fixture')
-    const root = document.createElement('div')
-    root.id = 'root'
-    document.body.appendChild(root)
-    win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
-    act(() => {
-      const entry = new AppWebEntry(root, {
-        loadBundle: async (url) => {
-          const code = bundles.get(url)
-          if (code === undefined) throw new Error(`missing built bundle ${url}`)
-          ;(0, eval)(code)
-        },
-      })
-      void entry.run()
-      unmount = () => { entry.dispose() }
-    })
+    mountAssembledApp()
 
     const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
     fireEvent.click(await within(tree).findByText('Fixture 历史会话'))

+ 9 - 0
apps/web/tests/snapshots/todo-row/parallel-plan.expected.txt

@@ -0,0 +1,9 @@
+row=todo_write
+title=Update to-do list
+summary=1/4 completed · 实现 fixture 样本
+suffix=+1
+panel=1 completed · 2 in progress · 1 pending
+item=completed 梳理需求
+item=in_progress 实现 fixture 样本
+item=in_progress 跑后台构建
+item=pending 浏览器验收

+ 72 - 0
apps/web/tests/todo-row.snapshot.ts

@@ -0,0 +1,72 @@
+// @vitest-environment jsdom
+// Assembled todo snapshot: boots the real built `packages/client/*/lib/
+// client.js` bundles through AppWebEntry's ModuleLoader path against the
+// keyless FixtureApiClient transport, opens the fixture session, and pins the
+// two surfaces the fixture's parallel plan (turn 71, two items `in_progress`)
+// reaches — the `todo_write` tool row and the dock's plan strip.
+//
+// The row is pinned as three separate fields on purpose. `summary=` is the
+// ellipsized text and `suffix=` is ToolRow's non-shrinking `summarySuffix`
+// slot, so a regression that folds the `+N` count back into the summary string
+// changes this file even though the concatenated text would read the same; the
+// jsdom package suites bench over src and cannot see the bundled registration.
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { hasClass, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
+
+const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/todo-row/parallel-plan.expected.txt')
+const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh'
+
+installAssembledBootEnv()
+
+/** Normalize the todo row and the plan strip to a stable text shape: the row's
+ *  title, its truncatable summary, its non-shrinking suffix, then the panel's
+ *  per-status header and every list item with its status. */
+function todoShape(row: Element, panel: Element): string {
+  const pick = (from: Element, name: string): Element[] =>
+    [...from.querySelectorAll('*')].filter(el => hasClass(el, name))
+  const first = (from: Element, name: string): string =>
+    pick(from, name)[0]?.textContent?.trim() ?? '<absent>'
+  const items = [...panel.querySelectorAll('[data-status]')]
+    .map(item => `item=${item.getAttribute('data-status')} ${item.textContent?.trim() ?? ''}`)
+  return [
+    `row=${row.getAttribute('data-tool')}`,
+    `title=${first(row, 'title')}`,
+    `summary=${first(row, 'summary')}`,
+    `suffix=${first(row, 'summarySuffix')}`,
+    `panel=${first(panel, 'progress')}`,
+    ...items,
+  ].join('\n')
+}
+
+describe('assembled todo surfaces', () => {
+  it('renders the parallel plan as a row summary, a separate active count, and the dock plan strip', async () => {
+    mountAssembledApp()
+
+    const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+    fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
+    // The todo turn is the fixture's last, so wait for its keyed row rather
+    // than for chat content in general.
+    const row = await waitFor(() => {
+      const found = document.querySelector('[data-tool="todo_write"]')
+      expect(found).not.toBeNull()
+      return found!
+    }, { timeout: 10_000 })
+    // The panel is the standing plan the turn's `todo/write` event feeds; it
+    // mounts above the composer, outside the row, and starts collapsed — its
+    // list only exists once expanded.
+    const panel = await screen.findByTestId('todo-panel', undefined, { timeout: 10_000 })
+    const toggle = panel.querySelector('button[aria-expanded]')
+    if (toggle === null) throw new Error('the plan strip must expose its expand toggle')
+    if (toggle.getAttribute('aria-expanded') === 'false') fireEvent.click(toggle)
+
+    const shape = todoShape(row, panel)
+    if (refreshing) {
+      mkdirSync(dirname(EXPECTED), { recursive: true })
+      writeFileSync(EXPECTED, shape)
+    }
+    await expect(shape).toMatchFileSnapshot(EXPECTED)
+  })
+})

+ 1 - 1
packages/client/ui-conversation/tests/todo-panel.spec.tsx

@@ -126,7 +126,7 @@ describe('TodoPanel', () => {
   it('marks every parallel active item, and counts them all in the header', () => {
     render(<TodoPanel todos={PARALLEL} t={t} />)
     fireEvent.click(screen.getByRole('button', { expanded: false }))
-    // The cap this branch removes made this list unreachable: three items carry
+    // The old unconditional cap made this list unreachable: three items carry
     // the in-progress glyph at once, and the header counts all three.
     const statuses = screen.getAllByRole('listitem').map(li => li.getAttribute('data-status'))
     expect(statuses.filter(s => s === 'in_progress')).toHaveLength(3)

+ 4 - 4
packages/todo/tool-todo/tests/tool-todo.spec.ts

@@ -140,13 +140,13 @@ describe('dsh-tool-todo', () => {
     expect(agent.session.events.findLast(e => e.type === 'todo/write')!.data.todos).toEqual(todos)
   })
 
-  describe('allowParallelInProgress: false', () => {
+  describe('allowParallelInProgress', () => {
     const parallel = [
       { content: 'run subagent a', status: 'in_progress' },
       { content: 'run subagent b', status: 'in_progress' },
     ]
 
-    it('rejects a call marking several items in_progress', async () => {
+    it('false rejects a call marking several items in_progress', async () => {
       const ctx = await setup(false)
       const agent = agentWithSession('single-active')
       const result = await callTodo(ctx, { todos: parallel }, { agent })
@@ -156,7 +156,7 @@ describe('dsh-tool-todo', () => {
       expect(agent.session.events.some(e => e.type === 'todo/write')).toBe(false)
     })
 
-    it('still accepts one active item', async () => {
+    it('false still accepts one active item', async () => {
       const ctx = await setup(false)
       const todos: TodoItem[] = [
         { content: 'run subagent a', status: 'in_progress' },
@@ -166,7 +166,7 @@ describe('dsh-tool-todo', () => {
       expect(result.isError).toBe(false)
     })
 
-    it('an explicit true accepts a parallel write', async () => {
+    it('true accepts the very list false rejects', async () => {
       const ctx = await setup(true)
       const result = await callTodo(ctx, { todos: parallel })
       expect(result.isError).toBe(false)