Explorar o código

Merge master into browser-use backends

Tianyi Cui hai 4 días
pai
achega
ee031f9cf9
Modificáronse 100 ficheiros con 7569 adicións e 209 borrados
  1. 6 0
      .agents/notes/archived/architecture/2026-09-02-durable-image-offload.i18n.yaml
  2. 56 0
      .agents/notes/archived/architecture/2026-09-02-durable-image-offload.md
  3. 56 0
      .agents/notes/archived/architecture/2026-09-02-durable-image-offload.zh.md
  4. 3 0
      .agents/notes/archived/manifest.json
  5. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  6. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  8. 2 2
      .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml
  9. 1 1
      .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md
  10. 1 1
      .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md
  11. 2 2
      .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml
  12. 1 1
      .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md
  13. 1 1
      .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.zh.md
  14. 2 2
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml
  15. 1 1
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md
  16. 1 1
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md
  17. 2 2
      .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml
  18. 3 3
      .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
  19. 3 3
      .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md
  20. 2 2
      .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml
  21. 1 1
      .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md
  22. 1 1
      .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md
  23. 6 0
      .agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml
  24. 49 0
      .agents/notes/implemented/architecture/2026-09-10-image-offload-events.md
  25. 49 0
      .agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md
  26. 6 0
      .agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.i18n.yaml
  27. 35 0
      .agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.md
  28. 35 0
      .agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.zh.md
  29. 2 2
      .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml
  30. 2 2
      .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md
  31. 2 2
      .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md
  32. 6 0
      .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml
  33. 29 0
      .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md
  34. 29 0
      .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md
  35. 6 0
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
  36. 45 0
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
  37. 45 0
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
  38. 2 2
      .agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml
  39. 5 5
      .agents/notes/implemented/process/2026-07-20-gui-testing-system.md
  40. 5 5
      .agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md
  41. 2 2
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
  42. 2 2
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
  43. 2 2
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
  44. 2 2
      .agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml
  45. 7 5
      .agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md
  46. 7 5
      .agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md
  47. 2 2
      .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml
  48. 12 5
      .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md
  49. 13 5
      .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md
  50. 3 0
      THIRD_PARTY_NOTICES.md
  51. 3 0
      apps/cli/composition.md
  52. 3 0
      apps/web/package.json
  53. 123 41
      apps/web/stress-tests/reasoning-chunks.stress.ts
  54. 2 2
      apps/web/tests/README.i18n.yaml
  55. 1 1
      apps/web/tests/README.md
  56. 1 1
      apps/web/tests/README.zh.md
  57. 33 9
      apps/web/tests/assembled-boot.ts
  58. 44 0
      apps/web/tests/assembled-remote.spec.ts
  59. 399 0
      apps/web/tests/assembled-remote.ts
  60. 3 3
      apps/web/tests/built-boot.expected.e2e.ts
  61. 5 3
      apps/web/tests/clickable-links-gallery.e2e.ts
  62. 1 1
      apps/web/tests/command-image-envelope.expected.e2e.ts
  63. 3 0
      apps/web/tests/details-session-lifecycle.e2e.ts
  64. 1 0
      apps/web/tests/document-preview.e2e.ts
  65. 1 0
      apps/web/tests/expected/sidebar-terminal/limit.expected.md
  66. 1 0
      apps/web/tests/expected/sidebar-terminal/running.expected.md
  67. 3 0
      apps/web/tests/expected/sidebar-terminal/selection.expected.md
  68. 5 0
      apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md
  69. 5859 0
      apps/web/tests/fixtures/assembled-remote.fixture.json
  70. 8 0
      apps/web/tests/fixtures/sidebar-terminal.patch.yml
  71. 15 16
      apps/web/tests/goal-bar.e2e.ts
  72. 3 4
      apps/web/tests/goal-bar.overlay.yml
  73. 3 3
      apps/web/tests/image-display.expected.e2e.ts
  74. 6 1
      apps/web/tests/lifecycle-chrome.e2e.ts
  75. 43 5
      apps/web/tests/markdown-wide-table.e2e.ts
  76. 1 1
      apps/web/tests/max-tokens-notice.expected.e2e.ts
  77. 22 3
      apps/web/tests/navigation-panes.e2e.ts
  78. 44 1
      apps/web/tests/ptc-round.e2e.ts
  79. 1 1
      apps/web/tests/replay-round-trip.e2e.ts
  80. 1 1
      apps/web/tests/search-card.expected.e2e.ts
  81. 19 9
      apps/web/tests/sidebar-right.e2e.ts
  82. 227 0
      apps/web/tests/sidebar-terminal.e2e.ts
  83. 1 1
      apps/web/tests/submission-echo.e2e.ts
  84. 1 1
      apps/web/tests/todo-row.expected.e2e.ts
  85. 1 1
      apps/web/tests/trajectory-image-display.expected.e2e.ts
  86. 2 2
      apps/web/tests/turn-tail-actions.e2e.ts
  87. 7 0
      apps/web/tsconfig.json
  88. 2 2
      docs/architecture.i18n.yaml
  89. 2 2
      docs/architecture.md
  90. 2 2
      docs/architecture.zh.md
  91. 2 2
      docs/capability-seams.i18n.yaml
  92. 4 0
      docs/capability-seams.md
  93. 4 0
      docs/capability-seams.zh.md
  94. 2 2
      docs/config-catalog.i18n.yaml
  95. 42 1
      docs/config-catalog.md
  96. 42 1
      docs/config-catalog.zh.md
  97. 2 2
      docs/event-producer-consumer.i18n.yaml
  98. 3 2
      docs/event-producer-consumer.md
  99. 3 2
      docs/event-producer-consumer.zh.md
  100. 2 2
      docs/module-graph.i18n.yaml

+ 6 - 0
.agents/notes/archived/architecture/2026-09-02-durable-image-offload.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/archived/architecture/2026-09-02-durable-image-offload.md
+2026-09-02-durable-image-offload.md: db03e9f52499c1790b3a0c03853f3aea23315bec
+2026-09-02-durable-image-offload.zh.md: 2b6d0a4aa65f9a39b88888506f7e85b4cfff48cf

+ 56 - 0
.agents/notes/archived/architecture/2026-09-02-durable-image-offload.md

@@ -0,0 +1,56 @@
+# Agent Note: Durable image offload by surface replacement
+
+Status: implemented
+Archived: 2026-09-10
+
+English | [中文](2026-09-02-durable-image-offload.zh.md)
+
+## Problem
+
+Request-size image offload was recomputed from scratch on every request. Each route collected every image occurrence on the derived surface, oldest first, and once the accumulated bytes exceeded its budget it rounded the excess up to a whole removal quantum and replaced that many oldest occurrences with placeholder text, per the [unified image request pipeline](../feature/2026-08-20-unified-image-request-pipeline.md). Nothing remembered where the previous request stopped; the prefix was stable only because the arithmetic over an append-only history repeated itself.
+
+That stability failed wherever the arithmetic inputs moved. The [Files inline fallback](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) rebuilt a request under a 20 MiB inline budget with a 10 MiB quantum, offloading far more images for that one request, and the next request in file mode brought them back. The pi-ai route used a quantum of one byte, so its prefix moved on almost every request. Compaction lowered the total and returned previously offloaded images. A route switch moved every step boundary. Each move changed the model-visible prefix and invalidated the provider cache prefix.
+
+The same recomputation broke the repository invariant that model-visible input is reconstructable from the session log. Which representation was dispatched, the exact derived request-version byte lengths, and the route budgets and quanta were runtime or configuration facts that never entered the log; `request/header` records call config, system prompt, and tools only. Provider usage anchors token totals and cannot recover the image set, and the [route-priced estimate](../../archived/feature/2026-08-24-route-priced-image-request-pressure.md) documented that it did not reproduce the fallback budget. No consumer could pair a logged assistant response with the image set its request carried.
+
+## Decision
+
+The offload of an image occurrence is a durable surface fact recorded the way compaction records its reductions: a `surfaceOp: replace` node.
+
+**Marked copies on the surface.** `ImageBlock` gains `offloaded?: true`. An offloaded occurrence lives in a replacement event of the same type as the node that carried it (`user/message` or `tool/result`) whose content is a copy of the original with those blocks marked; `sourceEventSeqs` points at the replaced node. The session core, its event map, its derivation, and its validation are unchanged. `deriveMessages()` sends the marked block; serialization renders every marked block as `offloadedImageText` with the currently resolved access path and prepares only retained occurrences.
+
+**Only advances.** A replacement never reverts. When a budget grows, a route changes, or compaction lowers the total, the marked copies stay, so the model-visible prefix and the provider cache prefix move only forward.
+
+**Adapters project, never decide.** An image-capable route enforces an `LlmImageRequestBudget` (`representation`, `maxBytes`, `maxImages`, both quanta) over the retained occurrences' exact request-version bytes. When they still exceed the budget, in file mode, under the inline fallback's tighter budget, or under the pi-ai bound, the adapter fails the attempt with `IMAGE_OFFLOAD_REQUIRED` and `LlmFailure.offloadImages` naming how many more oldest occurrences must be offloaded, computed with the shared `requiredImageOffload()`. Nothing plans an offload before dispatch.
+
+**Recovery owned by `dsh-compaction-image-offload`.** Image offload is compaction in another capacity dimension: the provider rejects a request, durable history is reduced, the step retries. The executor is a sibling of `compaction-tool-result-pruner` in the compaction group and listens on the `agent/request-error` waterfall. On `IMAGE_OFFLOAD_REQUIRED` it walks the surface in model request order, marks the first `offloadImages` retained occurrences, and for each node that carried one appends the seam's `compaction/prune` shadow price followed by the marked copy, then returns the `retry` action without spending the provider retry budget or logging `llm/retry`. Assistant nodes carry model output, not input images, and are skipped. When nothing remains to offload it delegates and the failure reaches ordinary recovery. The loop re-runs the step over the replaced surface and logs the fresh `request/header` every surface replacement gets; the agent loop is unchanged.
+
+**Token accounting.** `priceImages` receives the surface's `ImageBlock`s and prices a marked one as its placeholder text; the DeepSeek and replay pricing no longer reproduce any offload arithmetic. The meter needs no new state: the `compaction/prune` event and the replacement reprice the node like a tool-result prune.
+
+**Other consumers.** Compaction reconstructs each selected event through `deriveEventMessage()` and sees the marks. Resume, fork, and replay reproduce the surface from the log. Text-only routes keep their separate whole-history substitution.
+
+## Alternatives considered
+
+**Keep recomputing the offload point per request.** Stable only while the arithmetic inputs held still; the inline fallback, the pi-ai quantum, compaction, and route switches all moved the prefix, and no consumer could reconstruct a historical request's image set.
+
+**Record each request's projection outcome as a log-only event.** Restores reconstructability but not stability: the recorded outcome is not a decision input, so every oscillation still happens and the log merely documents it, with two sources of truth for the offloaded set that can disagree.
+
+**Log the full projected request body.** Everything except the offload decision is already derivable; repeating the history per request grows the log quadratically to record one position.
+
+**A log-only `image/offload` watermark event applied by session derivation.** An earlier revision recorded the offload point as a position (event seq plus block path) and had `Session.deriveMessages()` mark every occurrence at or before it. That gave the session a second mechanism for changing model-visible history next to `surfaceOp: replace`, with its own validation, fold, cache invalidation, and required-on-read event, and placed the logic in the core instead of on the plugin that decides. Issue #3041 asks that a permanent eviction use a surface-changing event rather than a request-projection event; because the offload never reverts, it is one.
+
+**Let each adapter append the replacement.** The adapter owns the budgets but not the session surface; a surface change appended below the loop lets two adapters define the surface differently. Adapters report the count they need instead.
+
+**Plan the offload before dispatch, in the loop or in a plugin.** The loop knows the exact prepared route before deriving each request, so planning there never spends a failed attempt; but it puts a route-specific policy into the one component every profile shares, changes the documented step order, and bypasses the `agent/request-error` waterfall that context-overflow compaction and retry already use for the same repair-and-retry pattern. A pre-step plugin keeps the loop unchanged but cannot see the step's own messages or the first request's route, so the failure path stays necessary, and the plan needs every route to declare its budget on its model info. Handling only the failure costs one attempt per quantum crossing (64 MiB in DeepSeek file mode, 20 MiB on pi-ai) and matches the planned direction: routes will stop checking sizes locally, send every image, and the provider will report what it cannot cache, which is exactly a failure naming an offload point.
+
+**Keep a transient extra offload for the inline fallback and exact-byte overflow.** Would have sent an unlogged projection in exactly the cases the invariant exists for; the failure-and-advance path costs one serialization attempt and keeps every dispatched request derivable from the log.
+
+## Consequences
+
+Offloaded images never return automatically when budgets grow, a larger route is selected, or compaction lowers the total; recovery is the read-only path in the placeholder, which the model uses deliberately. A Files outage or a temporary switch to a small-budget route offloads permanently; both are accepted for the same reason. Each offload copies the carrying node's message into the log; images are references, so the copy is the node's text plus block metadata.
+
+Every dispatched request's image set is determined by the log alone, across file mode, inline fallback, resume, fork, retry, and compaction, and the provider cache prefix no longer oscillates. The execution-world access path embedded in placeholder and handle text is still resolved at serialization time; that gap exists for retained images too and belongs to a separate decision about recording the execution-world mapping. The route-local byte checks are provisional: once the provider reports uncacheable images itself, `requiredImageOffload()` and the route budgets go away while the marked copies and the recovery branch stay as they are.
+
+## Testing
+
+`packages/llm/llm/tests/content.spec.ts` pins arbitrary-depth image traversal, the offload count including the 129-to-64 MiB quantum example, and placeholder projection. `packages/compaction/compaction-image-offload/tests/image-offload.spec.ts` pins the `IMAGE_OFFLOAD_REQUIRED` replace-and-retry path with its `compaction/prune` shadow prices and without a retry event, the untouched original node, request-order counts after an earlier surface replacement, and the exhausted case that delegates downstream. Adapter specs pin placeholder projection, prepared-only-retained reads, and the exact-byte failure with its count; `route-pricing.spec.ts` pins placeholder pricing for a marked replacement. The `inline-image-prompt` TypeScript SDK snapshot replays an authored `IMAGE_OFFLOAD_REQUIRED` attempt through the shipped profile and pins the replacement and the retried request.

+ 56 - 0
.agents/notes/archived/architecture/2026-09-02-durable-image-offload.zh.md

@@ -0,0 +1,56 @@
+# Agent Note: 以表层替换实现持久的图片 offload
+
+Status: implemented
+Archived: 2026-09-10
+
+[English](2026-09-02-durable-image-offload.md) | 中文
+
+## 问题
+
+请求级图片 offload 过去在每次请求时都从头重算。每条路由按最老优先的顺序收集派生表层上的全部图片出现位置,累计字节一旦超过预算,就把超出部分向上取整到整个删除量子,把这么多最老的出现位置替换为占位文本,见[统一图片请求管线](../feature/2026-08-20-unified-image-request-pipeline.zh.md)。没有任何东西记住上一次请求停在哪里,前缀稳定只是因为对 append-only 历史做同样的算术会得到同样的结果。
+
+算术的输入一动,这种稳定就失效。[Files 内联回退](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md)会用 20 MiB 内联预算和 10 MiB 量子重建请求,那一次省略多得多的图片,下一次 file 模式的请求又把它们带回来。pi-ai 路由的量子是一个字节,前缀几乎每次请求都会移动。compaction 降低总量,让先前省略的图片回归。切换路由会移动每个台阶边界。每一次移动都改变模型可见前缀,并让 provider 的缓存前缀失效。
+
+同样的重算也破坏了仓库不变量:模型可见输入必须能从 session log 重建。实际发出的表示方式、派生请求版本的精确字节长度、路由预算和量子都是运行时或配置事实,从不进入日志,`request/header` 只记录调用配置、系统提示词和工具。provider usage 只锚定 token 总量,恢复不了图片集合,[按路由定价的估计](../../archived/feature/2026-08-24-route-priced-image-request-pressure.md)也写明它不复现回退预算。没有任何消费方能把一条已记录的助手响应和它的请求携带的图片集合配对。
+
+## 决定
+
+一个图片出现位置的省略是一条持久的表层事实,记录方式和 compaction 记录它的缩减一样:一个 `surfaceOp: replace` 节点。
+
+**表层上带标记的副本。** `ImageBlock` 新增 `offloaded?: true`。被省略的出现位置存在于一条与原承载节点同类型的替换事件里(`user/message` 或 `tool/result`),内容是原消息的副本,只把那些块打上标记;`sourceEventSeqs` 指向被替换的节点。session 核心、它的事件表、派生和校验都不变。`deriveMessages()` 原样发送带标记的块;序列化把每个带标记的块渲染为带当前已解析访问路径的 `offloadedImageText`,只准备保留的出现位置。
+
+**只前进。** 替换永不回退。预算变大、路由切换或 compaction 降低总量时,带标记的副本留在原地,所以模型可见前缀和 provider 缓存前缀只向前移动。
+
+**adapter 只投影,不决定。** 支持图片的路由按保留的出现位置的精确请求版本字节执行一个 `LlmImageRequestBudget`(`representation`、`maxBytes`、`maxImages` 与两个量子)。当它们仍超过预算,无论是 file 模式、内联回退更紧的预算还是 pi-ai 上限,adapter 都以 `IMAGE_OFFLOAD_REQUIRED` 让本次尝试失败,并在 `LlmFailure.offloadImages` 中用共享的 `requiredImageOffload()` 算出还需省略多少最老的出现位置。发送前没有任何规划。
+
+**恢复归 `dsh-compaction-image-offload`。** 图片省略是 compaction 在另一个容量维度上的实例:provider 拒绝请求,持久历史被缩减,step 重试。执行器是 compaction 组里 `compaction-tool-result-pruner` 的兄弟包,监听 `agent/request-error` waterfall。收到 `IMAGE_OFFLOAD_REQUIRED` 时,它按模型请求顺序遍历表层,给前 `offloadImages` 个保留的出现位置打标记,为每个承载了其中任一位置的节点先追加 seam 的 `compaction/prune` 影子价格,再追加带标记的副本,然后返回 `retry` 动作,不占提供方重试预算,也不记录 `llm/retry`。assistant 节点承载的是模型输出而不是输入图片,直接跳过。没有可省略的出现位置时向下游委托,失败进入普通恢复路径。循环在替换后的表层上重跑该 step,并像每次表层替换后一样记录新的 `request/header`;agent loop 不变。
+
+**token 记账。** `priceImages` 接收表层的 `ImageBlock`,把带标记的按占位文本定价;DeepSeek 和 replay 的定价不再复现任何 offload 算术。meter 不需要新状态:`compaction/prune` 事件加替换节点,和工具结果剪枝一样重新为该节点定价。
+
+**其他消费方。** compaction 通过 `deriveEventMessage()` 重建每个选中事件,看得到标记。resume、fork 和重放从日志复现表层。纯文本路由保留各自的全历史替换。
+
+## 考虑过的替代方案
+
+**继续每次请求重算 offload 位置。** 只在算术输入不动时稳定,内联回退、pi-ai 量子、compaction 和路由切换都会移动前缀,且没有消费方能重建历史请求的图片集合。
+
+**用 log-only 事件记录每次请求的投影结果。** 恢复了可重建性但没有稳定性:记录的结果不是决策输入,每一种抖动照旧发生,日志只是把它记下来,且省略集合有两个可能不一致的事实来源。
+
+**记录完整的投影后请求体。** 除 offload 决定外一切都已可派生,为记录一个位置而每次请求重复整段历史会让日志平方级增长。
+
+**由 session 派生应用的 log-only `image/offload` 水位事件。** 更早的一版把 offload 点记成一个位置(事件序号加块路径),由 `Session.deriveMessages()` 给位于它及之前的每个出现位置打标记。这让 session 在 `surfaceOp: replace` 之外多出第二种改变模型可见历史的机制,带着自己的校验、折叠、缓存失效和读取时必须识别的事件,并且把逻辑放进了核心而不是做决定的插件。issue #3041 要求永久淘汰使用改变表层的事件而不是请求投影事件;省略永不回退,它就是永久淘汰。
+
+**让各个 adapter 自己追加替换。** adapter 拥有预算,但不拥有会话表层;在循环之下追加表层变更会让两个 adapter 对表层做出不同定义。adapter 改为上报它需要的数量。
+
+**在发送前规划省略,无论放在循环里还是插件里。** 循环在派生每个请求之前就知道精确的已准备路由,在那里规划永远不会多花一次失败的尝试;但这会把一条路由专属的策略放进所有 profile 共用的那个组件,改变已记录的 step 顺序,还绕过了上下文溢出 compaction 和重试已经在用的同一套 `agent/request-error` waterfall。pre-step 插件不改循环,但看不到 step 自己的消息和第一个请求的路由,失败路径仍然必需,而且规划要求每条路由在模型信息上声明预算。只处理失败的代价是每越过一个量子多一次尝试(DeepSeek file 模式 64 MiB,pi-ai 20 MiB),并且和既定方向一致:路由将不再本地检查大小,全部发送,由 provider 报告无法缓存的部分,那正是一个指明省略点的失败。
+
+**为内联回退和精确字节溢出保留临时的额外省略。** 恰好会在不变量所针对的场景发送未记录的投影;失败再推进的路径只多花一次序列化尝试,且让每个已发出请求都可由日志派生。
+
+## 后果
+
+预算变大、选中更大的路由或 compaction 降低总量时,被省略的图片不会自动回归;恢复手段是占位文本中的只读路径,模型需要时主动使用。一次 Files 故障或临时切到小预算路由会永久省略,两者出于同一理由被接受。每次省略会把承载节点的消息复制一份进日志;图片只是引用,复制的是该节点的文本和块元数据。
+
+每个已发出请求的图片集合都仅由日志决定,覆盖 file 模式、内联回退、resume、fork、重试和 compaction,provider 缓存前缀不再来回变化。占位文本和句柄文本里嵌入的执行世界访问路径仍在序列化时解析;这个缺口对保留的图片同样存在,属于另一个关于记录执行世界映射的决定。路由本地的字节检查是过渡实现:provider 自己报告无法缓存的图片之后,`requiredImageOffload()` 和路由预算会被删掉,带标记的副本和恢复分支保持不变。
+
+## 测试
+
+`packages/llm/llm/tests/content.spec.ts` 钉住任意深度的图片遍历、包括 129 到 64 MiB 量子示例在内的省略计数,以及占位投影。`packages/compaction/compaction-image-offload/tests/image-offload.spec.ts` 钉住 `IMAGE_OFFLOAD_REQUIRED` 替换并重试、带 `compaction/prune` 影子价格且不追加重试事件的路径、原节点保持不变、更早一次表层替换之后的请求顺序计数,以及向下游委托的耗尽情况。adapter 测试钉住占位投影、只读取保留图片以及带数量的精确字节失败;`route-pricing.spec.ts` 钉住带标记替换的占位定价。`inline-image-prompt` TypeScript SDK 快照通过发布的 profile 重放一次手工编写的 `IMAGE_OFFLOAD_REQUIRED` 尝试,钉住替换和重试后的请求。

+ 3 - 0
.agents/notes/archived/manifest.json

@@ -358,6 +358,9 @@
     "architecture/2026-09-01-streamed-tool-call-identity.i18n.yaml": "sha256:5193bf7429a0300f743ea7e6087c3175a7d28c2c468ca1cfd1f77702b68b6399",
     "architecture/2026-09-01-streamed-tool-call-identity.i18n.yaml": "sha256:5193bf7429a0300f743ea7e6087c3175a7d28c2c468ca1cfd1f77702b68b6399",
     "architecture/2026-09-01-streamed-tool-call-identity.md": "sha256:303433b95c22571bf6a801da604b42a4c7231f5f6d07daeb518354f0d7019141",
     "architecture/2026-09-01-streamed-tool-call-identity.md": "sha256:303433b95c22571bf6a801da604b42a4c7231f5f6d07daeb518354f0d7019141",
     "architecture/2026-09-01-streamed-tool-call-identity.zh.md": "sha256:53728d3ee06b29d6023e28024fb3ab6fa4a7473ae5e55368cd19690719d120f3",
     "architecture/2026-09-01-streamed-tool-call-identity.zh.md": "sha256:53728d3ee06b29d6023e28024fb3ab6fa4a7473ae5e55368cd19690719d120f3",
+    "architecture/2026-09-02-durable-image-offload.i18n.yaml": "sha256:d06155597e7df4ca4c15a2902ab664d69cc2fde1e7963e61543d20a49930d2b9",
+    "architecture/2026-09-02-durable-image-offload.md": "sha256:02b7becba3dd73a4e627b85705e8bd5dd55978cb041f4d3a0fd1420a76e4de92",
+    "architecture/2026-09-02-durable-image-offload.zh.md": "sha256:a0464b8272086d663ee698efd0b2edd6224d40770d7a8fd88a6d8bfe030b2a1d",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml": "sha256:ba8a62a9fa3263de4162709cb1aaf81f6ad4feee5011d7a5d8b24eb92ad81d30",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml": "sha256:ba8a62a9fa3263de4162709cb1aaf81f6ad4feee5011d7a5d8b24eb92ad81d30",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.md": "sha256:9db9c47559c17f0e2931b9e646f1d2d17b590df4d5699d4e779ed6d7f65fc371",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.md": "sha256:9db9c47559c17f0e2931b9e646f1d2d17b590df4d5699d4e779ed6d7f65fc371",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md": "sha256:9a266590d49093a097bcdb5d09865757cdd07625cfaaddce1303046d580551f1",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md": "sha256:9a266590d49093a097bcdb5d09865757cdd07625cfaaddce1303046d580551f1",

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
-2026-07-23-client-plugin-loading-model.md: c5576b148c5991dd498d3aed605e3c2e3395774b
-2026-07-23-client-plugin-loading-model.zh.md: c7d6982c2680995bd4698ddbff052a7708f69997
+2026-07-23-client-plugin-loading-model.md: d0f9b20f0adabda6cc7132e5411bcadd60c9e108
+2026-07-23-client-plugin-loading-model.zh.md: 27d5026ed03509d6408a16389246cb1321a37959

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md

@@ -58,12 +58,12 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
 
 
 Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
 Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
 
 
-**Phase one — the module face.** The injected HTML installs `window.__ModuleLoader__` in queue mode, starts preloading every application combo URL, executes every bootstrap combo URL as a blocking classic script, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel calls the facade's `create()` with the raw graph and shell seeds. The facade removes and materializes the modules registration with a bootstrap `require` that rejects every external, then calls its `createClientModuleSystem` export. The modules bundle parses the graph, constructs the system, memoizes its own exports, retains the instance in its module closure, and switches the same facade to live registration. The kernel then prefetches every `immediately` row in parallel. Rows in the same application combo share its execution; separate combos load independently when an immediate row, a requested dependency, or ordinary entry import reaches them. A prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains a registration barrier, not a package identity.
+**Phase one — the module face.** The injected HTML installs `window.__ModuleLoader__` in queue mode, starts preloading every application combo URL, executes every bootstrap combo URL as a blocking classic script, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel calls the facade's `create()` with the raw graph and shell seeds. The facade removes and materializes the modules registration with a bootstrap `require` that rejects every external, then calls its `createClientModuleSystem` export. The modules bundle parses the graph, constructs and returns the system, memoizes its own exports, and switches the same facade to live registration. The kernel then prefetches every `immediately` row in parallel. Rows in the same application combo share its execution; separate combos load independently when an immediate row, a requested dependency, or ordinary entry import reaches them. A prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains a registration barrier, not a package identity.
 
 
 **Phase two — the plugin face.**
 **Phase two — the plugin face.**
 
 
 1. The kernel mounts the vendored Loader and injects the module system as `internal` before any entry exists. Ordering matters: `tree.import`'s bare-import fallback must never run in a browser.
 1. The kernel mounts the vendored Loader and injects the module system as `internal` before any entry exists. Ordering matters: `tree.import`'s bare-import fallback must never run in a browser.
-2. It creates every graph row uniformly. Importing the modules row returns the memoized bootstrap exports, whose `apply()` provides the closed-over system as `ctx.modules`; rows that require that service remain PENDING until then, so the modules row needs no special creation position. Render assembly is an ordinary host-graph row provided by `dsh-client-ui-renderer`; the kernel appends no assembly pseudo-entry.
+2. It creates every graph row uniformly. Importing the modules row returns the memoized bootstrap exports, whose `apply()` reads that tree's `Loader.internal` and provides the same instance as `ctx.modules`; rows that require that service remain PENDING until then, so the modules row needs no special creation position. Render assembly is an ordinary host-graph row provided by `dsh-client-ui-renderer`; the kernel appends no assembly pseudo-entry.
 3. Graph order governs synchronous factory availability; Cordis activation remains independent and proceeds through service waiting.
 3. Graph order governs synchronous factory availability; Cordis activation remains independent and proceeds through service waiting.
 4. `settled` = every entry created + `loader.await()` quiescent + an all-ACTIVE sweep. The sweep lists each import-failed, FAILED, or PENDING fiber with its missing services. It exists because cordis inject waits have no timeout — the sweep is the fail-loud floor.
 4. `settled` = every entry created + `loader.await()` quiescent + an all-ACTIVE sweep. The sweep lists each import-failed, FAILED, or PENDING fiber with its missing services. It exists because cordis inject waits have no timeout — the sweep is the fail-loud floor.
 5. The framework-free loading page projects real fiber states via `internal/status`. After the sweep, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass.
 5. The framework-free loading page projects real fiber states via `internal/status`. After the sweep, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass.

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md

@@ -58,12 +58,12 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
 
 
 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
 
 
-**第一阶段——模块面。**注入的 HTML 以 queue 模式安装 `window.__ModuleLoader__`,开始预加载所有 application combo URL,以阻塞式 classic script 依次执行所有 bootstrap combo URL,赋值 `window.__DSH_BOOT__`,然后启动 Vite 主模块。内核把原始图和外壳 seed 传给 facade 的 `create()`。Facade 移除 modules registration,用拒绝全部 external 的 bootstrap `require` 将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造系统、记忆化自身 exports、在模块闭包中保留该实例,并把同一 facade 切换到 live registration。随后内核并行预取每个 `immediately` row;同一 application combo 中的 row 共享一次执行,不同 combo 会在 immediate row、被请求依赖或普通 entry import 首次触及时独立加载。预取失败在这里被吞下,因为第二阶段 import 会重试并拥有那次大声失败。`immediately` 仍是 registration barrier,不是包身份。
+**第一阶段——模块面。**注入的 HTML 以 queue 模式安装 `window.__ModuleLoader__`,开始预加载所有 application combo URL,以阻塞式 classic script 依次执行所有 bootstrap combo URL,赋值 `window.__DSH_BOOT__`,然后启动 Vite 主模块。内核把原始图和外壳 seed 传给 facade 的 `create()`。Facade 移除 modules registration,用拒绝全部 external 的 bootstrap `require` 将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造并返回系统、记忆化自身 exports,并把同一 facade 切换到 live registration。随后内核并行预取每个 `immediately` row;同一 application combo 中的 row 共享一次执行,不同 combo 会在 immediate row、被请求依赖或普通 entry import 首次触及时独立加载。预取失败在这里被吞下,因为第二阶段 import 会重试并拥有那次大声失败。`immediately` 仍是 registration barrier,不是包身份。
 
 
 **第二阶段——插件面。**
 **第二阶段——插件面。**
 
 
 1. 内核挂载 vendored Loader,在任何 entry 存在之前就把模块系统注入为 `internal`。顺序有讲究:`tree.import` 的裸 import 兜底分支在浏览器里绝不能跑到。
 1. 内核挂载 vendored Loader,在任何 entry 存在之前就把模块系统注入为 `internal`。顺序有讲究:`tree.import` 的裸 import 兜底分支在浏览器里绝不能跑到。
-2. 它统一创建每个 graph row。Import modules row 会返回记忆化的 bootstrap exports,其 `apply()` 把闭包中的系统提供为 `ctx.modules`;需要该 service 的 row 会保持 PENDING 直至此时,因此 modules row 无需特殊创建位置。渲染组装是由 `dsh-client-ui-renderer` 提供的普通 host graph row;内核不追加组装伪 entry。
+2. 它统一创建每个 graph row。Import modules row 会返回记忆化的 bootstrap exports,其 `apply()` 读取该树的 `Loader.internal`,把同一个实例提供为 `ctx.modules`;需要该 service 的 row 会保持 PENDING 直至此时,因此 modules row 无需特殊创建位置。渲染组装是由 `dsh-client-ui-renderer` 提供的普通 host graph row;内核不追加组装伪 entry。
 3. Graph 顺序治理同步 factory 可用性;Cordis 激活与之独立,仍经服务等待推进。
 3. Graph 顺序治理同步 factory 可用性;Cordis 激活与之独立,仍经服务等待推进。
 4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。
 4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。
 5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。检查完成后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。
 5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。检查完成后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md
-2026-07-29-projected-token-usage-and-request-context.md: f96257243bef91ff6a73418231de5e777d8edb2e
-2026-07-29-projected-token-usage-and-request-context.zh.md: 7365d5d816f9f9b324f3e3d3b4db0d3346851bdf
+2026-07-29-projected-token-usage-and-request-context.md: 10056ac8ef85562035d4fc4591e90aec603a7dd8
+2026-07-29-projected-token-usage-and-request-context.zh.md: 0e4cd68791f0ee65dd700b64493be3c33b78a920

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md

@@ -58,4 +58,4 @@ Token totals stay stable across pagination, compaction, replay, restart, and rec
 
 
 Occupancy is approximate in the ways documented above. It is available immediately after restore or reconnect, since both fields are durable, at the cost of describing the last recorded request rather than an exact current boundary.
 Occupancy is approximate in the ways documented above. It is available immediately after restore or reconnect, since both fields are durable, at the cost of describing the last recorded request rather than an exact current boundary.
 
 
-Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. Connection and API Gateway carry no token-specific code, own no per-session metrics cache, and perform no measurement. The browser keeps two generic projection values and no connection-local telemetry; streaming text deltas do not force the stats line to recompute or churn layout-observer subscriptions.
+Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the assembled RemoteMock scenario supplies the same projection values. Connection and API Gateway carry no token-specific code, own no per-session metrics cache, and perform no measurement. The browser keeps two generic projection values and no connection-local telemetry; streaming text deltas do not force the stats line to recompute or churn layout-observer subscriptions.

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md

@@ -58,4 +58,4 @@ token 总量在分页、压缩、回放、重启和重连期间保持稳定,
 
 
 占用率在上文记录的意义上是近似值。由于两个字段都是持久的,它在恢复或重连后立即可用;代价是它描述的是最后一条已记录的请求,而不是精确的当前边界。
 占用率在上文记录的意义上是近似值。由于两个字段都是持久的,它在恢复或重连后立即可用;代价是它描述的是最后一条已记录的请求,而不是精确的当前边界。
 
 
-每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attempt/Turn fold;Web Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。Connection 与 API Gateway 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量不会迫使统计行重新计算或反复替换布局 observer 订阅。
+每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attempt/Turn fold;Web Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而组装 RemoteMock 场景提供相同投影值。Connection 与 API Gateway 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量不会迫使统计行重新计算或反复替换布局 observer 订阅。

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md
-2026-07-31-goal-owned-durable-events.md: 8f7d3b74a83e063fbfc5a530f9e51a2a3eea8197
-2026-07-31-goal-owned-durable-events.zh.md: 8ee0099209c9c98a88549073b33c98ddb4b12bbd
+2026-07-31-goal-owned-durable-events.md: 0d3e632ba275f38e48fdf1f9ee6747f4bc460aab
+2026-07-31-goal-owned-durable-events.zh.md: fd29a6f36eed42f5ba26a46a32acb165d1e42873

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md

@@ -30,4 +30,4 @@ The domain does not automatically project each mutation into model input. Goal t
 
 
 Goal state is independent of inbox placement and admission. Replay has one mutation path, projections advance directly on `goal/change`, and continuation messages carry only round attribution. The model does not receive a mutation-only `<goal_state>` message; model-visible state appears through goal tools and scheduled continuation prompts. Direct session writers remain trusted and can append malformed changes, which the strict fold and invariant companion reject.
 Goal state is independent of inbox placement and admission. Replay has one mutation path, projections advance directly on `goal/change`, and continuation messages carry only round attribution. The model does not receive a mutation-only `<goal_state>` message; model-visible state appears through goal tools and scheduled continuation prompts. Direct session writers remain trusted and can append malformed changes, which the strict fold and invariant companion reject.
 
 
-Focused goal, goal-round-driver, command, TUI, and client-fixture tests pin durable replay, positive-round accounting, inbox independence, projection updates, and restored-session behavior. The keyless process test inspects the persisted `goal/change` event and verifies that creation alone starts no continuation round.
+Focused goal, goal-round-driver, command, TUI, and Client RemoteMock tests pin durable replay, positive-round accounting, inbox independence, projection updates, and restored-session behavior. The keyless process test inspects the persisted `goal/change` event and verifies that creation alone starts no continuation round.

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.zh.md

@@ -30,4 +30,4 @@ Goal 领域需要持久状态,但不需要拥有待处理的模型输入。继
 
 
 Goal 状态不依赖 inbox 放置与准入。回放只有一条变更路径,投影直接由 `goal/change` 推进,继续执行消息只携带 Round 归属。模型不会收到仅用于变更的 `<goal_state>` 消息;模型可见状态来自 goal 工具与已调度的继续执行提示词。直接写入会话的写入方仍受信任,并且可以追加畸形变更;严格折叠与 invariant 配套模块会拒绝这些变更。
 Goal 状态不依赖 inbox 放置与准入。回放只有一条变更路径,投影直接由 `goal/change` 推进,继续执行消息只携带 Round 归属。模型不会收到仅用于变更的 `<goal_state>` 消息;模型可见状态来自 goal 工具与已调度的继续执行提示词。直接写入会话的写入方仍受信任,并且可以追加畸形变更;严格折叠与 invariant 配套模块会拒绝这些变更。
 
 
-聚焦的 goal、goal-round-driver、command、TUI 与 client fixture(测试前置数据)测试固定持久回放、正数 Round 计数、inbox 独立性、投影更新和恢复会话行为。无密钥进程测试检查持久的 `goal/change` 事件,并验证仅创建 goal 不会启动继续执行 Round。
+聚焦的 goal、goal-round-driver、command、TUI 与 Client RemoteMock 测试固定持久回放、正数 Round 计数、inbox 独立性、投影更新和恢复会话行为。无密钥进程测试检查持久的 `goal/change` 事件,并验证仅创建 goal 不会启动继续执行 Round。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md
-2026-08-15-client-shells-and-dynamic-packages.md: 1d67c778b6a06849324dd6095a98d57dc41b94f9
-2026-08-15-client-shells-and-dynamic-packages.zh.md: db1e4e7e7b319c283ae39a94535d88d4dc71d60a
+2026-08-15-client-shells-and-dynamic-packages.md: 89f89784512b86b70ee1b9460850e6f2f951a082
+2026-08-15-client-shells-and-dynamic-packages.zh.md: 5512d9262e979a94a65c25a6c1271b030e23ec70

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md

@@ -51,7 +51,7 @@ The modules Node half injects the startup protocol into the served HTML in this
 4. Assign `window.__DSH_BOOT__`, including all scheduling descriptors and every row's one-resource HMR combo URL.
 4. Assign `window.__DSH_BOOT__`, including all scheduling descriptors and every row's one-resource HMR combo URL.
 5. Execute the Vite main module.
 5. Execute the Vite main module.
 
 
-The bootstrap combo currently registers only the modules factory. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs `ClientModuleSystem`, caches its own exports as the modules row, retains the system in a module closure, and switches the same facade to live mode. The modules client face consequently has a zero-external bootstrap requirement.
+The bootstrap combo currently registers only the modules factory. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs and returns `ClientModuleSystem`, caches its own exports as the modules row, and switches the same facade to live mode. The kernel installs that instance as its Loader's `internal`, and the modules plugin reads it there when it provides `ctx.modules`. The modules client face consequently has a zero-external bootstrap requirement and no module-global system identity.
 
 
 After the `immediately` tier has registered its factories, the kernel creates all Loader entries, awaits Cordis quiescence, and requires every fiber to be ACTIVE. It then calls `ctx.uiRenderer.mount(container)`. The dynamic `ui-renderer` package owns React, slot rendering, hydration of the existing boot DOM, and the React root lifecycle; the startup kernel and failure page remain React-free.
 After the `immediately` tier has registered its factories, the kernel creates all Loader entries, awaits Cordis quiescence, and requires every fiber to be ACTIVE. It then calls `ctx.uiRenderer.mount(container)`. The dynamic `ui-renderer` package owns React, slot rendering, hydration of the existing boot DOM, and the React root lifecycle; the startup kernel and failure page remain React-free.
 
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md

@@ -51,7 +51,7 @@ Modules Node 半按以下顺序向实际返回的 HTML 注入启动协议:
 4. 赋值 `window.__DSH_BOOT__`,其中包含全部调度描述及每个 row 的单资源 HMR combo URL。
 4. 赋值 `window.__DSH_BOOT__`,其中包含全部调度描述及每个 row 的单资源 HMR combo URL。
 5. 执行 Vite 主模块。
 5. 执行 Vite 主模块。
 
 
-Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造 `ClientModuleSystem`、把自身 exports 缓存为 modules row、在模块闭包中保留该系统,并把同一 facade 切换到 live 模式。因此 modules client face 必须满足零 external 的自举要求
+Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造并返回 `ClientModuleSystem`、把自身 exports 缓存为 modules row,并把同一 facade 切换到 live 模式。内核把该实例装成自身 Loader 的 `internal`,modules 插件从这里读取并提供 `ctx.modules`。因此 modules client face 保持零 external 的自举要求,也没有模块级系统身份
 
 
 `immediately` 层级完成 factory 注册后,内核创建全部 Loader entry,等待 Cordis 静止,并要求每个 fiber 都进入 ACTIVE。随后调用 `ctx.uiRenderer.mount(container)`。动态 `ui-renderer` 包拥有 React、slot 渲染、已有启动 DOM 的 hydrate 和 React root 生命周期;启动内核与失败页保持 React-free。
 `immediately` 层级完成 factory 注册后,内核创建全部 Loader entry,等待 Cordis 静止,并要求每个 fiber 都进入 ACTIVE。随后调用 `ctx.uiRenderer.mount(container)`。动态 `ui-renderer` 包拥有 React、slot 渲染、已有启动 DOM 的 hydrate 和 React root 生命周期;启动内核与失败页保持 React-free。
 
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
-2026-08-23-client-derived-tool-presentation.md: 895618099c6669058d23fc0a58335a90d5fbe4ca
-2026-08-23-client-derived-tool-presentation.zh.md: 38572f17c41ea9b68dc8c8c8cdc26a74d9dbb757
+2026-08-23-client-derived-tool-presentation.md: 703960cb24f90eb678fda381f929efb3f94bbd28
+2026-08-23-client-derived-tool-presentation.zh.md: 7f161379553f3aca33c72c2edf93e114d27eb7f9

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md

@@ -387,7 +387,7 @@ This change does not add a general tool-side-effect registry. The ability for a
 
 
 ## Fixtures and Test Data
 ## Fixtures and Test Data
 
 
-The Client fixture deletes its handwritten `presentCall()`, `presentResult()`, `viewFor()`, and fixture tool-view types. It continues producing the same raw calls, result content, and result metadata as a real log.
+The assembled RemoteMock scenario contains no handwritten `presentCall()`, `presentResult()`, `viewFor()`, or tool-view types. It supplies the same raw calls, result content, and result metadata as a real log.
 
 
 | Fixture | Raw facts that must remain |
 | Fixture | Raw facts that must remain |
 |---|---|
 |---|---|
@@ -398,7 +398,7 @@ The Client fixture deletes its handwritten `presentCall()`, `presentResult()`, `
 | web | result metadata sources/answer or url/statusCode/truncated |
 | web | result metadata sources/answer or url/statusCode/truncated |
 | generic/custom | name, argsRaw, content, and error |
 | generic/custom | name, argsRaw, content, and error |
 
 
-The fixture does not import Host tool packages to compute page presentation and retains no presenter mirror. The same raw fixture continues to drive jsdom, built Web snapshots, and the `?fixture` browser path.
+The scenario does not import Host tool packages to compute page presentation and retains no presenter mirror. The same raw scenario drives built Web snapshots under jsdom; real-Host browser cases independently cover the network path.
 
 
 ## Presentation-Equivalence Matrix
 ## Presentation-Equivalence Matrix
 
 
@@ -566,7 +566,7 @@ Changes to this decision use `dsh-pre-push-checks` to select commands for the fi
 - ui-chat and ui-trajectory Tool Definition tests;
 - ui-chat and ui-trajectory Tool Definition tests;
 - ui-tool terminal, diff, read, search, web, row, tree, and details tests;
 - ui-tool terminal, diff, read, search, web, row, tree, and details tests;
 - ui-deliverables produced-file tests;
 - ui-deliverables produced-file tests;
-- connection fixture and Client runtime tests;
+- assembled RemoteMock and Client runtime tests;
 - affected Host and Client TypeScript faces;
 - affected Host and Client TypeScript faces;
 - lint and duplication;
 - lint and duplication;
 - per-file 100% coverage for affected source files;
 - per-file 100% coverage for affected source files;

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md

@@ -387,7 +387,7 @@ Deliverables Definition 按 callId 观察 root `tool/call` 与成功 `tool/resul
 
 
 ## Fixture 与测试数据
 ## Fixture 与测试数据
 
 
-Client fixture 删除手写 `presentCall()`、`presentResult()`、`viewFor()` 与 fixture tool-view 类型。它继续产生与真实日志相同的 raw call、result content 和 result meta。
+组装 RemoteMock 场景不包含手写 `presentCall()`、`presentResult()`、`viewFor()` 或 tool-view 类型。它提供与真实日志相同的 raw call、result content 和 result meta。
 
 
 | Fixture | 必须保留的原始事实 |
 | Fixture | 必须保留的原始事实 |
 |---|---|
 |---|---|
@@ -398,7 +398,7 @@ Client fixture 删除手写 `presentCall()`、`presentResult()`、`viewFor()` 
 | web | result meta 的 sources/answer 或 url/statusCode/truncated |
 | web | result meta 的 sources/answer 或 url/statusCode/truncated |
 | generic/custom | name、argsRaw、content、error |
 | generic/custom | name、argsRaw、content、error |
 
 
-fixture 不导入 Host 工具包来计算页面展示,也不保留 presenter 镜像。同一 raw fixture 继续驱动 jsdom、built Web snapshot 与 `?fixture` 浏览器路径。
+该场景不导入 Host 工具包来计算页面展示,也不保留 presenter 镜像。同一 raw 场景在 jsdom 下驱动 built Web snapshot;真实 Host 浏览器用例独立覆盖网络路径。
 
 
 ## 展示等价矩阵
 ## 展示等价矩阵
 
 
@@ -566,7 +566,7 @@ Host registry 允许不同 scope 为同一 tool name 提供不同定义;Sessio
 - ui-chat 与 ui-trajectory Tool Definition 测试;
 - ui-chat 与 ui-trajectory Tool Definition 测试;
 - ui-tool terminal、diff、read、search、web、row、tree 与 details 测试;
 - ui-tool terminal、diff、read、search、web、row、tree 与 details 测试;
 - ui-deliverables produced-files 测试;
 - ui-deliverables produced-files 测试;
-- connection fixture 与 Client runtime 测试;
+- 组装 RemoteMock 与 Client runtime 测试;
 - 受影响 Host/Client TypeScript face;
 - 受影响 Host/Client TypeScript face;
 - lint 与 duplication;
 - lint 与 duplication;
 - 受影响源文件 per-file 100% coverage;
 - 受影响源文件 per-file 100% coverage;

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md
-2026-09-06-embedded-stream-record-readers.md: 972fee634833cef5fd7b0a54f69780b9370f0cc3
-2026-09-06-embedded-stream-record-readers.zh.md: faba6e179887a9943926f2c73aa8e42a10cee300
+2026-09-06-embedded-stream-record-readers.md: 76e109de577d070093bb7123aa50d9a4ae7fcbe5
+2026-09-06-embedded-stream-record-readers.zh.md: 33f4b31117197c47e7f2ff1637bc13fa2cbf1a8c

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md

@@ -20,7 +20,7 @@ After v2 embedded streams settlement widened with the message content and Chat a
 - Run readers: `runFirstTokenTime` and `runFirstVisibleTime` reconstruct the first qualifying member's time from `time0` and the `dt` gaps and stop scanning there; a name-bearing Tool-call run yields `time0` without reading a fragment.
 - Run readers: `runFirstTokenTime` and `runFirstVisibleTime` reconstruct the first qualifying member's time from `time0` and the `dt` gaps and stop scanning there; a name-bearing Tool-call run yields `time0` without reading a fragment.
 - Stream readers: `assistantStreamFirstTokenTime`, `assistantStreamHasVisibleContent`, `assistantStreamHasVisibleText`, `lastAssistantStreamChunk(stream, type)` (backward scan), `assistantStreamChunks(stream, type)`, `joinAssistantStreamText`, and `assembleAssistantStream`, which feeds a `BlockAssembler` one joined delta per run (assembly only concatenates, so blocks, usage, finish, and replay state equal the per-member result). `RawStreamChunkType` excludes the delta types, so a raw-chunk lookup can never silently skip packed members.
 - Stream readers: `assistantStreamFirstTokenTime`, `assistantStreamHasVisibleContent`, `assistantStreamHasVisibleText`, `lastAssistantStreamChunk(stream, type)` (backward scan), `assistantStreamChunks(stream, type)`, `joinAssistantStreamText`, and `assembleAssistantStream`, which feeds a `BlockAssembler` one joined delta per run (assembly only concatenates, so blocks, usage, finish, and replay state equal the per-member result). `RawStreamChunkType` excludes the delta types, so a raw-chunk lookup can never silently skip packed members.
 
 
-Session Stats reads `assistantStreamFirstTokenTime`; the token meter reads `lastAssistantStreamChunk(stream, 'usage')` and assembles provider output through `assembleAssistantStream`; the subagent output fold appends `joinAssistantStreamText`; the Session Controller scans `assistantStreamChunks(stream, 'block-end')` for images.
+Session Stats, Chat, and Trajectory read `assistantStreamFirstTokenTime` from both `assistant/attempt` and `assistant/message`, retaining the Step's first token across retries. Chat and Trajectory settle content from the assembled message while reading timing independently, so reopening history retains TTFT and decoding metrics without expanding streams. The token meter reads `lastAssistantStreamChunk(stream, 'usage')` and assembles provider output through `assembleAssistantStream`; the subagent output fold appends `joinAssistantStreamText`; the Session Controller scans `assistantStreamChunks(stream, 'block-end')` for images.
 
 
 `expandAssistantStream` keeps its strict validation and its remaining callers, which need every member or validate the stream at a durable boundary: Session restore validation, the v1-to-v2 migration validator and publication Worker replay, the reconnect baseline, and test support.
 `expandAssistantStream` keeps its strict validation and its remaining callers, which need every member or validate the stream at a durable boundary: Session restore validation, the v1-to-v2 migration validator and publication Worker replay, the reconnect baseline, and test support.
 
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md

@@ -20,7 +20,7 @@ Session 格式 v2 将每次模型尝试的紧凑流(`AssistantStreamRecord[]`
 - Run 读取器:`runFirstTokenTime` 与 `runFirstVisibleTime` 从 `time0` 与 `dt` 间隔重建首个合格成员的时间并停止扫描;带名称的 Tool-call run 直接产出 `time0`,不读片段。
 - Run 读取器:`runFirstTokenTime` 与 `runFirstVisibleTime` 从 `time0` 与 `dt` 间隔重建首个合格成员的时间并停止扫描;带名称的 Tool-call run 直接产出 `time0`,不读片段。
 - 流读取器:`assistantStreamFirstTokenTime`、`assistantStreamHasVisibleContent`、`assistantStreamHasVisibleText`、`lastAssistantStreamChunk(stream, type)`(逆向扫描)、`assistantStreamChunks(stream, type)`、`joinAssistantStreamText` 与 `assembleAssistantStream`(每个 run 向 `BlockAssembler` 喂入一个拼接后的 delta;组装只做拼接,因此 blocks、usage、finish 与 replay state 与逐成员结果一致)。`RawStreamChunkType` 排除 delta 类型,因此原始 chunk 查找不可能静默跳过打包成员。
 - 流读取器:`assistantStreamFirstTokenTime`、`assistantStreamHasVisibleContent`、`assistantStreamHasVisibleText`、`lastAssistantStreamChunk(stream, type)`(逆向扫描)、`assistantStreamChunks(stream, type)`、`joinAssistantStreamText` 与 `assembleAssistantStream`(每个 run 向 `BlockAssembler` 喂入一个拼接后的 delta;组装只做拼接,因此 blocks、usage、finish 与 replay state 与逐成员结果一致)。`RawStreamChunkType` 排除 delta 类型,因此原始 chunk 查找不可能静默跳过打包成员。
 
 
-Session Stats 读取 `assistantStreamFirstTokenTime`;token 计量读取 `lastAssistantStreamChunk(stream, 'usage')` 并通过 `assembleAssistantStream` 组装提供商输出;子代理输出折叠追加 `joinAssistantStreamText`;Session Controller 用 `assistantStreamChunks(stream, 'block-end')` 扫描镜像。
+Session Stats、Chat 与 Trajectory 从 `assistant/attempt` 和 `assistant/message` 读取 `assistantStreamFirstTokenTime`,跨重试保留步骤的首个 token。Chat 与 Trajectory 从组装后的消息结算内容,并独立读取计时,因此重新打开历史时无需展开流便能保留 TTFT 与解码指标。token 计量读取 `lastAssistantStreamChunk(stream, 'usage')` 并通过 `assembleAssistantStream` 组装提供商输出;子代理输出折叠追加 `joinAssistantStreamText`;Session Controller 用 `assistantStreamChunks(stream, 'block-end')` 扫描镜像。
 
 
 `expandAssistantStream` 保留其严格校验与其余调用方(需要每个成员或在持久边界校验流):Session 恢复校验、v1-to-v2 迁移校验器与发布 Worker 重放、重连基线、测试支撑。
 `expandAssistantStream` 保留其严格校验与其余调用方(需要每个成员或在持久边界校验流):Session 恢复校验、v1-to-v2 迁移校验器与发布 Worker 重放、重连基线、测试支撑。
 
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/architecture/2026-09-10-image-offload-events.md
+2026-09-10-image-offload-events.md: f540dc98cee8daf3e360b14234a2ac21d0f53aab
+2026-09-10-image-offload-events.zh.md: 868fca59ba5fc1941de752127f61b091aedb234f

+ 49 - 0
.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md

@@ -0,0 +1,49 @@
+# Agent Note: Durable image offload events
+
+Status: implemented
+
+English | [中文](2026-09-10-image-offload-events.zh.md)
+
+## Problem
+
+A request can exceed an image-capable route's byte or image-count budget. Recomputing the omitted prefix for each request lets a route switch, inline fallback, or compaction restore previously omitted images. The model-visible image set then depends on unlogged request preparation. The [unified image pipeline](../feature/2026-08-20-unified-image-request-pipeline.md) owns attachment normalization and request versions; durable omission needs its own recorded decision.
+
+## Decision
+
+`dsh-compaction-image-offload` owns recovery on the `agent/request-error` waterfall. An `IMAGE_OFFLOAD_REQUIRED` failure supplies `offloadImages`; the plugin selects that many oldest retained input-image occurrences in current surface order, appends one `image/offload` event, and returns `retry`. Assistant output images are excluded. If no retained input image remains, the plugin delegates. Other failures do not enter this recovery. Offload does not consume the provider retry budget or emit `llm/retry`.
+
+Summary requests bypass the agent error waterfall. `dsh-compaction-basic` preserves the full `LlmFailure`, checks cancellation and selection stability, and dispatches synchronous `compaction/summary-error` with the selected event seqs. The same image-offload plugin records omissions only within that selection. Synchronous recovery keeps the stability check and decision adjacent. The backend then re-derives input and pricing, including the shrink baseline. Each retry requires additional omitted occurrences; exhaustion delegates the failure. A later summary failure or cancellation preserves any recorded omissions, so command errors do not claim the conversation is unchanged.
+
+The event payload is `{ targets: [{ seq, imageIndexes }] }`. Each target identifies a current `user/message` or `tool/result` node. Its strictly increasing, zero-based indexes enumerate every image in depth-first content order, including nested tool results and previously omitted images. Equal attachment ids remain distinct occurrences. Exact selections remain unambiguous after positional replacements reorder the surface; an attachment id or a raw-log prefix cannot identify the selected set.
+
+The event has no `surfaceOp`. It neither creates a message nor replaces a message node. The plugin's pure projection validates all targets before Session commits the event and rejects missing or shadowed nodes, output images, duplicate targets, invalid indexes, and already omitted occurrences. Reconstruction derives immutable message copies with `ImageBlock.offloaded: true` at the recorded positions. Original events, message ids, sources, and unaffected blocks remain unchanged. Resume, fork, and replay apply the same selections from the log.
+
+The compaction plugin owns selection, event declaration, image validation and projection, and retry policy. [Plugin-owned message projections](2026-09-11-plugin-owned-message-projections.md) owns the generic Session integration and explicit detached assembly, superseding core-owned interpretation. The instance `deriveEventMessage()` and `deriveMessages()` return projected messages; pure reconstructors pass `foldSurface(events, projections).projectedMessages` to the exported `deriveEventMessage()`. Rewriters preserving images must consume projected messages, including tool-result pruning and summarizer input.
+
+`contentGeneration` advances for message replacements and image-offload events so cached history and request series are refreshed. `replaceGeneration` advances only for replacements; image omission cannot masquerade as successful text compaction. An unchanged request envelope gets a `request/header` with reason `series`; a simultaneous envelope change carries `startsSeries: true`.
+
+Adapters prepare only retained images and render marked occurrences as `offloadedImageText` with the current execution-world access path. Route-local budget checks report required counts, including the tighter inline fallback budget, but do not edit history. These checks are provisional: a provider failure that identifies uncacheable images can replace local counting without moving the durable decision into the adapter.
+
+Token accounting applies selections to the affected nodes' image occurrences without replacing their identities or changing prior usage-anchor snapshots. Route pricing substitutes placeholder text for image cost. The fixed structural heuristic excludes the `offloaded` marker, so image omission does not change that heuristic or require a `compaction/prune` shadow price. The context-pressure and context-breakdown checkpoint versions advance to reject cached estimates that counted the marker.
+
+`image/offload` is required on read. Its addition changes the recognized event vocabulary, not the Session envelope or operation grammar, so it does not advance `SESSION_FORMAT_VERSION`. A reader without this event type refuses the log instead of silently restoring omitted images.
+
+## Alternatives considered
+
+**Reuse `compaction/prune` and `surfaceOp: replace`.** The [archived replacement design](../../archived/architecture/2026-09-02-durable-image-offload.md) records full carrying-message copies and couples omission to node replacement and shadow-price accounting. A dedicated event expresses which images are omitted while retaining message identity and keeping retry policy in the same plugin.
+
+**Record a request-local projection outcome.** A record that does not affect subsequent derivation cannot prevent omitted images from returning. Logging whole request bodies repeats message history for a decision that needs only occurrence indexes.
+
+**Use an attachment id or a single watermark.** Attachments can occur repeatedly, and surface order can differ from event-sequence order. Explicit per-node indexes distinguish these cases without redefining a watermark after every replacement.
+
+**Move selection into the loop or adapters.** The request-error extension already owns repair and retry. The adapter knows the route budget; the plugin knows the session input order. Neither requires route-specific policy in the shared loop.
+
+## Consequences
+
+Omission remains effective when a route budget grows, an inline fallback ends, or compaction reduces context. It does not delete attachment bytes. Reading the placeholder's access path creates a new image occurrence that can be retained independently. The execution-world path is still resolved during serialization, as it is for retained image handles; this event records the selected image set, not filesystem mappings.
+
+Session invokes the registered pure projection and maintains one content-generation signal. Consumers reconstructing model input must use projected messages. Human views can still show original images from immutable events. The plugin requires neither token-meter nor compaction services merely to record omission.
+
+## Testing
+
+Plugin projection tests cover nested and repeated occurrences, atomic rejection, cache invalidation, pure folding, restore, fork, and untouched source events. Recovery tests cover consecutive failures, current surface order, exhausted recovery, fresh request headers, and disposal. Token-meter tests preserve node identity and prior usage anchors while repricing only selected occurrences. Tool-result pruning and compaction tests ensure later rewrites consume the projected history. TypeScript and Python SDK expected outputs exercise the required event through shipped profiles.

+ 49 - 0
.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md

@@ -0,0 +1,49 @@
+# Agent Note:持久图片省略事件
+
+Status: implemented
+
+[English](2026-09-10-image-offload-events.md) | 中文
+
+## 问题
+
+请求可能超过图片路由的字节或图片数量预算。每次请求重新计算省略前缀,会让路由切换、内联回退或压缩恢复先前省略的图片。模型可见的图片集合因此取决于未记录的请求准备过程。[统一图片流水线](../feature/2026-08-20-unified-image-request-pipeline.zh.md)负责附件归一化与请求版本,持久省略需要单独记录决策。
+
+## 决策
+
+`dsh-compaction-image-offload` 在 `agent/request-error` waterfall 上负责恢复。`IMAGE_OFFLOAD_REQUIRED` 失败提供 `offloadImages`,插件按当前 surface 顺序选择该数量最旧且仍保留的输入图片出现位置,追加一个 `image/offload` 事件,再返回 `retry`。Assistant 输出图片不参与选择。没有保留的输入图片时,插件委托后续处理。其他失败不进入这项恢复。图片省略不消耗提供方重试预算,也不产生 `llm/retry`。
+
+摘要请求不经过 agent 错误 waterfall。`dsh-compaction-basic` 保留完整的 `LlmFailure`,检查取消和选区稳定性,再携带所选事件序号触发同步的 `compaction/summary-error`。同一个图片省略插件只在选区内记录省略。同步恢复使稳定性检查与决策相邻执行。后端随后重新派生输入并计价,包括判断摘要是否缩短输入的基准。每次重试都必须继续省略图片,无图可省略时委派失败。后续摘要失败或取消仍保留已经记录的省略,因此命令错误不声称会话未变。
+
+事件载荷为 `{ targets: [{ seq, imageIndexes }] }`。每个目标指向当前的 `user/message` 或 `tool/result` 节点。索引从零开始且严格递增,按内容深度优先顺序枚举所有图片,包括嵌套工具结果和先前省略的图片。相同附件 ID 的多次出现分别计数。位置替换改变 surface 顺序后,明确选择仍没有歧义,附件 ID 或原始日志前缀无法标识这个集合。
+
+事件不带 `surfaceOp`,不创建消息,也不替换消息节点。插件的纯投影在 Session 提交事件前校验全部目标,拒绝缺失或已被遮蔽的节点、输出图片、重复目标、无效索引和已被省略的位置。重建过程派生不可变消息副本,将记录的位置标为 `ImageBlock.offloaded: true`。原始事件、消息 ID、来源和未受影响的内容块保持不变。恢复、分叉和回放从日志应用相同的选择。
+
+compaction 插件拥有选图、事件声明、图片校验与投影,以及重试策略。[插件拥有消息投影](2026-09-11-plugin-owned-message-projections.zh.md)负责通用 Session 集成和显式独立装配,取代由核心负责解释的职责划分。实例方法 `deriveEventMessage()` 与 `deriveMessages()` 返回派生消息,纯重建函数将 `foldSurface(events, projections).projectedMessages` 传给导出的 `deriveEventMessage()`。需要保留图片的改写必须读取派生消息,包括工具结果裁剪和摘要输入。
+
+消息替换和图片省略事件都会推进 `contentGeneration`,使历史缓存和请求系列刷新。`replaceGeneration` 只随替换推进,图片省略不能被判断为文本压缩成功。请求配置未变时写入原因是 `series` 的 `request/header`,配置同时变化时携带 `startsSeries: true`。
+
+适配器只准备保留的图片,将带标记的位置渲染为包含当前执行环境访问路径的 `offloadedImageText`。路由本地预算检查报告所需数量,包括内联回退的更低预算,但不编辑历史。这些检查是暂时方案,未来提供方报告无法缓存的图片时,可以替代本地计数,无需把持久决策移入适配器。
+
+Token 计量将选择应用到受影响节点的图片出现位置,不替换节点身份,也不修改已有用量锚点快照。路由计价以占位文本替代图片费用。固定结构启发式不计 `offloaded` 标记,因此图片省略不改变该启发式,也不需要 `compaction/prune` 的遮蔽价格。上下文压力和分类明细检查点版本递增,拒绝曾将该标记计入的缓存估算。
+
+`image/offload` 是读取时必须识别的事件。它增加已知事件类型,不改变 Session 信封或操作语法,因此不推进 `SESSION_FORMAT_VERSION`。不认识该类型的读取器会拒绝日志,避免静默恢复已省略的图片。
+
+## 考虑过的替代方案
+
+**复用 `compaction/prune` 和 `surfaceOp: replace`。** [已归档的替换设计](../../archived/architecture/2026-09-02-durable-image-offload.md)记录携带图片的完整消息副本,把图片省略与节点替换和遮蔽价格计量绑定。专用事件表达被省略的图片,保留消息身份,重试策略仍留在同一个插件中。
+
+**记录单次请求的投影结果。** 不影响后续派生的记录无法阻止省略的图片返回。记录完整请求体会重复消息历史,而决策只需要图片出现位置索引。
+
+**使用附件 ID 或单个分界点。** 附件可能重复出现,surface 顺序也可能与事件序号顺序不同。逐节点记录明确索引能够区分这些情况,无需在每次替换后重新定义分界点。
+
+**把选择移入循环或适配器。** 请求错误扩展点已经负责修复与重试。适配器知道路由预算,插件知道会话输入顺序,两者都不需要在共享循环中加入路由策略。
+
+## 影响
+
+路由预算增加、内联回退结束或压缩减少上下文时,省略仍然有效。省略不删除附件字节。读取占位文本中的访问路径会创建新的图片出现位置,可以独立保留。执行环境路径仍在序列化时解析,与保留图片的标识文本一致。这个事件记录选择的图片集合,不记录文件系统映射。
+
+Session 调用已注册的纯投影,并维护一个内容版本信号。重建模型输入的消费方必须使用派生消息。面向人的视图仍可从不可变事件显示原始图片。插件记录省略时不需要 token-meter 或 compaction 服务。
+
+## 测试
+
+插件的投影测试覆盖嵌套和重复图片、原子拒绝、缓存失效、纯折叠、恢复、分叉及原始事件不变。恢复测试覆盖连续失败、当前 surface 顺序、无图可省略时的恢复、新请求头和释放。Token-meter 测试在仅重计所选位置价格时保留节点身份和已有用量锚点。工具结果裁剪和压缩测试保证后续改写读取派生历史。TypeScript 和 Python SDK 预期输出通过发布的 profile 覆盖必需事件。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/architecture/2026-09-11-plugin-owned-message-projections.md
+2026-09-11-plugin-owned-message-projections.md: 9e1eca92567fc035d48c1c42ad86d371f2542c5b
+2026-09-11-plugin-owned-message-projections.zh.md: b26b5d4549294b37665ac933944b7f51ef7a5717

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.md

@@ -0,0 +1,35 @@
+# Agent Note: Plugin-owned message projections
+
+Status: implemented
+
+English | [中文](2026-09-11-plugin-owned-message-projections.zh.md)
+
+## Problem
+
+The dedicated [image-offload event](2026-09-10-image-offload-events.md) changes derived message content without replacing nodes. Implementing its image traversal and target validation inside Session makes each feature-specific message transformation a core change. The ordinary session-projection registry derives domain state but does not participate in canonical model-history derivation.
+
+## Decision
+
+The event-owning plugin supplies a pure `SessionMessageProjection`: one event type and a synchronous interpreter of the preceding history. It validates the complete durable payload and returns immutable updates for existing messages, preserving their identities. Session owns atomic acceptance, shared live and detached folding, and content-generation cache invalidation. It contains no image selection or image projection implementation.
+
+The `@messageProjection` tag on the owning `SessionEventMap` member generates the required-interpreter inventory. Missing definitions reject append, seeded creation, restore, and pure folding. This inventory does not make unknown third-party event names readable or change `ignorable` compatibility. A projection event cannot also declare a surface operation.
+
+The compaction-image-offload plugin registers its definition through a fiber-owned `ctx.sessions.registerMessageProjection()` effect. Each event has one registered owner. Removing a definition invalidates pending committed decisions and cached reads that used it; a replacement definition requires restoring the session. Rejected, uncommitted candidates retain no interpreter dependency.
+
+Detached readers pass definitions explicitly. The current Session format catalog assembles the same browser-safe plugin exports for persistence validation and offline queries. This static assembly does not mount recovery listeners. Live sessions use the registered composition, while compaction invariants borrow that composition's definitions. No process-global registry or import-time registration is involved.
+
+The image event payload and required-on-read semantics remain owned by the image-offload note. Its indexes, selection policy, retry behavior, and SDK recordings are unchanged; this decision partially supersedes that note's core-owned interpretation.
+
+## Alternatives considered
+
+**Keep image interpretation in Session.** That gives detached callers an implicit built-in, but turns a plugin-owned transformation into core event-specific code. Explicit assembly preserves deterministic replay without assigning the algorithm to core.
+
+**Use the ordinary session-projection registry.** Its folds expose domain state after commit. They cannot veto an invalid append or supply canonical `deriveMessages()` content without another integration point.
+
+**Transform only the outgoing request.** Compaction, fork, offline replay, and request invariants also derive messages. A send-time transformation does not cover these consumers.
+
+## Consequences
+
+Adding a content-changing event requires its declaration, pure interpreter, live registration, and detached assembly entry. Ordinary log-only events need none of this. Consumers of the pure fold use the generic `projectedMessages` result. The event envelope and stored image decisions remain unchanged, so no structural Session format version is added.
+
+Tests cover missing interpreters, generic atomic projection, loaded windows, restore and fork, duplicate registration, fiber disposal, cached and pending reads, and rejected candidates. Image-specific tests remain with their plugin. The catalog checks that every generated required type has a detached interpreter and rejects invalid image references. Existing TypeScript and Python image SDK recordings exercise the same durable event through shipped profiles.

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 插件拥有消息投影
+
+Status: implemented
+
+[English](2026-09-11-plugin-owned-message-projections.md) | 中文
+
+## 问题
+
+独立的[图片省略事件](2026-09-10-image-offload-events.zh.md)修改派生消息内容,不替换节点。如果图片遍历和目标校验在 Session 内实现,每种功能专用的消息转换都需要修改核心代码。普通的 session-projection 注册表派生领域状态,不参与模型历史的标准派生过程。
+
+## 决策
+
+事件所属的插件提供纯 `SessionMessageProjection`,包含一个事件类型和同步解释此前历史的处理器。它校验完整的持久载荷,返回现有消息的不可变更新,并保留消息身份。Session 负责原子接受、实时与独立回放共用的折叠,以及内容版本驱动的缓存失效。它不包含选图或图片投影实现。
+
+所属 `SessionEventMap` 成员上的 `@messageProjection` 标记生成必需处理器清单。缺少处理器时,追加、带历史的创建、恢复和纯折叠都会被拒绝。这份清单不会让未知的第三方事件名变得可读,也不改变 `ignorable` 的兼容规则。投影事件不能同时声明 surface 操作。
+
+compaction-image-offload 插件通过 fiber 拥有的 `ctx.sessions.registerMessageProjection()` effect 注册处理器。每种事件只有一个注册者。移除处理器后,尚未应用的已提交决策和使用过它的缓存读取都会失效。替换处理器需要重新恢复会话。被拒绝且未提交的候选事件不会保留对处理器的依赖。
+
+独立读取器显式传入处理器。当前 Session 格式目录装配相同的浏览器安全插件导出,供持久数据校验和离线查询使用。这项静态装配不挂载恢复监听器。实时会话使用已注册的组合,压缩 invariant 借用该组合的处理器。不使用进程全局注册表,也不在导入模块时注册。
+
+图片事件载荷和读取时必须识别的语义仍由图片省略记录负责。索引、选图策略、重试行为和 SDK 录制保持不变。本决策部分取代该记录中由核心负责解释事件的职责划分。
+
+## 考虑过的替代方案
+
+**保留 Session 内的图片解释逻辑。** 独立调用者可以隐式获得内置处理器,但插件拥有的转换会变成核心中特定事件的代码。显式装配能够保留确定性回放,无需让核心拥有算法。
+
+**使用普通的 session-projection 注册表。** 它的折叠在提交后公开领域状态。没有额外集成点,它无法否决无效追加,也无法提供标准 `deriveMessages()` 内容。
+
+**只转换发出的请求。** 压缩、分叉、离线回放和请求 invariant 也会派生消息。发送时转换无法覆盖这些消费方。
+
+## 影响
+
+增加修改内容的事件,需要事件声明、纯处理器、实时注册和独立装配项。普通的仅记录日志事件不需要这些机制。纯折叠的消费方使用通用的 `projectedMessages` 结果。事件信封和已经存储的图片决策保持不变,因此不增加结构性的 Session 格式版本。
+
+测试覆盖缺少处理器、通用原子投影、已加载窗口、恢复与分叉、重复注册、fiber 释放、缓存与待应用读取,以及被拒绝的候选事件。图片专用测试保留在插件包。目录检查每种生成的必需类型都有独立处理器,并拒绝无效图片引用。现有 TypeScript 和 Python 图片 SDK 录制通过发布的 profile 覆盖相同的持久事件。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md
-2026-08-20-unified-image-request-pipeline.md: 467c8ccf02221414b4355459115df72cc34cf0ba
-2026-08-20-unified-image-request-pipeline.zh.md: 5796f59337d1933b112203673f5c93fd37489cda
+2026-08-20-unified-image-request-pipeline.md: 7431a474b55a9dae82b1c9cd36b770b31fe39602
+2026-08-20-unified-image-request-pipeline.zh.md: 247a57b8017f513c552204d951ef6931f7f9d045

+ 2 - 2
.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md

@@ -26,7 +26,7 @@ Batch admission prepares and verifies every normalized attachment once before pu
 
 
 The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, and alpha without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared.
 The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, and alpha without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared.
 
 
-Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. Each omitted image becomes a per-image placeholder that retains its identity and access resolved for the current tool execution world, including nested tool-result images, while append-only session history keeps the original references.
+Request-size offload follows the [durable image-offload decision](../architecture/2026-09-10-image-offload-events.md). Routes check retained occurrences against their request-version byte and image-count budgets and report `IMAGE_OFFLOAD_REQUIRED` with a removal count. The compaction plugin records exact oldest-first occurrences in `image/offload`; Session derivation applies the marks. Adapters prepare only retained attachments and render each marked occurrence as placeholder text with its identity and current execution-world access path, including nested tool-result images. Original message events retain the attachment references.
 
 
 ### Stable handles
 ### Stable handles
 
 
@@ -62,7 +62,7 @@ Historical attachment objects that later disappear or fail integrity verificatio
 
 
 ## Verification
 ## Verification
 
 
-Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, stop lazy encoding after the first fitting candidate, keep the smallest ladder output above an unreachable byte target, cover square and wide 640,000-pixel projections, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for conservatively offloaded history, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, fall back to bounded all-inline requests after file resolution failure, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry.
+Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, stop lazy encoding after the first fitting candidate, keep the smallest ladder output above an unreachable byte target, cover square and wide 640,000-pixel projections, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for durably offloaded history, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, fall back to bounded all-inline requests after file resolution failure, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry.
 
 
 ## Consequences
 ## Consequences
 
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md

@@ -26,7 +26,7 @@ Status: implemented
 
 
 `variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸和透明通道,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。
 `variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸和透明通道,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。
 
 
-请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。每张省略图片都会变成逐图占位文本,保留自己的身份和本次工具执行环境解析出的访问方式,嵌套工具结果图片也使用相同规则;追加式会话历史继续保留原始引用。
+请求大小 offload 遵循[持久图片省略决策](../architecture/2026-09-10-image-offload-events.zh.md)。路由根据请求版本字节和图片数量预算检查保留的位置,通过 `IMAGE_OFFLOAD_REQUIRED` 报告所需移除数量。Compaction 插件在 `image/offload` 中记录明确的最旧图片出现位置,Session 派生时应用标记。适配器只准备保留的附件,将每个带标记的位置渲染为包含其身份和当前执行环境访问路径的占位文本,嵌套工具结果图片也适用。原始消息事件保留附件引用。
 
 
 ### 稳定句柄
 ### 稳定句柄
 
 
@@ -62,7 +62,7 @@ Status: implemented
 
 
 ## Verification
 ## Verification
 
 
-包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、首个候选合规后停止编码、字节目标不可达时保留最小阶梯产物、正方形和宽屏 640,000 像素投影、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已保守 offload 的历史附件读取、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、文件解析失败后回退到有界全内联请求、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。
+包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、首个候选合规后停止编码、字节目标不可达时保留最小阶梯产物、正方形和宽屏 640,000 像素投影、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已持久省略的历史附件读取、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、文件解析失败后回退到有界全内联请求、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。
 
 
 ## Consequences
 ## Consequences
 
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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-09-09-ptc-trajectory-code-inspection.md
+2026-09-09-ptc-trajectory-code-inspection.md: 477d199f519b5d515e5d58430bd902d9d209e46b
+2026-09-09-ptc-trajectory-code-inspection.zh.md: f305569176214c63ac549b0ec5103289e3e57a88

+ 29 - 0
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md

@@ -0,0 +1,29 @@
+# Agent Note: Inspect recorded PTC source in Trajectory
+
+Status: implemented
+
+English | [中文](2026-09-09-ptc-trajectory-code-inspection.zh.md)
+
+## Problem
+
+PTC programs arrive as JSON string arguments. Escaping makes long programs difficult to read in a generic argument tree, while historical calls may use a different runtime language from the current deployment. Recorded result text can contain both printed output and a returned value without retaining their separation.
+
+## Decision
+
+[Trajectory](../../../../packages/client/ui-trajectory/README.md) identifies calls by their recorded tool name and derives a code inspector from validated `run_code` arguments. The validated program carries the original JSON text; copying preserves source and argument bytes and exposes the original JSON through a separate toggle. Syntax highlighting requires an unambiguous TypeScript or Python hint in that call's recorded parameter description; unknown or conflicting hints leave plain text. Unsupported arguments retain the generic inspector.
+
+The result view preserves recorded text and uses a tree only for complete JSON objects or arrays. Errors retain captured output. Code views sample the shared wrapping preference when opened and keep their own choice while mounted.
+
+The [PTC runtime decision](2026-06-15-ptc.md) still owns execution and settlement; the [client presentation decision](../architecture/2026-08-23-client-derived-tool-presentation.md) still owns deriving UI from recorded facts. This inspector adds no Session events or host presentation fields.
+
+## Alternatives considered
+
+**Keep source inside the argument tree.** JSON escaping obscures program structure and makes copying executable source cumbersome.
+
+**Use the active runtime language or infer it from source.** Either can mislabel a historical program. Recorded schema hints constrain highlighting without changing the recording.
+
+**Split printed output from return values.** Historical rendered text does not establish that distinction; a parser could invent a separation the producer never recorded.
+
+## Consequences
+
+Readers can inspect and copy recorded programs without changing replay data. Schemas with no recognizable language hint receive no syntax highlighting. Component tests cover recorded-name recognition, schema fallback, exact source and argument copying, output states, and independent wrapping. JSON-tree tests cover clipping geometry, missing `ResizeObserver`, clipboard settlement after hover changes or unmount, and value-read counts during hover. Thinking tests cover body arrival, manual disclosure, and switching records; the [PTC browser scenario](../../../../apps/web/tests/ptc-round.e2e.ts) pins the assembled inspector and verifies overflow and the original-JSON round trip.

+ 29 - 0
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 在 Trajectory 中检查已记录的 PTC 源码
+
+Status: implemented
+
+[English](2026-09-09-ptc-trajectory-code-inspection.md) | 中文
+
+## 问题
+
+PTC 程序以 JSON 字符串参数传入。转义使长程序在通用参数树中难以阅读,而历史调用使用的运行时语言可能不同于当前部署。记录的结果文本可以同时包含打印输出与返回值,却未保留二者的分界。
+
+## 决策
+
+[Trajectory](../../../../packages/client/ui-trajectory/README.zh.md) 按已记录的工具名识别调用,并从已验证的 `run_code` 参数派生代码检查器。验证后的程序携带原始 JSON 文本;复制时保留源码与参数字节,并通过独立切换按钮展示原始 JSON。语法高亮要求该调用记录的参数说明包含明确且无冲突的 TypeScript 或 Python 提示;未知或冲突的提示使用纯文本。不支持的参数保留通用检查器。
+
+结果视图保留记录的文本,只对完整 JSON 对象或数组使用树形展示。错误保留已捕获的输出。代码视图在打开时读取共享换行偏好,并在挂载期间保留自身的选择。
+
+[PTC 运行时决策](2026-06-15-ptc.zh.md) 仍负责执行与结算;[客户端展示决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md) 仍负责从已记录事实派生 UI。此检查器不增加 Session 事件或宿主展示字段。
+
+## 考虑过的替代方案
+
+**把源码保留在参数树中。** JSON 转义遮蔽程序结构,也使复制可执行源码变得繁琐。
+
+**使用当前运行时语言,或从源码推断语言。** 两种方式都可能错误标注历史程序。已记录 Schema 的提示约束高亮,无需修改记录。
+
+**拆分打印输出与返回值。** 历史渲染文本无法确定二者的分界;解析器可能凭空添加生产方从未记录的分隔。
+
+## 后果
+
+读者可以检查和复制已记录的程序,无需修改回放数据。Schema 没有可识别的语言提示时不提供语法高亮。组件测试覆盖记录工具名识别、Schema 回退、源码与参数原样复制、输出状态及独立换行。JSON 树测试覆盖裁剪几何、缺少 `ResizeObserver`、悬停切换或卸载后剪贴板写入落定,以及悬停期间读取值的次数。思考测试覆盖正文到达、手动展开折叠及记录切换;[PTC 浏览器场景](../../../../apps/web/tests/ptc-round.e2e.ts) 固定组装后的检查器展示,并验证溢出和原始 JSON 的往返切换。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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-09-09-web-sidebar-terminal.md
+2026-09-09-web-sidebar-terminal.md: ce1162a84d3f96336cbb217dbdfe62d12ac23280
+2026-09-09-web-sidebar-terminal.zh.md: 51e0f374b5fde389323868e56fbd86d3bc5720b3

+ 45 - 0
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md

@@ -0,0 +1,45 @@
+# Agent Note: Web sidebar terminals
+
+Status: implemented
+
+English | [中文](2026-09-09-web-sidebar-terminal.zh.md)
+
+## Problem
+
+Web users need an interactive shell beside a Session to inspect the workspace and run commands. The Agent's persistent terminal tools control prompts and wait for semantic results; a human terminal instead needs raw keyboard input, normal shell configuration and a full screen. Browser rendering and transport can disappear while a command is still running.
+
+## Decision
+
+`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal offers installed shells and waits for Start. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions.
+
+Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup.
+
+Sidebar layout, open-tab mappings, selection and process PIDs are not persisted. When the Session header mounts, the Client queries `terminal.list` and opens retained Host terminals as new tabs. Listing takes the Session ID directly because history can outlive its Agent and terminal owner; an offline Session has no retained terminals to restore. Their `params.terminalId` association exists only in the current page. New and recovered views use different `createWhenMissing` values: only a new view may allocate a process; a recovered target that disappears reports an error. Host state supplies recovery identities and titles, so the browser does not maintain a second active-terminal registry.
+
+The caller retains a terminal ID in memory before creating it. Repeating create with the same Session and open ID does not allocate a second process. Closing an uncertain create uses that ID even when no creation response arrived. The Host records closed IDs before awaiting allocation, preventing a delayed create from reviving a closed terminal. Each attachment begins with a consistent, bounded serialized xterm screen; ordered output follows through the Gateway's existing multiplexed Remote stream. Output and screen snapshots share one operation queue. Followers retain final output on normal closure and fail explicitly on buffer overflow. Browser render acknowledgement prevents React batching from dropping increments.
+
+The latest attachment controls input and dimensions; other attachments remain read-only. Every physical stream opening gets a fresh input attachment identity, including automatic transport recovery. Input RPCs are serialized, and results from a superseded attachment cannot downgrade a replacement connection. Terminal output creates no model input, Agent tool result or Session event. The existing [persistent Agent terminal decision](2026-07-16-persistent-pty-sessions.md) continues to govern model-owned sessions; this feature extends the [portable subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.md) with terminal environment facts and resize. Control-transfer and process-exit refusals only disable input, preserving the healthy output view. Client-owned error identifiers are translated by the terminal UI, including guidance to close retained exited terminals when the quota is full.
+
+## Alternatives considered
+
+**Persist complete shell profiles in the browser.** Only the selected path is a user preference. Arguments and availability belong to Host discovery; persisting them would allow stale profiles to bypass current execution configuration. The sidebar continues to own the terminal list and tab controls.
+
+**Keep the tab visible until process cleanup finishes.** A slow or failed termination would delay the user's close action. Saving the cleanup intent allows immediate removal while preserving failure reporting and retry.
+
+**Persist sidebar layout or an active tab-to-process registry.** Layout persistence is outside this feature. An additional active registry duplicates Host state and can restore stale associations. Only an unfinished close is an independent user request that must survive page reload.
+
+**Share the Agent terminal registry.** Its controlled prompts and semantic send/wait behavior would change human shell configuration and blur process ownership. User terminals share the subprocess capability instead.
+
+**Add a dedicated terminal WebSocket.** The existing Remote stream transport already owns authentication, cancellation and reconnection. A second carrier would duplicate those responsibilities.
+
+**Replay only a bounded raw byte tail.** A tail can begin inside an escape sequence or omit an alternate-screen transition. A serialized terminal screen provides a consistent recovery point with bounded history.
+
+**Kill when a React body or tab signal is disposed.** Unmount, Session switching and plugin reload can end those lifetimes without an explicit close. Cleanup must follow the user's close operation.
+
+**Build a Web completion engine.** Native shell completion already handles commands, paths and configured plugins through ordinary PTY input. An independent completion UI adds shell-specific parsing and synchronization; it is outside this feature.
+
+## Consequences
+
+A kept-open terminal retains a process and bounded screen memory. Reload restores Host-retained terminals rather than the previous sidebar layout; Host restart does not restore processes. An exited shell remains visible without automatic respawn. Background cleanup may outlive its tab, and unavailable browser storage limits retry recovery to the current page. Native PTY support and descendant cleanup guarantees remain provider-specific. One writable attachment avoids competing resize and input streams, while explicit takeover permits recovery from another page. Changing sandbox mode requires closing retained terminals first.
+
+The implementation retains the Agent-terminal and portable-execution notes because their ownership and provider decisions remain independently useful; neither is superseded by browser terminals.

+ 45 - 0
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md

@@ -0,0 +1,45 @@
+# Agent Note: Web sidebar terminals
+
+Status: implemented
+
+[English](2026-09-09-web-sidebar-terminal.md) | 中文
+
+## 问题
+
+Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命令。Agent 的持久终端工具控制提示符并等待语义结果;人工终端需要原始键盘输入、正常 shell 配置和完整屏幕。命令仍在运行时,浏览器渲染和网络连接可能消失。
+
+## 决定
+
+`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端提供已安装 shell 的选择并等待用户启动。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。
+
+关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。
+
+侧栏布局、打开标签页映射、选中项和进程 PID 不持久化。Session header 挂载时,Client 查询 `terminal.list`,把 Host 保留的终端打开为新标签页。列表直接使用 Session ID,因为历史记录可以比 Agent 和终端 owner 存活更久;离线 Session 没有需要恢复的保留终端。`params.terminalId` 关联只在当前页面中保留。新视图与恢复视图使用不同的 `createWhenMissing`:只有新视图可以分配进程,恢复目标消失时显示错误。恢复标识和标题来自 Host 状态,浏览器不维护第二份活跃终端注册表。
+
+调用者在创建之前把终端 ID 保留在内存中。相同 Session 和未关闭 ID 的重复 create 不再分配进程。创建结果不确定时,关闭仍使用该 ID,即使没有收到创建响应。Host 在等待分配完成前记录已关闭 ID,防止迟到的 create 复活已关闭终端。每次连接先接收一致、有界的 xterm 序列化屏幕,后续有序输出使用 Gateway 已有的复用 Remote stream。输出和屏幕快照共享操作队列。订阅者正常关闭时保留末尾输出,缓存超限时明确失败。浏览器在完成渲染后确认帧,避免 React 批处理丢失增量。
+
+最新连接控制输入和尺寸,其他连接保持只读。每次物理流建立都创建新的输入连接标识,包括传输自动恢复。输入 RPC 按序发送,旧连接的结果不能把新连接降级为失败。终端输出不产生模型输入、Agent 工具结果或 Session 事件。[Agent 持久终端决策](2026-07-16-persistent-pty-sessions.zh.md)仍约束模型拥有的终端;此功能为[可移植 subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)增加执行环境事实和 resize。 控制权转移和进程退出导致的拒绝只禁用输入,保留健康的输出视图。Client 自产错误标识由终端 UI 翻译,包括名额用满时关闭已保留的退出终端的提示。
+
+## 考虑过的替代方案
+
+**在浏览器保存完整 shell profile。** 用户偏好只包含选中的路径。参数和可用性由 Host 探测决定;持久化这些信息会让过期 profile 绕过当前执行配置。终端列表和标签页控件仍由侧栏负责。
+
+**进程清理完成前保留标签页。** 缓慢或失败的终止会拖延用户关闭操作。保存清理意图后,可以立即移除标签页,同时保留错误反馈与重试。
+
+**持久化侧栏布局或活跃标签页到进程的注册表。** 布局持久化不属于此功能。额外的活跃注册表重复 Host 状态,可能恢复过期关联。只有未完成的关闭操作是需要跨页面刷新保留的独立用户请求。
+
+**共用 Agent 终端注册表。** 受控提示符和语义化 send/wait 会改变人工 shell 配置并混淆进程所有权。用户终端只共享 subprocess 能力。
+
+**增加专用终端 WebSocket。** 现有 Remote stream 已管理认证、取消和重连。第二条传输通道会重复这些职责。
+
+**只重放有界原始字节尾部。** 字节尾部可能从转义序列中间开始,或缺失备用屏幕切换。序列化终端屏幕能在保留有界历史的同时提供一致恢复点。
+
+**React 正文或 tab signal 卸载时 kill。** 组件卸载、Session 切换和插件重载都可能结束这些生命周期,而用户并未关闭终端。清理必须跟随显式关闭操作。
+
+**自建 Web 补全引擎。** shell 原生补全通过普通 PTY 输入处理命令、路径和已配置插件。独立补全界面需要针对 shell 解析和同步,不属于此功能。
+
+## 影响
+
+保留终端会保留进程和有界屏幕内存。刷新恢复的是 Host 保留的终端,不是此前的侧栏布局;Host 重启不恢复进程。shell 退出后保持可见,不自动重启。后台清理可能比标签页存活更久,浏览器存储不可用时只能在当前页面保留重试能力。原生 PTY 支持和后代进程清理保证仍由 provider 决定。单一可写连接避免竞争的输入和尺寸流,显式接管允许从另一页面恢复操作。改变 sandbox mode 前需要关闭保留的终端。
+
+Agent 终端和可移植执行环境两篇记录仍保留,其所有权与 provider 决策继续独立有效,不被浏览器终端取代。

+ 2 - 2
.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-20-gui-testing-system.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-20-gui-testing-system.md
-2026-07-20-gui-testing-system.md: fa38b6e57fc0ff48f2604b000f6b9131897f2e4c
-2026-07-20-gui-testing-system.zh.md: 06ff9b81aad0d13abf51b2db093670d5e0ca57fb
+2026-07-20-gui-testing-system.md: 3fdb9a596dd18861ea8b765da129656942778f96
+2026-07-20-gui-testing-system.zh.md: e49d5a2f848e7f4847c7761ee0a487ce6fd3220e

+ 5 - 5
.agents/notes/implemented/process/2026-07-20-gui-testing-system.md

@@ -20,12 +20,12 @@ Cut along the architecture's natural test hooks into three tiers, bottom-up:
 |---|---|---|---|
 |---|---|---|---|
 | 1 Protocol isomorphism | Generated Typert Remote descriptors + `ApiGateway` + the Connection RPC carrier (arguments / results / errors / streams / cancellation) | **The full chain at the isomorphic point**: gateway host/client suites validate descriptor codecs and Remote dispatch in process; Connection host suites exercise the same `/api` carrier framing and trust checks without a browser | `packages/api/gateway/tests/`, `packages/client/connection/tests/` |
 | 1 Protocol isomorphism | Generated Typert Remote descriptors + `ApiGateway` + the Connection RPC carrier (arguments / results / errors / streams / cancellation) | **The full chain at the isomorphic point**: gateway host/client suites validate descriptor codecs and Remote dispatch in process; Connection host suites exercise the same `/api` carrier framing and trust checks without a browser | `packages/api/gateway/tests/`, `packages/client/connection/tests/` |
 | 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/{runtime,connection}/tests/` |
 | 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/{runtime,connection}/tests/` |
-| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane disables the shipped model-adapter row and replays recorded session fixtures through `dsh-llm-replay` in the real in-process web assembly against conversation aria goldens ([web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md), [required CI gate](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` |
+| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot the built client graph under jsdom with a test-owned `RemoteMock`; Playwright cases separately exercise the real browser and Host carriers, with recorded model sessions replayed through `dsh-llm-replay` ([whole-client tier](../testing/2026-09-06-client-assembly-test-line.md), [web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.expected.e2e.ts`, `apps/web/tests/*.e2e.ts`, `apps/web/tests/*.snapshot.ts` |
 
 
 Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/transcript-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2.
 Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/transcript-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2.
 
 
 - **Host and client source** are under the repo-wide per-file 100% coverage gate except the narrow browser-grade exclusions annotated in `vitest.config.ts`; component suites use per-file jsdom pragmas and Testing Library without changing Node suites.
 - **Host and client source** are under the repo-wide per-file 100% coverage gate except the narrow browser-grade exclusions annotated in `vitest.config.ts`; component suites use per-file jsdom pragmas and Testing Library without changing Node suites.
-- **App-owned semantic snapshots** read built client bundles, execute them through the real loader, and drive only deterministic fixture hooks. They own stable visible state such as sidebar labels, breadcrumbs, and `document.title`, not CSS pixels or lower-layer state-machine details.
+- **App-owned semantic snapshots** read built client bundles, execute them through the real loader, and drive deterministic RemoteMock scenarios. They own stable visible state such as sidebar labels, breadcrumbs, and `document.title`, not CSS pixels or lower-layer state-machine details.
 
 
 ## Lane map
 ## Lane map
 
 
@@ -33,7 +33,7 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes
 |---|---|---|---|
 |---|---|---|---|
 | Baseline | `pnpm run test:gui` | Tier 1+2 vitest (`packages/client packages/host`), seconds-fast, no browser, no server | Casually, after touching any GUI source |
 | Baseline | `pnpm run test:gui` | Tier 1+2 vitest (`packages/client packages/host`), seconds-fast, no browser, no server | Casually, after touching any GUI source |
 | Semantic snapshot | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | Keyless assembled-application semantics plus the repo's transport-specific expected outputs | After a human-visible GUI change; before delivery |
 | Semantic snapshot | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | Keyless assembled-application semantics plus the repo's transport-specific expected outputs | After a human-visible GUI change; before delivery |
-| Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs the tier-3 browser set: the two-level smoke (fixture level + real-host level self-skip) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=record`/`refresh` re-record fixtures / rewrite goldens) | After touching the build surface/boot/carriage; before delivery |
+| Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs built-client RemoteMock cases and real-Host browser scenarios, including keyless recorded-session replay (`DSH_SNAPSHOT=record`/`refresh` re-record fixtures / rewrite goldens) | After touching the build surface/boot/carriage; before delivery |
 | Browser expected-output gate | `DSH_SNAPSHOT=replay pnpm run test:web:built` | Reuses CI-built artifacts and compares every committed browser golden without writing | Every Linux pull request |
 | Browser expected-output gate | `DSH_SNAPSHOT=replay pnpm run test:web:built` | Reuses CI-built artifacts and compares every committed browser golden without writing | Every Linux pull request |
 | Gate | `pnpm run test:coverage` | The repo-wide gate (host and client GUI packages included, except annotated browser-grade exclusions) | The PR window |
 | Gate | `pnpm run test:coverage` | The repo-wide gate (host and client GUI packages included, except annotated browser-grade exclusions) | The PR window |
 
 
@@ -42,7 +42,7 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes
 ## Anti-regression discipline
 ## Anti-regression discipline
 
 
 - **Every bug fix pins an assertion**: a browser-visible bug is pinned into its owning browser spec (smoke or e2e scenario); a data-layer bug is pinned into the matching spec (precedent: the res-close misjudgment pinned in the webserver bridge suite — pure Node, reproduces in seconds, no longer needs the 12s browser sentinel as the only defense).
 - **Every bug fix pins an assertion**: a browser-visible bug is pinned into its owning browser spec (smoke or e2e scenario); a data-layer bug is pinned into the matching spec (precedent: the res-close misjudgment pinned in the webserver bridge suite — pure Node, reproduces in seconds, no longer needs the 12s browser sentinel as the only defense).
-- **All-green on fixture is not done, the real wire must pass too**: what the fixture short-circuits is exactly the wire carriage chain (node:http bridge close semantics, real network timing); both empirically confirmed bugs hid there. Changes touching connection/bridge/handler/SSE must run the browser lane (`pnpm run test:web`) — its keyless e2e scenarios drive the real HTTP/SSE carriage, and the with-key real-host smoke remains the live-model complement.
+- **All-green on RemoteMock is not done, the real wire must pass too**: a decoded in-process carrier deliberately omits the HTTP/WebSocket chain and its network timing. Changes touching connection, bridge, handler, or streaming transport must run the browser lane (`pnpm run test:web`), whose keyless e2e scenarios drive the real carrier; the with-key real-Host smoke remains the live-model complement.
 - The code-on-disk-is-the-answer reconciliation workflow: when a behavior change lands and turns existing cases red, reconcile on the spot (fix the test or fix the code, with the RFC/contract as arbiter); no red left hanging.
 - The code-on-disk-is-the-answer reconciliation workflow: when a behavior change lands and turns existing cases red, reconcile on the spot (fix the test or fix the code, with the RFC/contract as arbiter); no red left hanging.
 
 
 ## Consequences
 ## Consequences
@@ -55,6 +55,6 @@ Each lane tests its own tier: touching any GUI source gets seconds-fast `test:gu
 |---|---|
 |---|---|
 | Single e2e (everything through the browser) | Browser startup is seconds × N slower and timing is uncontrollable; wire/object-layer invariants can be fully asserted in milliseconds in node env |
 | Single e2e (everything through the browser) | Browser startup is seconds × N slower and timing is uncontrollable; wire/object-layer invariants can be fully asserted in milliseconds in node env |
 | Migrating the verify scripts to vitest | An ordered script shares one browser session; splitting the cases either formalizes it (sequential + shared page) or re-runs the preamble × N; streaming PASS/FAIL output is exactly the agent's locating interface |
 | Migrating the verify scripts to vitest | An ordered script shares one browser session; splitting the cases either formalizes it (sequential + shared page) or re-runs the preamble × N; streaming PASS/FAIL output is exactly the agent's locating interface |
-| Reusing FixtureApiClient in tests | The demo script runs on a real clock, tests need deferred hand-controlled timing — orthogonal purposes; forced reuse chains the tests to the demo's rhythm |
+| Keeping a production client fixture for tests | It couples shipped code to scenario data and a query-selected transport; RemoteMock and real-Host tests own the two required tiers directly |
 | A standalone vitest config for GUI packages (once designed as vitest.gui.config.ts) | Package-level tests/ are already scanned by the root include; `vitest run packages/client packages/host` path filtering is the tight loop — zero new config |
 | A standalone vitest config for GUI packages (once designed as vitest.gui.config.ts) | Package-level tests/ are already scanned by the root include; `vitest run packages/client packages/host` path filtering is the tight loop — zero new config |
 | Deferring hooks/component-layer unit tests | jsdom remains the coverage mainline because it gives fast per-file component behavior; the required browser replay gate complements it at the assembled tier rather than replacing it ([CI gate decision](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) |
 | Deferring hooks/component-layer unit tests | jsdom remains the coverage mainline because it gives fast per-file component behavior; the required browser replay gate complements it at the assembled tier rather than replacing it ([CI gate decision](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) |

+ 5 - 5
.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md

@@ -20,12 +20,12 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境
 |---|---|---|---|
 |---|---|---|---|
 | 1 协议同构层 | 生成的 Typert Remote 描述符 + `ApiGateway` + Connection RPC 承载(参数/结果/错误/流/取消) | **同构点全链**:gateway host/client 套件在进程内验证描述符 codec 与 Remote 分派;Connection host 套件不经浏览器即可运行相同的 `/api` 承载帧与信任检查 | `packages/api/gateway/tests/`、`packages/client/connection/tests/` |
 | 1 协议同构层 | 生成的 Typert Remote 描述符 + `ApiGateway` + Connection RPC 承载(参数/结果/错误/流/取消) | **同构点全链**:gateway host/client 套件在进程内验证描述符 codec 与 Remote 分派;Connection host 套件不经浏览器即可运行相同的 `/api` 承载帧与信任检查 | `packages/api/gateway/tests/`、`packages/client/connection/tests/` |
 | 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/{runtime,connection}/tests/` |
 | 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/{runtime,connection}/tests/` |
-| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以确定性方式驱动跨插件状态变化;另有最简 Playwright 冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道会禁用交付配置中的模型适配器行,并通过 `dsh-llm-replay` 在真实进程内 web 组装中回放录制的会话 fixture(测试前置数据),与会话区 aria 预期输出比对([web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)、[必需 CI 门禁](../testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md)) | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts`、`apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` |
+| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照在 jsdom 下用测试持有的 `RemoteMock` 启动构建后 Client 图;Playwright 用例分别验证真实浏览器与 Host 载体,并通过 `dsh-llm-replay` 回放已录制的模型会话([整机客户端测试档](../testing/2026-09-06-client-assembly-test-line.zh.md)、[web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)) | `apps/web/tests/*.expected.e2e.ts`、`apps/web/tests/*.e2e.ts`、`apps/web/tests/*.snapshot.ts` |
 
 
 层间纪律:**各层各测各的,上层不重测下层**:应用语义快照只固定组装后插件边界上的用户可见投影,Playwright 冒烟测试负责验证浏览器与承载层是否存活;wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/transcript-adapter)随 2 层同包 tests/ 零假体直测。
 层间纪律:**各层各测各的,上层不重测下层**:应用语义快照只固定组装后插件边界上的用户可见投影,Playwright 冒烟测试负责验证浏览器与承载层是否存活;wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/transcript-adapter)随 2 层同包 tests/ 零假体直测。
 
 
 - **host 与 client 源码**均纳入全仓 per-file 100% 覆盖率门禁,仅排除 `vitest.config.ts` 中带注释的少量浏览器级例外;组件套件通过逐文件 jsdom pragma 和 Testing Library 运行,不会改变 Node 套件。
 - **host 与 client 源码**均纳入全仓 per-file 100% 覆盖率门禁,仅排除 `vitest.config.ts` 中带注释的少量浏览器级例外;组件套件通过逐文件 jsdom pragma 和 Testing Library 运行,不会改变 Node 套件。
-- **归应用所有的语义快照**读取已构建的 client bundle,通过真实 loader 执行它们,并且只驱动确定性的 fixture 钩子。它们负责固定侧边栏标签、面包屑和 `document.title` 等稳定可见状态,而不固定 CSS 像素或下层状态机细节。
+- **归应用所有的语义快照**读取已构建的 client bundle,通过真实 loader 执行它们,并驱动确定性的 RemoteMock 场景。它们负责固定侧边栏标签、面包屑和 `document.title` 等稳定可见状态,而不固定 CSS 像素或下层状态机细节。
 
 
 ## 车道地图
 ## 车道地图
 
 
@@ -33,7 +33,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境
 |---|---|---|---|
 |---|---|---|---|
 | 基础 | `pnpm run test:gui` | 1+2 层 vitest(`packages/client packages/host`),秒级、无浏览器、无 server | 改 GUI 任意源码后随手跑 |
 | 基础 | `pnpm run test:gui` | 1+2 层 vitest(`packages/client packages/host`),秒级、无浏览器、无 server | 改 GUI 任意源码后随手跑 |
 | 语义快照 | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | 无需密钥的组装应用语义,以及仓库按传输形态划分的预期输出 | 用户可见的 GUI 变更后;交付前 |
 | 语义快照 | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | 无需密钥的组装应用语义,以及仓库按传输形态划分的预期输出 | 用户可见的 GUI 变更后;交付前 |
-| 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再跑 3 层浏览器全集:双级冒烟测试(fixture 级 + 真 host 级 self-skip)加上无密钥回放 e2e 场景(`DSH_SNAPSHOT=record`/`refresh` 重录 fixture / 重写期望输出) | 改构建面/boot/承载后;交付前 |
+| 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再运行 built-client RemoteMock 用例与真实 Host 浏览器场景,其中包括无密钥的录制会话回放(`DSH_SNAPSHOT=record`/`refresh` 重录 fixture/重写预期输出) | 改构建面/boot/承载后;交付前 |
 | 浏览器预期输出门禁 | `DSH_SNAPSHOT=replay pnpm run test:web:built` | 复用 CI 构建的产物,并在不写入的情况下比较每份已提交的浏览器预期输出 | 每个 Linux 拉取请求 |
 | 浏览器预期输出门禁 | `DSH_SNAPSHOT=replay pnpm run test:web:built` | 复用 CI 构建的产物,并在不写入的情况下比较每份已提交的浏览器预期输出 | 每个 Linux 拉取请求 |
 | 门禁 | `pnpm run test:coverage` | 全仓门禁(host 与 client GUI 包均纳入,仅排除带注释的浏览器级例外) | PR(Pull Request)窗口 |
 | 门禁 | `pnpm run test:coverage` | 全仓门禁(host 与 client GUI 包均纳入,仅排除带注释的浏览器级例外) | PR(Pull Request)窗口 |
 
 
@@ -42,7 +42,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境
 ## 防回归纪律
 ## 防回归纪律
 
 
 - **修一个 bug 钉一条断言**:浏览器可见的 bug 钉进所属浏览器 spec(冒烟测试或 e2e 场景);数据层 bug 钉进对应 spec(先例:res-close 误判钉在 webserver 桥 suite——纯 Node 秒级复现,不再需要 12s 浏览器哨兵作唯一防线)。
 - **修一个 bug 钉一条断言**:浏览器可见的 bug 钉进所属浏览器 spec(冒烟测试或 e2e 场景);数据层 bug 钉进对应 spec(先例:res-close 误判钉在 webserver 桥 suite——纯 Node 秒级复现,不再需要 12s 浏览器哨兵作唯一防线)。
-- **fixture 全绿不算完,真 wire 也要过**:fixture 短路的恰是 wire 承载链(node:http 桥 close 语义、真网络时序),两次实证 bug 都藏在那里。改动触及连接/桥/handler/SSE 的,浏览器车道(`pnpm run test:web`)必跑——其无密钥 e2e 场景驱动真实 HTTP/SSE 承载,带密钥的真 host 冒烟测试仍是真模型侧的补充。
+- **RemoteMock 全绿不算完,真 wire 也要过**:已解码的进程内载体会刻意绕过 HTTP/WebSocket 链及其网络时序。改动触及 connection、bridge、handler 或流式 transport 时必须运行浏览器车道(`pnpm run test:web`),由其中的无密钥 e2e 场景驱动真实载体;带密钥的真实 Host 冒烟仍是真模型侧的补充。
 - 落盘代码即答案的对表工作流:行为改动落盘打红既有用例时,当场对表校准(改测试还是改代码以 RFC/约定为裁),不留悬红。
 - 落盘代码即答案的对表工作流:行为改动落盘打红既有用例时,当场对表校准(改测试还是改代码以 RFC/约定为裁),不留悬红。
 
 
 ## Consequences
 ## Consequences
@@ -55,6 +55,6 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境
 |---|---|
 |---|---|
 | 单一 e2e(全走浏览器) | 浏览器起步秒级×N 倍慢+时序不可控;wire/对象层不变量在 node env 可毫秒级全断言 |
 | 单一 e2e(全走浏览器) | 浏览器起步秒级×N 倍慢+时序不可控;wire/对象层不变量在 node env 可毫秒级全断言 |
 | verify 脚本迁 vitest | 有序脚本共享浏览器会话,拆 case 要么形式化(sequential+共享 page)要么重走前置×N;PASS/FAIL 流式输出正是 agent(智能体)定位接口 |
 | verify 脚本迁 vitest | 有序脚本共享浏览器会话,拆 case 要么形式化(sequential+共享 page)要么重走前置×N;PASS/FAIL 流式输出正是 agent(智能体)定位接口 |
-| 测试复用 FixtureApiClient | 演示脚本走真实时钟,测试需要 deferred 手控时序——用途正交,硬复用把测试绑死在演示节奏上 |
+| 为测试保留生产 Client fixture | 它把交付代码耦合到场景数据和 query 选择的 transport;RemoteMock 与真实 Host 测试分别直接持有两个所需层级 |
 | GUI 包独立 vitest config(曾设计 vitest.gui.config.ts) | 包级 tests/ 本就被根 include 扫到,`vitest run packages/client packages/host` 路径过滤即窄循环——零新 config |
 | GUI 包独立 vitest config(曾设计 vitest.gui.config.ts) | 包级 tests/ 本就被根 include 扫到,`vitest run packages/client packages/host` 路径过滤即窄循环——零新 config |
 | 钩子/组件层暂缓单测 | jsdom 仍是覆盖率主线,因为它能快速验证逐文件组件行为;必需的浏览器回放门禁在组装层与之互补,而非取代它([CI 门禁决策](../testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md)) |
 | 钩子/组件层暂缓单测 | jsdom 仍是覆盖率主线,因为它能快速验证逐文件组件行为;必需的浏览器回放门禁在组装层与之互补,而非取代它([CI 门禁决策](../testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md)) |

+ 2 - 2
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
-2026-07-24-web-gui-browser-e2e-lane.md: 8276ed1982a7472ba1b825837a933058c32bf840
-2026-07-24-web-gui-browser-e2e-lane.zh.md: 3a7e5ea5547cf5584a2ad3f61b90c91326379de5
+2026-07-24-web-gui-browser-e2e-lane.md: b08d99a7d750bdeabac3dcbb7007e922d32dbdcd
+2026-07-24-web-gui-browser-e2e-lane.zh.md: fbb92a094bbd14042d33e5ebd20d2d3ffd490d43

+ 2 - 2
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md

@@ -6,7 +6,7 @@ English | [中文](2026-07-24-web-gui-browser-e2e-lane.zh.md)
 
 
 ## Problem
 ## Problem
 
 
-The web GUI ships as a real assembled chain — chromium page → client plugin bundles → HTTP unary RPC + two SSE streams → `toFetchHandler`/apiproxy → the host agent loop, tools, and JSONL persistence — and no test exercised that chain keylessly and deterministically. The [GUI testing system](../process/2026-07-20-gui-testing-system.md) covers tier 1 (wire isomorphism in node), tier 2 (object-layer state machines), and tier-3 smokes, but the keyless smoke drives `FixtureApiClient` — no host, no wire, no agent loop — while the full-chain smoke needs `DEEPSEEK_API_KEY` and a live model, so it is nondeterministic and self-skips in keyless CI. The snapshot philosophy of [docs/testing.md](../../../../docs/testing.md) — record once with a key, replay forever keyless, refresh on format churn — already covers the ACP, headless `stream-json`, and TUI transcript surfaces; the web surface was the one assembled product shape without it. The gap is exactly where the two confirmed GUI P0s hid: the wire carriage chain the fixture client short-circuits.
+The web GUI ships as a real assembled chain — chromium page → client plugin bundles → HTTP unary RPC + two SSE streams → `toFetchHandler`/apiproxy → the host agent loop, tools, and JSONL persistence — and no test exercised that chain keylessly and deterministically. The [GUI testing system](../process/2026-07-20-gui-testing-system.md) covers tier 1 (wire isomorphism in node), tier 2 (object-layer state machines), and tier-3 smokes, but the former keyless smoke drove a standalone client fixture — no host, no wire, no agent loop — while the full-chain smoke needed `DEEPSEEK_API_KEY` and a live model, so it was nondeterministic and self-skipped in keyless CI. The snapshot philosophy of [docs/testing.md](../../../../docs/testing.md) — record once with a key, replay forever keyless, refresh on format churn — already covered the ACP, headless `stream-json`, and TUI transcript surfaces; the web surface was the one assembled product shape without it. The gap is exactly where the two confirmed GUI P0s hid: the wire carriage chain the standalone fixture short-circuited.
 
 
 ## Decision
 ## Decision
 
 
@@ -64,7 +64,7 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot
 
 
 **Mock HTTP provider at `DEEPSEEK_BASE_URL`.** Rejected as the lane's mechanism (kept for the one existing workspace-probe smoke): fixtures become hand-authored OpenAI SSE byte scripts, a second fixture format that drifts from the session-log format the rest of the repo records and replays; the adapter's real HTTP path is with-key e2e's job.
 **Mock HTTP provider at `DEEPSEEK_BASE_URL`.** Rejected as the lane's mechanism (kept for the one existing workspace-probe smoke): fixtures become hand-authored OpenAI SSE byte scripts, a second fixture format that drifts from the session-log format the rest of the repo records and replays; the adapter's real HTTP path is with-key e2e's job.
 
 
-**Growing the `?fixture` client.** Rejected: tier separation — `FixtureApiClient` exists to test the client shell without a server; everything below the client API boundary stays untested by construction.
+**Keeping or growing a `?fixture` client.** Rejected: client-only assembly tests inject `RemoteMock`, while behavior that depends on a Host runs through the real scaffold. A production query switch would retain a second application backend and still leave everything below the Client API untested.
 
 
 **Placeholder `DEEPSEEK_API_KEY` + replay interception instead of disabling the adapter row.** Rejected despite zero composition change and two in-tree precedents: it satisfies `llm-deepseek`'s fail-loud key check with a lie and leaves a dead adapter mounted-but-intercepted; the disabled row (the ACP overlay's move) is honest keylessness and fails loud at the earliest resolvable point.
 **Placeholder `DEEPSEEK_API_KEY` + replay interception instead of disabling the adapter row.** Rejected despite zero composition change and two in-tree precedents: it satisfies `llm-deepseek`'s fail-loud key check with a lie and leaves a dead adapter mounted-but-intercepted; the disabled row (the ACP overlay's move) is honest keylessness and fails loud at the earliest resolvable point.
 
 

+ 2 - 2
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md

@@ -6,7 +6,7 @@ Status: implemented
 
 
 ## 问题
 ## 问题
 
 
-Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bundle → HTTP 单次 RPC + 两条 SSE(Server-Sent Events)流 → `toFetchHandler`/apiproxy → host 端的 agent loop(智能体循环)、工具与 JSONL 持久化——却没有任何测试无密钥且确定性地检验这条链。[GUI 测试体系](../process/2026-07-20-gui-testing-system.zh.md)覆盖第 1 层(Node 中的协议同构)、第 2 层(对象层状态机)与第 3 层冒烟测试,但无密钥冒烟驱动的是 `FixtureApiClient`——没有 host、没有 wire、没有 agent loop——而全链路冒烟需要 `DEEPSEEK_API_KEY` 和真实模型,因此不确定、在无密钥 CI 中自行跳过。[docs/testing.md](../../../../docs/testing.zh.md) 的快照哲学——带密钥录制一次、永久无密钥回放、格式变动时刷新——已覆盖 ACP(Agent Client Protocol)、headless `stream-json` 与 TUI 三个 transcript(文本记录)表面;web 表面是唯一没有这层保障的组装形态。而缺口恰恰是两起已实证 GUI P0 藏身之处:fixture(测试前置数据)客户端短路掉的 wire 承载链。
+Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bundle → HTTP 单次 RPC + 两条 SSE(Server-Sent Events)流 → `toFetchHandler`/apiproxy → host 端的 agent loop(智能体循环)、工具与 JSONL 持久化——却没有任何测试无密钥且确定性地检验这条链。[GUI 测试体系](../process/2026-07-20-gui-testing-system.zh.md)覆盖第 1 层(Node 中的协议同构)、第 2 层(对象层状态机)与第 3 层冒烟测试,但原有无密钥冒烟驱动的是独立 Client fixture——没有 host、没有 wire、没有 agent loop——而全链路冒烟需要 `DEEPSEEK_API_KEY` 和真实模型,因此不确定、在无密钥 CI 中自行跳过。[docs/testing.md](../../../../docs/testing.zh.md) 的快照哲学——带密钥录制一次、永久无密钥回放、格式变动时刷新——已覆盖 ACP(Agent Client Protocol)、headless `stream-json` 与 TUI 三个 transcript(文本记录)表面;web 表面是唯一没有这层保障的组装形态。而缺口恰恰是两起已实证 GUI P0 藏身之处:独立 fixture 短路掉的 wire 承载链。
 
 
 ## 决策
 ## 决策
 
 
@@ -64,7 +64,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
 
 
 **`DEEPSEEK_BASE_URL` 处的 mock HTTP 提供方。** 作为本车道机制已否决(仅保留给既有的工作区探针冒烟):fixture 会变成手写的 OpenAI SSE 字节脚本,一种与仓库其余部分录制回放的会话日志格式渐行渐远的第二 fixture 格式;适配器的真实 HTTP 路径归带密钥 e2e 管。
 **`DEEPSEEK_BASE_URL` 处的 mock HTTP 提供方。** 作为本车道机制已否决(仅保留给既有的工作区探针冒烟):fixture 会变成手写的 OpenAI SSE 字节脚本,一种与仓库其余部分录制回放的会话日志格式渐行渐远的第二 fixture 格式;适配器的真实 HTTP 路径归带密钥 e2e 管。
 
 
-**扩展 `?fixture` 客户端。** 已否决:分层纪律——`FixtureApiClient` 的存在意义就是脱离服务器测试客户端 shell;client API 边界以下按构造即失测。
+**保留或扩展 `?fixture` 客户端。** 已否决:仅客户端的组装测试注入 `RemoteMock`,依赖 Host 行为的测试则运行真实 scaffold。生产 query 开关会保留第二套应用后端,而且 Client API 以下仍然按构造失测。
 
 
 **用占位 `DEEPSEEK_API_KEY` + 回放拦截替代禁用适配器行。** 尽管零组合改动且树内有两处先例仍被否决:它用谎言满足 `llm-deepseek` 的快速失败密钥检查,还留下一个挂载却被拦截的死适配器;禁用行(ACP overlay 的同款做法)是诚实的无密钥,并在最早可解析点快速失败。
 **用占位 `DEEPSEEK_API_KEY` + 回放拦截替代禁用适配器行。** 尽管零组合改动且树内有两处先例仍被否决:它用谎言满足 `llm-deepseek` 的快速失败密钥检查,还留下一个挂载却被拦截的死适配器;禁用行(ACP overlay 的同款做法)是诚实的无密钥,并在最早可解析点快速失败。
 
 

+ 2 - 2
.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md
-2026-08-03-opt-in-reasoning-chunk-browser-stress.md: bc5d31d80e5fb8aebcd9988dcb1f051d113cbf63
-2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: 105c8d69c37a18fd18aed3cc9f6f716e38b952d7
+2026-08-03-opt-in-reasoning-chunk-browser-stress.md: 5b679ce2682743531bd3ff48621cbde462a335aa
+2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: 3d730a4505351be968c71092c65a9b9c6b69818c

+ 7 - 5
.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md

@@ -8,7 +8,7 @@ English | [中文](2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md)
 
 
 Long reasoning streams continuously produce large numbers of process-local `assistant/live-chunk` updates before one durable settlement. Each update must remain ordered and be folded into the Assistant Definition to preserve live completeness, while the settlement embeds the exact stream for replay; React, however, needs only the current accumulated result, not every intermediate state within one browser frame.
 Long reasoning streams continuously produce large numbers of process-local `assistant/live-chunk` updates before one durable settlement. Each update must remain ordered and be folded into the Assistant Definition to preserve live completeness, while the settlement embeds the exact stream for replay; React, however, needs only the current accumulated result, not every intermediate state within one browser frame.
 
 
-Each `yield` in an async stream can create a new microtask boundary, so `Notifier.markDirty()` backed only by microtask batching degrades into rebuilding a `ConversationSnapshot`, notifying `useSyncExternalStore`, and running a React render for every chunk. Even with the live Think row collapsed, 100,000 reasoning chunks can overwhelm the main thread with reconciliation, commit, and layout work. The performance boundary must sit between session ingestion and React publication; it cannot hide the problem by slowing the producer or discarding raw events.
+Each `yield` in an async stream can create a new microtask boundary, so `Notifier.markDirty()` backed only by microtask batching degrades into rebuilding a `ConversationSnapshot`, notifying `useSyncExternalStore`, and running a React render for every chunk. Even with the live Think row collapsed, 100,000 reasoning chunks can overwhelm the main thread with reconciliation, commit, and layout work. The performance boundary must sit between session ingestion and React publication; the test must preserve every raw event and distinguish one-batch rendering cost from an unbounded transport backlog.
 
 
 ## Decision
 ## Decision
 
 
@@ -18,9 +18,9 @@ The Session Controller appends every Client-only live chunk to its event source,
 
 
 Keeping the live Think row horizontally pinned to the end of the accumulated text is purely visual alignment and does not require synchronous layout reads on every React commit. An in-component scheduler coalesces consecutive requests into one update every three frames, reads `scrollWidth` and `clientWidth` from the latest DOM, and updates `scrollLeft` directly to the latest position; the fixed visual cadence keeps summary changes readable without allowing browser smooth-scroll animations to accumulate. This throttling applies only to Think's horizontal summary and does not delay Chat body scrolling, history-prepend anchoring, or user-triggered `scrollIntoView`.
 Keeping the live Think row horizontally pinned to the end of the accumulated text is purely visual alignment and does not require synchronous layout reads on every React commit. An in-component scheduler coalesces consecutive requests into one update every three frames, reads `scrollWidth` and `clientWidth` from the latest DOM, and updates `scrollLeft` directly to the latest position; the fixed visual cadence keeps summary changes readable without allowing browser smooth-scroll animations to accumulate. This throttling applies only to Think's horizontal summary and does not delay Chat body scrolling, history-prepend anchoring, or user-triggered `scrollIntoView`.
 
 
-`pnpm run test:web:stress` remains keyless, opt-in browser performance evidence. The deterministic `?fixture` session emits 100,000 `reasoning-delta` events at a cadence independent of painting, and a terminal marker proves that the events cross production session reduction and reach the live Think row; a 50-millisecond heartbeat and a pre-scheduled DOM event measure main-thread stalls and interaction latency, respectively, with a 250-millisecond budget for identifying clear regressions. `DSH_WEB_STRESS_HEADFUL=1` lets developers profile the same scenario in a visible browser with the Performance panel. The stress lane is evidence for manual performance diagnosis and fix acceptance, not a default CI gate or a substitute for deterministic scheduling unit tests.
+`pnpm run test:web:stress` remains keyless, opt-in browser performance evidence. A test-owned model adapter emits 100,000 `reasoning-delta` chunks through the real Host, Gateway, WebSocket, Session reduction, and live Think row. Each 128-chunk batch waits for one browser timer turn before the next batch, so a 50-millisecond heartbeat and a pre-scheduled DOM event measure one bounded ingestion/render interval instead of accumulated socket backlog against a 250-millisecond budget. A terminal marker proves that all chunks reached the UI. `DSH_WEB_STRESS_HEADFUL=1` lets developers profile the same scenario in a visible browser with the Performance panel. The stress lane is evidence for manual performance diagnosis and fix acceptance, not a default CI gate or a substitute for deterministic scheduling unit tests.
 
 
-Focused tests pin `Notifier`'s per-frame coalescing, structural-event preemption, invalidated callbacks, and no-rAF fallback, and prove at the `Session` layer that a frame publishes the latest accumulated text only once and that finalization is not followed by a duplicate notification from a stale frame callback. Small fixture unit tests continue to pin input validation, external arrival pacing, concurrency rejection, exact event count, and terminal-marker delivery without bringing the 100,000-chunk workload into the default test suites.
+Focused tests pin `Notifier`'s per-frame coalescing, structural-event preemption, invalidated callbacks, and no-rAF fallback, and prove at the `Session` layer that a frame publishes the latest accumulated text only once and that finalization is not followed by a duplicate notification from a stale frame callback. The opt-in browser case owns adapter pacing, exact event count, and terminal-marker delivery without bringing the 100,000-chunk workload into the default test suites.
 
 
 ## Alternatives considered
 ## Alternatives considered
 
 
@@ -32,7 +32,9 @@ Focused tests pin `Notifier`'s per-frame coalescing, structural-event preemption
 
 
 **Pacing the test producer by animation frames.** Rejected: the producer would slow whenever rendering slowed, giving the page implicit backpressure absent from a real network stream and masking main-thread starvation.
 **Pacing the test producer by animation frames.** Rejected: the producer would slow whenever rendering slowed, giving the page implicit backpressure absent from a real network stream and masking main-thread starvation.
 
 
-**A live model or recorded HTTP byte stream.** Rejected: live models are nondeterministic, and an HTTP/SSE recording would not improve the target assertion. The in-memory fixture preserves individual asynchronous session events, production client reduction, and the React rendering path while controlling the workload and arrival cadence.
+**Sending the former browser-local rate through the WebSocket without an acknowledgment.** Rejected: queued transport work accumulates faster than a model can produce it and turns the result into a socket-backlog measurement. A browser timer acknowledgment bounds each batch while the heartbeat still detects a batch that starves the page.
+
+**A live external model or recorded HTTP byte stream.** Rejected: an external model is nondeterministic, and an HTTP recording would add a second fixture format without improving the target assertion. The test-owned adapter preserves individual chunks through the real Host and browser transport while controlling the workload.
 
 
 ## Consequences
 ## Consequences
 
 
@@ -40,4 +42,4 @@ The publication rate of streaming `ConversationSnapshot` objects is bounded by t
 
 
 Horizontal layout reads and writes for the collapsed Think summary run at most once every three frames, and each update moves the summary directly to the latest position; React still commits accumulated snapshots normally, and the summary returns to the first line at finalization. This local visual policy does not change the immediacy of body scrolling or user interactions.
 Horizontal layout reads and writes for the collapsed Think summary run at most once every three frames, and each update moves the summary directly to the latest position; React still commits accumulated snapshots normally, and the summary returns to the first line at finalization. This local visual policy does not change the immediacy of body scrolling or user interactions.
 
 
-The browser stress lane continues to provide a responsiveness signal from the real assembled application and an entry point for visible profiling, but hardware and scheduling differences make it suitable only as explicit performance evidence. Deterministic focused tests guard publication counts, accumulated content, and preemption order, while the default test lanes remain fast.
+The browser stress lane continues to provide a responsiveness signal from the real assembled application and carrier plus an entry point for visible profiling, but hardware and scheduling differences make it suitable only as explicit performance evidence. Deterministic focused tests guard publication counts, accumulated content, and preemption order, while the default test lanes remain fast.

+ 7 - 5
.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md

@@ -8,7 +8,7 @@ Status: implemented
 
 
 长 reasoning stream 会在一个持久 settlement 前连续产生大量进程本地 `assistant/live-chunk` update。每个 update 都必须保持有序并折叠进 Assistant Definition,以保留实时完整性;settlement 则嵌入精确 stream 供 replay。React 只需要看到当前累计结果,不需要观察同一浏览器帧内每个中间态。
 长 reasoning stream 会在一个持久 settlement 前连续产生大量进程本地 `assistant/live-chunk` update。每个 update 都必须保持有序并折叠进 Assistant Definition,以保留实时完整性;settlement 则嵌入精确 stream 供 replay。React 只需要看到当前累计结果,不需要观察同一浏览器帧内每个中间态。
 
 
-异步流的每次 `yield` 都可能形成新的微任务边界,因此仅靠微任务合批的 `Notifier.markDirty()` 会退化为每个分片重建一次 `ConversationSnapshot`、通知一次 `useSyncExternalStore` 并运行一次 React render。即使实时 Think 行保持折叠,100,000 个推理分片仍会让协调、提交和布局工作压住主线程。性能边界必须位于会话接收与 React 发布之间,不能通过减慢生产方或丢弃原始事件来掩盖问题
+异步流的每次 `yield` 都可能形成新的微任务边界,因此仅靠微任务合批的 `Notifier.markDirty()` 会退化为每个分片重建一次 `ConversationSnapshot`、通知一次 `useSyncExternalStore` 并运行一次 React render。即使实时 Think 行保持折叠,100,000 个推理分片仍会让协调、提交和布局工作压住主线程。性能边界必须位于会话接收与 React 发布之间;测试必须保留每个原始事件,并区分单批渲染成本与无限累积的 transport backlog
 
 
 ## 决策
 ## 决策
 
 
@@ -18,9 +18,9 @@ Session Controller 把每个 Client-only live chunk 追加到 event source,Con
 
 
 实时 Think 行对累计文本的横向跟尾属于纯视觉对齐,不需要在每次 React 提交中同步读取布局。组件内调度器将连续请求合并为每三帧一次,从最新 DOM 读取 `scrollWidth` 和 `clientWidth` 并将 `scrollLeft` 直接更新到最新位置;固定的视觉节奏让摘要变化可读,又不会积压浏览器平滑滚动动画。该节流只作用于 Think 的横向摘要,不延迟 Chat 正文滚动、历史 prepend 锚定或用户触发的 `scrollIntoView`。
 实时 Think 行对累计文本的横向跟尾属于纯视觉对齐,不需要在每次 React 提交中同步读取布局。组件内调度器将连续请求合并为每三帧一次,从最新 DOM 读取 `scrollWidth` 和 `clientWidth` 并将 `scrollLeft` 直接更新到最新位置;固定的视觉节奏让摘要变化可读,又不会积压浏览器平滑滚动动画。该节流只作用于 Think 的横向摘要,不延迟 Chat 正文滚动、历史 prepend 锚定或用户触发的 `scrollIntoView`。
 
 
-`pnpm run test:web:stress` 保留为无密钥、需显式启用的浏览器性能证据。确定性的 `?fixture` 会话以独立于绘制的节奏发出 100,000 个 `reasoning-delta`,结尾标记证明事件经过生产会话归并并到达实时 Think 行;50 毫秒心跳和预先调度的 DOM 事件分别测量主线程停顿与交互延迟,250 毫秒预算用于识别明显回归。`DSH_WEB_STRESS_HEADFUL=1` 允许开发者在可见浏览器中使用 Performance 面板分析同一场景。该压力车道是手动性能诊断与修复验收的证据,不是默认 CI 门禁,也不替代确定性的调度单元测试。
+`pnpm run test:web:stress` 保留为无密钥、需显式启用的浏览器性能证据。测试持有的模型 adapter 通过真实 Host、Gateway、WebSocket、Session 归并和实时 Think 行发出 100,000 个 `reasoning-delta` chunk。每批 128 个 chunk 会等待一次浏览器 timer 轮次后再发下一批,因此 50 毫秒心跳与预先调度的 DOM 事件按 250 毫秒预算测量一个有界接收/渲染区间,而不是累积的 socket backlog。结尾标记证明所有 chunk 都已到达 UI。`DSH_WEB_STRESS_HEADFUL=1` 允许开发者在可见浏览器中使用 Performance 面板分析同一场景。该压力车道是手动性能诊断与修复验收的证据,不是默认 CI 门禁,也不替代确定性的调度单元测试。
 
 
-聚焦测试固定 `Notifier` 的逐帧合并、结构事件抢占、失效回调和无 rAF 回退,并在 `Session` 层证明一帧只发布一次最新累计文本且定稿不会被旧帧回调重复通知。fixture(测试前置数据)的小型单元测试继续固定输入校验、外部到达节奏、并发拒绝、精确事件数和结尾标记交付,无需把 100,000 分片工作负载带入默认测试套件。
+聚焦测试固定 `Notifier` 的逐帧合并、结构事件抢占、失效回调和无 rAF 回退,并在 `Session` 层证明一帧只发布一次最新累计文本且定稿不会被旧帧回调重复通知。按需启用的浏览器用例持有 adapter 节奏、精确事件数和结尾标记交付,不把 100,000 分片工作负载带入默认测试套件。
 
 
 ## 曾考虑的替代方案
 ## 曾考虑的替代方案
 
 
@@ -32,7 +32,9 @@ Session Controller 把每个 Client-only live chunk 追加到 event source,Con
 
 
 **按动画帧控制测试生产方节奏。** 不予采纳:生产方会在渲染变慢时同步减速,使页面获得真实网络流不存在的隐式背压,并掩盖主线程饥饿。
 **按动画帧控制测试生产方节奏。** 不予采纳:生产方会在渲染变慢时同步减速,使页面获得真实网络流不存在的隐式背压,并掩盖主线程饥饿。
 
 
-**真实模型或录制的 HTTP 字节流。** 不予采纳:实时模型不具确定性,HTTP/SSE(Server-Sent Events)录制也不会改进目标断言。内存 fixture 保留逐个异步会话事件、生产客户端归并和 React 渲染路径,同时控制工作负载与到达节奏。
+**不等待确认就把原有浏览器本地速率发入 WebSocket。** 不予采纳:transport 工作的排队速度会超过模型可能达到的产出速度,使结果变成 socket backlog 测量。浏览器 timer 确认限制每批工作量,同时 heartbeat 仍能发现一批数据使页面饥饿。
+
+**真实外部模型或录制的 HTTP 字节流。** 不予采纳:外部模型不具确定性,HTTP 录制还会增加第二种 fixture 格式,却不会改进目标断言。测试持有的 adapter 让每个 chunk 经过真实 Host 与浏览器 transport,同时控制工作负载。
 
 
 ## 后果
 ## 后果
 
 
@@ -40,4 +42,4 @@ Session Controller 把每个 Client-only live chunk 追加到 event source,Con
 
 
 折叠 Think 摘要的横向布局读写最多每三帧执行一次,并直接追上该时刻的最新位置;React 仍按累计快照正常提交,定稿时摘要恢复到首行。该局部视觉策略不会改变正文滚动和用户交互的即时性。
 折叠 Think 摘要的横向布局读写最多每三帧执行一次,并直接追上该时刻的最新位置;React 仍按累计快照正常提交,定稿时摘要恢复到首行。该局部视觉策略不会改变正文滚动和用户交互的即时性。
 
 
-浏览器压力车道继续提供真实组装应用上的响应性信号和可见 profiling 入口,但硬件与调度差异使其只适合作为显式性能证据。确定性的 focused tests 负责守住发布次数、累计内容与抢占顺序,默认测试车道保持快速。
+浏览器压力车道继续提供真实组装应用与载体上的响应性信号和可见 profiling 入口,但硬件与调度差异使其只适合作为显式性能证据。确定性的 focused tests 负责守住发布次数、累计内容与抢占顺序,默认测试车道保持快速。

+ 2 - 2
.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md
-2026-09-06-client-assembly-test-line.md: 0d7ed86e4f0c8898441737c8d6a4c631586268a2
-2026-09-06-client-assembly-test-line.zh.md: 8b4381dbfa24819e1b1f086b7c318d388b681ef5
+2026-09-06-client-assembly-test-line.md: d2515c0689b9ea39bf14e4ce653a26d9a81ebfb8
+2026-09-06-client-assembly-test-line.zh.md: 90b5768db4307c551b895d7101324f329f42c955

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 12 - 5
.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md


+ 13 - 5
.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md

@@ -18,21 +18,25 @@ API 客户端 spec 用一个可编程的 Remote 面假件驱动对象。这个
 
 
 **roster 从 bundle 现读,绝不拷贝。** `bundleRoster(bundles)` 用 include 插件自己的 YAML 方言(带 `!!js` 的 `entryListSchema`)解析每个 bundle 的 `dsh.bundle.patch`,用它的 `applyEntryPatches` 合成各层,再保留每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行,带上该声明的 `inject` 与 `immediately`。`webApp` 是 `web` profile 的 roster(先 `dsh-base`、再 `dsh-web-app`),import 时算出。spec 点名它要测的东西,其余推导:`webApp.closure([row])` 保留一行及其传递 `inject` 锥;`pick` 与 `without` 留给刻意裁剪。测试运行时仍是 Client 面的包:它不 import 任何 Host 模块,此处不用动态 import,其 client 面的 `types` 在 `client-build-environment` 之外加了 `node`,好让读取器使用 `node:fs`。`closure` 把 shell 静态种入的平台模块(`PLATFORM_MODULES`)视为无需行即已满足。
 **roster 从 bundle 现读,绝不拷贝。** `bundleRoster(bundles)` 用 include 插件自己的 YAML 方言(带 `!!js` 的 `entryListSchema`)解析每个 bundle 的 `dsh.bundle.patch`,用它的 `applyEntryPatches` 合成各层,再保留每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行,带上该声明的 `inject` 与 `immediately`。`webApp` 是 `web` profile 的 roster(先 `dsh-base`、再 `dsh-web-app`),import 时算出。spec 点名它要测的东西,其余推导:`webApp.closure([row])` 保留一行及其传递 `inject` 锥;`pick` 与 `without` 留给刻意裁剪。测试运行时仍是 Client 面的包:它不 import 任何 Host 模块,此处不用动态 import,其 client 面的 `types` 在 `client-build-environment` 之外加了 `node`,好让读取器使用 `node:fs`。`closure` 把 shell 静态种入的平台模块(`PLATFORM_MODULES`)视为无需行即已满足。
 
 
-**生产启动路径原样运行。** `TestClient.start(plan, mock)` 把 mock 装成 Connection 载体(`__DSH_TRANSPORT__ = { rpc: mock.rpc }`),加载每个 roster 行的 `/client` 模块,经生产模块 facade 的 `pendingQueue` 登记其工厂,经 `bootClient` 启动,可选经 `mountClient` 挂载,然后等 `connected`。`reload(name)` 经 client-hmr 导出的 `tearDownEntryFiber` 重建一个 Loader entry;`unload(name)` 移除它;`dispose()` 拆掉一切,然后对任何没有规则的端点让测试失败。jsdom 没有 `EventSource` 与 `ResizeObserver`;`start` 只在全局缺失处装惰性替身。每个 worker 内启动与 entry 重建逐个进行,因为 `connection` 插件在 apply 时读传输全局,每次都先装上当事客户端的传输;传输与替身按引用计数持有,第一个客户端安装、最后一次 dispose 恢复,因此同一测试里重叠的客户端各连各的 mock,`reload` 了 `connection` 行之后也是。
+**生产启动路径只替换 Connection 输入适配。** `TestClient.start(plan, mock)` 加载每个 roster 行的 `/client` 模块,用 `installConnection(ctx, { transport: { rpc: mock.rpc } })` 替换 Connection 行的页面全局适配,经生产模块 facade 的 `pendingQueue` 登记这些工厂,经 `bootClient` 启动,可选经 `mountClient` 挂载,然后等 `connected`。`reload(name)` 经 client-hmr 导出的 `tearDownEntryFiber` 重建一个 Loader entry;`unload(name)` 移除它;`dispose()` 拆掉一切,然后对任何没有规则的端点让测试失败。jsdom 没有 `EventSource` 与 `ResizeObserver`;`start` 只对全局缺失的惰性替身做引用计数。模块系统、Connection 载体、Remote mock、重载和销毁都归实例所有,因此客户端可以并行启动,不通过页面全局变量协调。
+
+modules 插件激活时从自己的 `ctx.loader.internal` 读取模块系统。重载 bootstrap 行会重新发布同一个实例而不让 bootstrap 代码失效,另一个客户端的 Loader 与 `ctx.modules` 不受影响。
 
 
 **`remote.<ns>` 是无契约代理,不是生成客户端。** `@deepseek-ai/dsh-api-remotes` 行被去掉,因为它生成的客户端只存在于构建后的 `lib/`。对 roster 行注入的每个 `remote.<ns>`,加上 mock 有规则的每个命名空间,本档各提供一个 Proxy:`ctx.remote.<ns>.<method>(...args)` 经 roster 自己的 Connection 用位置参数调用端点 `<ns>/<method>`,mock 为它登记了流脚本就走流、否则走一元。Cordis 把 `ctx.remote.<ns>` 解析到服务 `remote.<ns>`,所以 Gateway 客户端本身不动。一元应答原样返回;一元拒绝按生成客户端折叠载体抛错的方式折叠,经 Gateway 客户端导出的 `carrierFailure` 与 `cancelledFailure`,因此不等待就发出 Remote 调用的产品代码看不到任何 reject。流的项与失败按流吐出的样子直传。
 **`remote.<ns>` 是无契约代理,不是生成客户端。** `@deepseek-ai/dsh-api-remotes` 行被去掉,因为它生成的客户端只存在于构建后的 `lib/`。对 roster 行注入的每个 `remote.<ns>`,加上 mock 有规则的每个命名空间,本档各提供一个 Proxy:`ctx.remote.<ns>.<method>(...args)` 经 roster 自己的 Connection 用位置参数调用端点 `<ns>/<method>`,mock 为它登记了流脚本就走流、否则走一元。Cordis 把 `ctx.remote.<ns>` 解析到服务 `remote.<ns>`,所以 Gateway 客户端本身不动。一元应答原样返回;一元拒绝按生成客户端折叠载体抛错的方式折叠,经 Gateway 客户端导出的 `carrierFailure` 与 `cancelledFailure`,因此不等待就发出 Remote 调用的产品代码看不到任何 reject。流的项与失败按流吐出的样子直传。
 
 
 **原生 mock 负责响应配置和调用断言。** 测试通过 `mock.remote.<namespace>.<method>` 使用 `mockResolvedValue`、`mockResolvedValueOnce`、`mockReturnValueOnce` 或 `mockImplementation`,签名由生成的 API 提供。每个 mock 实例独立持有原生响应队列。可复用的表只登记默认值或位置参数 handler,每个端点仅保留最新默认响应。有状态回调和 deferred promise 归各测试所有。`ok` 构造成功信封。流需要显式声明,可提供接收打开参数与句柄(`push`、`end`、`fail`)的脚本;无脚本的声明产生流漏配,未声明端点默认走一元。值不校验。`mock.streams` 控制脚本流并提供打开/排空等待;`mock.log` 记录载体调用(`pending`、`answered`、`failed`)、脚本流状态、首参数 `requests(endpoint?)` 和未匹配请求。`RemoteMock.create()` 为 `$events` 应答 ready 帧,让客户端可以连接。
 **原生 mock 负责响应配置和调用断言。** 测试通过 `mock.remote.<namespace>.<method>` 使用 `mockResolvedValue`、`mockResolvedValueOnce`、`mockReturnValueOnce` 或 `mockImplementation`,签名由生成的 API 提供。每个 mock 实例独立持有原生响应队列。可复用的表只登记默认值或位置参数 handler,每个端点仅保留最新默认响应。有状态回调和 deferred promise 归各测试所有。`ok` 构造成功信封。流需要显式声明,可提供接收打开参数与句柄(`push`、`end`、`fail`)的脚本;无脚本的声明产生流漏配,未声明端点默认走一元。值不校验。`mock.streams` 控制脚本流并提供打开/排空等待;`mock.log` 记录载体调用(`pending`、`answered`、`failed`)、脚本流状态、首参数 `requests(endpoint?)` 和未匹配请求。`RemoteMock.create()` 为 `$events` 应答 ready 帧,让客户端可以连接。
 
 
-**Vitest 拥有每条测试的 mock 和客户端生命周期。** `createClientTest(plan, options)` 增加原生 `mock`、`remote` 与 `start` fixture。mock 每次新建并携带默认响应;`remote` 是它的命名空间 Proxy,显式 `start()` 留出配置启动期应答的时机,同一测试共用一个启动 Promise。收尾等待启动,即使断言失败也销毁成功创建的客户端、检查漏配,并拒绝后续启动。启动错误由调用方 await 观察。分别拥有多个客户端时仍用 `TestClient.start`。场景数据直接配置原生 mock;返回 mutation 应答与更新后续 describe 应答仍是两个独立操作。
+**Vitest 拥有每条测试的 mock 和客户端生命周期。** `createClientTest(plan, options)` 增加原生 `mock`、`remote` 与 `start` fixture。mock 每次新建并携带默认响应;`remote` 是它的命名空间 Proxy,显式 `start()` 留出配置启动期应答的时机,同一测试共用一个启动 Promise。不需要 Connection 的对象层 spec 直接使用 `remote`;需要 Gateway `$stream` 的用例调用 `start()`。收尾等待启动,即使断言失败也销毁成功创建的客户端、检查漏配,并拒绝后续启动。启动错误由调用方 await 观察。分别拥有多个客户端时仍用 `TestClient.start`。场景数据直接配置原生 mock;返回 mutation 应答与更新后续 describe 应答仍是两个独立操作。
 
 
 **`remoteDefaultResponses` 是启动期 Remote 端点的默认响应。** 这张表恰好列出 `web` roster 在没有 session、没有 workspace、默认设置下启动并渲染时会打的端点,每行注明调用方。spec 在其上叠加自己的 `RemoteTable`;新的启动期调用会在 `dispose()` 时让 spec 失败。
 **`remoteDefaultResponses` 是启动期 Remote 端点的默认响应。** 这张表恰好列出 `web` roster 在没有 session、没有 workspace、默认设置下启动并渲染时会打的端点,每行注明调用方。spec 在其上叠加自己的 `RemoteTable`;新的启动期调用会在 `dispose()` 时让 spec 失败。
 
 
+归应用所有的 built-bundle 测试会在 `AppWebEntry` 启动前通过 `__DSH_TRANSPORT__` 安装一份新的 `RemoteMock`。场景数据留在 `apps/web/tests` 下;生产 Connection 包不包含场景数据集或由 query 选择的测试 transport。需要 Host 行为的浏览器用例使用 `launchWebScaffold()` 和真实 HTTP/WebSocket 路径。
+
 `mock.remote` 使用直接调用方与 Connection 分发共用的原生 `@vitest/spy.fn` 函数。`MockedRemote` 对完整生成的命名空间映射应用 Vitest 深层 mock 类型转换;映射为空时仅这个 Proxy 弱化为 `any`。生产 `Context` 与 Remote 声明保持严格,不需要命名空间专属类型副本或编译器 Flag。[Proxy 类型指引](../../../../packages/test-support/remote-mock/README.zh.md#remote-proxy)要求即使无构建测试通过,本地也必须执行构建后的类型检查。
 `mock.remote` 使用直接调用方与 Connection 分发共用的原生 `@vitest/spy.fn` 函数。`MockedRemote` 对完整生成的命名空间映射应用 Vitest 深层 mock 类型转换;映射为空时仅这个 Proxy 弱化为 `any`。生产 `Context` 与 Remote 声明保持严格,不需要命名空间专属类型副本或编译器 Flag。[Proxy 类型指引](../../../../packages/test-support/remote-mock/README.zh.md#remote-proxy)要求即使无构建测试通过,本地也必须执行构建后的类型检查。
 
 
 ## 为本档新增的产品导出
 ## 为本档新增的产品导出
 
 
-- `client/connection`:`ClientTransportHooks.rpc?` 公开 `?fixture` 路径内部已在用的已解码载体;`fetch` 变为可选
+- `client/connection`:`ClientTransportHooks.rpc?` 接受由进程内组合持有的已解码载体;`fetch` 可选。`installConnection(ctx, options)` 从实例局部的 transport、recovery 与 location 输入安装同一个生产服务
 - `client/hmr`:`tearDownEntryFiber(entry)` 就是 `reload` 本来执行的 registry 先行的 fiber 拆除。
 - `client/hmr`:`tearDownEntryFiber(entry)` 就是 `reload` 本来执行的 registry 先行的 fiber 拆除。
 - `client/modules`:`parseDshClient` 与 `exactPackageSpecifier` 从 client 面导出,由 Host 和 roster 读取器共用。测试工厂使用已有注册队列。roster 行到 boot graph 的合成只有测试消费者,放在本档里。
 - `client/modules`:`parseDshClient` 与 `exactPackageSpecifier` 从 client 面导出,由 Host 和 roster 读取器共用。测试工厂使用已有注册队列。roster 行到 boot graph 的合成只有测试消费者,放在本档里。
 - `client/web`:`bootClient` 与 `mountClient` 从 `AppWebEntry.run()` 抽出,后者现在调用它们。
 - `client/web`:`bootClient` 与 `mountClient` 从 `AppWebEntry.run()` 抽出,后者现在调用它们。
@@ -48,7 +52,11 @@ API 客户端 spec 用一个可编程的 Remote 面假件驱动对象。这个
 
 
 **自写一套测试侧的 YAML 与补丁解析器。** 否决:`entryListSchema` 与 `applyEntryPatches` 就是启动器自己的,不带 Host Context 合并;本档只写读文件、定位 package.json 和 web 行过滤。
 **自写一套测试侧的 YAML 与补丁解析器。** 否决:`entryListSchema` 与 `applyEntryPatches` 就是启动器自己的,不带 Host Context 合并;本档只写读文件、定位 package.json 和 web 行过滤。
 
 
-**用一个 mock 模块替代 Gateway 客户端。** 否决:mock 不得干涉 Gateway 内部;装在 Connection 载体上让重试、折叠与流语义都保持真实。
+**用一个 mock 模块替代 Gateway 客户端。** 否决:mock 不得干涉 Gateway 内部;把它传给生产 Connection 安装函数会让重试、折叠与流语义都保持真实。
+
+**在生产 Connection 包中保留由 query 选择的 fixture transport 与场景数据集。** 否决:这会把交付代码耦合到易变的应用测试数据,并让 URL 标记成为隐藏的 transport 选择器。仅客户端的组装测试注入 `RemoteMock`;拥有 Host 行为的浏览器测试使用真实 Host scaffold。
+
+**用 worker 级启动轮次包住页面全局 Connection 载体。** 否决:客户端会共享可变进程状态,每次启动或 Connection 重载都必须串行安装当事客户端的载体。实例局部的 Connection 安装让独立插件树可以并行启动和重载。
 
 
 **第二套带类型的 Gateway 实现,包含 `Api` 泛型、信封与错误类以及 fixtures 目录。** 否决:它重复 Gateway 声明与编解码。mock 从生成的命名空间映射派生方法类型,运行时只声明一元或流行为。
 **第二套带类型的 Gateway 实现,包含 `Api` 泛型、信封与错误类以及 fixtures 目录。** 否决:它重复 Gateway 声明与编解码。mock 从生成的命名空间映射派生方法类型,运行时只声明一元或流行为。
 
 
@@ -87,4 +95,4 @@ spec 起的是真插件:整个 `web` roster 冷启动约五秒、热启动远
 
 
 ## 测试
 ## 测试
 
 
-`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理,以及 jsdom 与纯 Node 下的 `TestClient`。七条改造后的 spec 使用本档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.<ns>` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。
+`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。`apps/web/tests/` 下归应用所有的 built-client spec 注入一份新的 RemoteMock,并将有状态场景数据留在这些 spec 旁边。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/client/ui-message-feedback/tests/` 下,对象层 spec 直接传入 RemoteMock 的 `messageFeedback` 命名空间。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission、projection tail-page、sessions-service 以及验证流的 manager 用例启动 Gateway 依赖锥;其余 manager 用例与 projection-store 的 manager-routing 用例直接使用同一组逐测试 Remote fixture,不启动客户端;client-apply 则启动插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。

+ 3 - 0
THIRD_PARTY_NOTICES.md

@@ -61,7 +61,10 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
 | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
 | [`@trycua/cua-driver`](https://github.com/trycua/cua) | MIT |
 | [`@trycua/cua-driver`](https://github.com/trycua/cua) | MIT |
 | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
 | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
+| [`@xterm/addon-fit`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit) | MIT |
+| [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize) | MIT |
 | [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT |
 | [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT |
+| [`@xterm/xterm`](https://github.com/xtermjs/xterm.js) | MIT |
 | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
 | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
 | [`acorn`](https://github.com/acornjs/acorn) | MIT |
 | [`acorn`](https://github.com/acornjs/acorn) | MIT |
 | [`anser`](https://github.com/IonicaBizau/anser) | MIT |
 | [`anser`](https://github.com/IonicaBizau/anser) | MIT |

+ 3 - 0
apps/cli/composition.md

@@ -152,6 +152,8 @@ flowchart LR
   cfg --> plugin_dsh_base_session_checkpoint_policy
   cfg --> plugin_dsh_base_session_checkpoint_policy
   plugin_dsh_base_tool_result_pruner["tool-result-pruner<br/>@deepseek-ai/dsh-compaction-tool-result-pruner"]
   plugin_dsh_base_tool_result_pruner["tool-result-pruner<br/>@deepseek-ai/dsh-compaction-tool-result-pruner"]
   cfg --> plugin_dsh_base_tool_result_pruner
   cfg --> plugin_dsh_base_tool_result_pruner
+  plugin_dsh_base_image_offload["image-offload<br/>@deepseek-ai/dsh-compaction-image-offload"]
+  cfg --> plugin_dsh_base_image_offload
   plugin_dsh_base_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"]
   plugin_dsh_base_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"]
   cfg --> plugin_dsh_base_tool_todo
   cfg --> plugin_dsh_base_tool_todo
   plugin_dsh_base_tool_goal["tool-goal<br/>@deepseek-ai/dsh-tool-goal"]
   plugin_dsh_base_tool_goal["tool-goal<br/>@deepseek-ai/dsh-tool-goal"]
@@ -256,6 +258,7 @@ flowchart LR
 | `spill-policy` | `@deepseek-ai/dsh-spill-policy` |
 | `spill-policy` | `@deepseek-ai/dsh-spill-policy` |
 | `session-checkpoint-policy` | `@deepseek-ai/dsh-session-checkpoint-policy` |
 | `session-checkpoint-policy` | `@deepseek-ai/dsh-session-checkpoint-policy` |
 | `tool-result-pruner` | `@deepseek-ai/dsh-compaction-tool-result-pruner` |
 | `tool-result-pruner` | `@deepseek-ai/dsh-compaction-tool-result-pruner` |
+| `image-offload` | `@deepseek-ai/dsh-compaction-image-offload` |
 | `tool-todo` | `@deepseek-ai/dsh-tool-todo` |
 | `tool-todo` | `@deepseek-ai/dsh-tool-todo` |
 | `tool-goal` | `@deepseek-ai/dsh-tool-goal` |
 | `tool-goal` | `@deepseek-ai/dsh-tool-goal` |
 | `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` |
 | `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` |

+ 3 - 0
apps/web/package.json

@@ -33,6 +33,7 @@
     "@deepseek-ai/cordis-plugin-group": "workspace:^",
     "@deepseek-ai/cordis-plugin-group": "workspace:^",
     "@deepseek-ai/dsh-client-modules": "workspace:^",
     "@deepseek-ai/dsh-client-modules": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
+    "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^",
     "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
@@ -41,6 +42,7 @@
     "@deepseek-ai/dsh-pwsh-local": "workspace:^",
     "@deepseek-ai/dsh-pwsh-local": "workspace:^",
     "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:^",
     "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:^",
     "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^",
     "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^",
+    "@deepseek-ai/dsh-remote-mock": "workspace:^",
     "@types/node": "^22.0.0",
     "@types/node": "^22.0.0",
     "@types/react": "~18.3.1",
     "@types/react": "~18.3.1",
     "@types/react-dom": "~18.3.0",
     "@types/react-dom": "~18.3.0",
@@ -56,6 +58,7 @@
     "vitest": "^4.1.8",
     "vitest": "^4.1.8",
     "ws": "8.21.0",
     "ws": "8.21.0",
     "@deepseek-ai/dsh-launch-environment": "workspace:^",
     "@deepseek-ai/dsh-launch-environment": "workspace:^",
+    "@deepseek-ai/dsh-subprocess-local": "workspace:^",
     "@deepseek-ai/dsh-experimental-auto-review": "workspace:^"
     "@deepseek-ai/dsh-experimental-auto-review": "workspace:^"
   }
   }
 }
 }

+ 123 - 41
apps/web/stress-tests/reasoning-chunks.stress.ts

@@ -1,22 +1,29 @@
 /**
 /**
  * Opt-in browser stress reproduction for reasoning-stream renderer stalls.
  * Opt-in browser stress reproduction for reasoning-stream renderer stalls.
- * The fixture emits 100,000 individual chunks through the normal async
- * carrier; the test measures event-loop and scheduled-interaction delay while
- * the assembled React surface keeps a collapsed Think row live.
+ * A test-owned model adapter emits 100,000 individual chunks through the real
+ * Host, Gateway, and browser carriers; the test measures event-loop and
+ * scheduled-interaction delay while the assembled React surface keeps a
+ * collapsed Think row live.
  */
  */
 import type { Browser, Page } from 'playwright'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { chromium } from 'playwright'
 import { expect, it, onTestFailed } from 'vitest'
 import { expect, it, onTestFailed } from 'vitest'
+import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
+import type {} from '@deepseek-ai/dsh-agent-default-model'
 import { launchWebScaffold, watchConsole, type WebScaffold } from '../tests/scaffold.ts'
 import { launchWebScaffold, watchConsole, type WebScaffold } from '../tests/scaffold.ts'
-import { newEnglishPage, saveFailureShot } from '../tests/support.ts'
+import {
+  connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft,
+} from '../tests/support.ts'
 
 
 const CHUNK_COUNT = 100_000
 const CHUNK_COUNT = 100_000
 const CHUNKS_PER_INTERVAL = 128
 const CHUNKS_PER_INTERVAL = 128
 const CHUNK_INTERVAL_MS = 16
 const CHUNK_INTERVAL_MS = 16
 const MAIN_THREAD_DELAY_BUDGET_MS = 250
 const MAIN_THREAD_DELAY_BUDGET_MS = 250
+const PROVIDER = 'reasoning-stress-test'
+const MODEL = 'reasoning-storm'
+const MARKER = `REASONING_STRESS_COMPLETE:${String(CHUNK_COUNT)}`
 
 
 interface ReasoningChunkStormState {
 interface ReasoningChunkStormState {
-  sessionId: string
   chunkCount: number
   chunkCount: number
   chunksPerInterval: number
   chunksPerInterval: number
   intervalMs: number
   intervalMs: number
@@ -25,6 +32,86 @@ interface ReasoningChunkStormState {
   emitting: boolean
   emitting: boolean
 }
 }
 
 
+/** Externally paced model stream whose counters are observed outside the browser. */
+class ReasoningStressAdapter extends LlmAdapter {
+  private finishStream!: () => void
+  private resolveStarted!: () => void
+  private resolveCompleted!: () => void
+  private releaseBatch: (() => void) | undefined
+  private resolveBatch: (() => void) | undefined
+  private batchInFlight = false
+  private finishRequested = false
+  private readonly finishGate = new Promise<void>((resolve) => { this.finishStream = resolve })
+  readonly started = new Promise<void>((resolve) => { this.resolveStarted = resolve })
+  readonly completed = new Promise<void>((resolve) => { this.resolveCompleted = resolve })
+  private emitted = 0
+  private emitting = false
+
+  finish(): void {
+    this.finishRequested = true
+    this.releaseBatch?.()
+    this.finishStream()
+  }
+
+  async emitNextBatch(): Promise<void> {
+    if (!this.emitting || this.releaseBatch === undefined) {
+      throw new Error('reasoning stress adapter has no batch awaiting release')
+    }
+    if (this.batchInFlight) throw new Error('reasoning stress adapter accepts one batch release at a time')
+    this.batchInFlight = true
+    const delivered = new Promise<void>((resolve) => { this.resolveBatch = resolve })
+    const release = this.releaseBatch
+    this.releaseBatch = undefined
+    release()
+    await delivered
+  }
+
+  state(): ReasoningChunkStormState {
+    return {
+      chunkCount: CHUNK_COUNT,
+      chunksPerInterval: CHUNKS_PER_INTERVAL,
+      intervalMs: CHUNK_INTERVAL_MS,
+      emitted: this.emitted,
+      marker: MARKER,
+      emitting: this.emitting,
+    }
+  }
+
+  override async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
+    if (this.emitting || this.emitted !== 0) throw new Error('reasoning stress adapter accepts one model call')
+    this.emitting = true
+    const parts: string[] = []
+    yield { type: 'block-start', index: 0, blockType: 'reasoning' }
+    while (this.emitted < CHUNK_COUNT) {
+      await new Promise<void>((resolve) => {
+        this.releaseBatch = resolve
+        if (this.emitted === 0) this.resolveStarted()
+        if (this.finishRequested) resolve()
+      })
+      if (this.finishRequested) break
+      options.signal?.throwIfAborted()
+      const end = Math.min(this.emitted + CHUNKS_PER_INTERVAL, CHUNK_COUNT)
+      while (this.emitted < end) {
+        const text = this.emitted === CHUNK_COUNT - 1
+          ? `\n${MARKER}`
+          : this.emitted % 64 === 63 ? '推理\n' : '推理'
+        parts.push(text)
+        yield { type: 'reasoning-delta', index: 0, text }
+        this.emitted += 1
+      }
+      this.batchInFlight = false
+      this.resolveBatch?.()
+      this.resolveBatch = undefined
+    }
+    this.emitting = false
+    if (this.emitted === CHUNK_COUNT) this.resolveCompleted()
+    await this.finishGate
+    options.signal?.throwIfAborted()
+    yield { type: 'block-end', index: 0, block: { type: 'reasoning', text: parts.join('') } }
+    yield { type: 'finish', reason: { kind: 'stop' } }
+  }
+}
+
 interface StressProbe {
 interface StressProbe {
   intervalId: number
   intervalId: number
   intervalMs: number
   intervalMs: number
@@ -36,10 +123,6 @@ interface StressProbe {
 }
 }
 
 
 interface StressWindow extends Window {
 interface StressWindow extends Window {
-  __fxTiming?: {
-    startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string
-    reasoningChunkStormState(): ReasoningChunkStormState | null
-  }
   __reasoningStressProbe?: StressProbe
   __reasoningStressProbe?: StressProbe
 }
 }
 
 
@@ -47,23 +130,22 @@ it('keeps the browser responsive while rendering 100,000 reasoning chunks', asyn
   let scaffold: WebScaffold | undefined
   let scaffold: WebScaffold | undefined
   let browser: Browser | undefined
   let browser: Browser | undefined
   let page: Page | undefined
   let page: Page | undefined
+  const adapter = new ReasoningStressAdapter()
   try {
   try {
     scaffold = await launchWebScaffold()
     scaffold = await launchWebScaffold()
+    scaffold.ctx.effect(
+      () => scaffold!.ctx.llm.registerAdapter([PROVIDER], adapter),
+      'reasoning stress adapter',
+    )
+    await scaffold.ctx.agentDefaultModel.saveSelection({ provider: PROVIDER, model: MODEL })
     browser = await chromium.launch({ headless: process.env.DSH_WEB_STRESS_HEADFUL !== '1' })
     browser = await chromium.launch({ headless: process.env.DSH_WEB_STRESS_HEADFUL !== '1' })
     page = await newEnglishPage(browser)
     page = await newEnglishPage(browser)
     const activePage = page
     const activePage = page
-    await activePage.addInitScript(() => {
-      localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: 'fx-alpha' }))
-    })
     const tripwire = watchConsole(activePage)
     const tripwire = watchConsole(activePage)
     onTestFailed(() => saveFailureShot(activePage, 'web-stress-reasoning-chunks'))
     onTestFailed(() => saveFailureShot(activePage, 'web-stress-reasoning-chunks'))
-    await activePage.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' })
+    await activePage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
-    // Fixture settings deliberately reject writes, so its welcome notice
-    // cannot acknowledge. Hide only that test overlay; the assembled chat
-    // tree beneath it remains mounted and exercises the production renderer.
-    await activePage.addStyleTag({ content: '[class*="onboardingOverlay"] { display: none !important; }' })
-    await activePage.locator('[data-sample="bash"]').first().waitFor({ timeout: 30_000 })
+    await connectFreshWorkspace(activePage, scaffold.workspaceCwd)
 
 
     await activePage.evaluate(() => {
     await activePage.evaluate(() => {
       const intervalMs = 50
       const intervalMs = 50
@@ -92,45 +174,42 @@ it('keeps the browser responsive while rendering 100,000 reasoning chunks', asyn
       ;(window as StressWindow).__reasoningStressProbe = probe
       ;(window as StressWindow).__reasoningStressProbe = probe
     })
     })
 
 
-    const marker = await activePage.evaluate(({ chunkCount, chunksPerInterval, intervalMs }) => {
-      const hooks = (window as StressWindow).__fxTiming
-      if (hooks === undefined) throw new Error('reasoning stress fixture hooks unavailable')
-      return hooks.startReasoningChunkStorm('fx-alpha', chunkCount, chunksPerInterval, intervalMs)
-    }, {
-      chunkCount: CHUNK_COUNT,
-      chunksPerInterval: CHUNKS_PER_INTERVAL,
-      intervalMs: CHUNK_INTERVAL_MS,
-    })
+    const settled = scaffold.whenTurnSettled(540_000)
+    const input = activePage.locator('[data-composer-input]').first()
+    await writeComposerDraft(activePage, input, `Render ${String(CHUNK_COUNT)} reasoning chunks.`)
+    await input.press('Enter')
+    await adapter.started
 
 
+    await adapter.emitNextBatch()
     const liveThink = activePage.locator('[data-variant="think"][data-state="running"]').last()
     const liveThink = activePage.locator('[data-variant="think"][data-state="running"]').last()
     await liveThink.waitFor({ timeout: 60_000 })
     await liveThink.waitFor({ timeout: 60_000 })
-    await expect.poll(async () => await activePage.evaluate(() => {
-      const hooks = (window as StressWindow).__fxTiming
-      return hooks?.reasoningChunkStormState()?.emitted ?? 0
-    }), { timeout: 540_000, interval: 100 }).toBe(CHUNK_COUNT)
-    await expect.poll(() => liveThink.textContent(), { timeout: 60_000, interval: 100 }).toContain(marker)
+    await activePage.evaluate(intervalMs => new Promise<void>((resolve) => {
+      window.setTimeout(resolve, intervalMs)
+    }), CHUNK_INTERVAL_MS)
+    while (adapter.state().emitted < CHUNK_COUNT) {
+      await adapter.emitNextBatch()
+      await activePage.evaluate(intervalMs => new Promise<void>((resolve) => {
+        window.setTimeout(resolve, intervalMs)
+      }), CHUNK_INTERVAL_MS)
+    }
+    await adapter.completed
+    await expect.poll(() => liveThink.textContent(), { timeout: 60_000, interval: 100 }).toContain(MARKER)
 
 
-    const report = await activePage.evaluate(() => {
+    const browserReport = await activePage.evaluate(() => {
       const win = window as StressWindow
       const win = window as StressWindow
       const probe = win.__reasoningStressProbe
       const probe = win.__reasoningStressProbe
-      const state = win.__fxTiming?.reasoningChunkStormState()
-      if (probe === undefined || state === undefined || state === null) {
-        throw new Error('reasoning stress metrics unavailable')
-      }
+      if (probe === undefined) throw new Error('reasoning stress metrics unavailable')
       window.clearInterval(probe.intervalId)
       window.clearInterval(probe.intervalId)
       const interactionDelayMs = probe.interactionHandledAt === null
       const interactionDelayMs = probe.interactionHandledAt === null
         ? null
         ? null
         : probe.interactionHandledAt - probe.interactionDueAt
         : probe.interactionHandledAt - probe.interactionDueAt
       return {
       return {
-        chunkCount: state.chunkCount,
-        chunksPerInterval: state.chunksPerInterval,
-        intervalMs: state.intervalMs,
-        emitted: state.emitted,
         maxMainThreadDelayMs: Math.max(0, probe.maxDelayMs),
         maxMainThreadDelayMs: Math.max(0, probe.maxDelayMs),
         interactionDelayMs,
         interactionDelayMs,
         heartbeatSamples: probe.samples,
         heartbeatSamples: probe.samples,
       }
       }
     })
     })
+    const report = { ...adapter.state(), ...browserReport }
     process.stdout.write(`reasoning-chunk stress report: ${JSON.stringify(report)}\n`)
     process.stdout.write(`reasoning-chunk stress report: ${JSON.stringify(report)}\n`)
 
 
     expect(report).toMatchObject({
     expect(report).toMatchObject({
@@ -146,7 +225,10 @@ it('keeps the browser responsive while rendering 100,000 reasoning chunks', asyn
     expect(interactionDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS)
     expect(interactionDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
+    adapter.finish()
+    await settled
   } finally {
   } finally {
+    adapter.finish()
     await browser?.close()
     await browser?.close()
     await scaffold?.close()
     await scaffold?.close()
   }
   }

+ 2 - 2
apps/web/tests/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/web/tests/README.md
 #   pnpm run verify-translation-pairing --write apps/web/tests/README.md
-README.md: 883d93d746afa6bac0b67f2c2d6bc6c03b31553a
-README.zh.md: 7a232329b890a669240272aada3a5bafff9efd99
+README.md: b2fa27bb7a60ed78309bf81e77b9cd5217bbee2a
+README.zh.md: 29ae2e86018257a4fc2379ea2563c8b5e5e9036f

+ 1 - 1
apps/web/tests/README.md

@@ -18,6 +18,6 @@ Importing a Client package — a value or a type — pulls its whole TypeScript
 
 
 When a scenario needs a Client-owned constant or pure function, mirror it here instead, next to the commented-out import that names the source module. A drift then surfaces as a missed selector or a stale mirrored value — a loud failure, never a silent pass. `scaffold.ts` follows this rule for the welcome-notice namespace, acknowledgement field, version, and asserted Chinese copy.
 When a scenario needs a Client-owned constant or pure function, mirror it here instead, next to the commented-out import that names the source module. A drift then surfaces as a missed selector or a stale mirrored value — a loud failure, never a silent pass. `scaffold.ts` follows this rule for the welcome-notice namespace, acknowledgement field, version, and asserted Chinese copy.
 
 
-One kind of Client import stands. `assembled-boot.ts` drives the shell itself, so it imports `AppWebEntry` from `@deepseek-ai/dsh-client-web` and the boot-manifest type from `@deepseek-ai/dsh-client-modules/client`: booting the real shell is what that harness is for, and both packages are already in the Host graph. The chat scenarios mirror `conversationContextKey` in `support.ts` instead of importing its Client owner.
+The built-client harness is the exception. `assembled-boot.ts` imports `AppWebEntry`, the boot-manifest type, and `RemoteMock`; `assembled-remote.ts` imports the Client test runtime's default responses and `RemoteMock`. These packages are explicit project references for booting the real shell against a test-owned carrier. The chat scenarios mirror `conversationContextKey` in `support.ts` instead of importing its Client owner.
 
 
 Nothing mechanically enforces this rule; keep it in review.
 Nothing mechanically enforces this rule; keep it in review.

+ 1 - 1
apps/web/tests/README.zh.md

@@ -18,6 +18,6 @@ import 一个 Client 包——无论值还是类型——都会把它整个 Type
 
 
 当某个场景需要 Client 持有的常量或纯函数时,改为在此处镜像一份,并紧挨着一条注释掉的 import 点明源模块。这样漂移会表现为选择器未命中或镜像值陈旧——是响亮的失败,绝不会是静默通过。`scaffold.ts` 按此规则镜像 welcome-notice 的 namespace、确认字段、版本和被断言的中文文案。
 当某个场景需要 Client 持有的常量或纯函数时,改为在此处镜像一份,并紧挨着一条注释掉的 import 点明源模块。这样漂移会表现为选择器未命中或镜像值陈旧——是响亮的失败,绝不会是静默通过。`scaffold.ts` 按此规则镜像 welcome-notice 的 namespace、确认字段、版本和被断言的中文文案。
 
 
-有一类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从 `@deepseek-ai/dsh-client-web` import `AppWebEntry`、从 `@deepseek-ai/dsh-client-modules/client` import boot manifest(元数据清单)类型:启动真实 shell 正是该 harness 的用途,且这两个包本来就在 Host 图中。chat 场景则在 `support.ts` 中镜像 `conversationContextKey`,而不 import 其 Client owner。
+built-client harness 是例外。`assembled-boot.ts` import `AppWebEntry`、boot manifest(元数据清单)类型与 `RemoteMock`;`assembled-remote.ts` import Client test runtime 的默认响应与 `RemoteMock`。这些包是显式的工程引用,用于通过测试持有的 carrier 启动真实 shell。chat 场景仍在 `support.ts` 中镜像 `conversationContextKey`,而不 import 其 Client owner。
 
 
 没有任何机制强制这条规则;靠 review 守住它。
 没有任何机制强制这条规则;靠 review 守住它。

+ 33 - 9
apps/web/tests/assembled-boot.ts

@@ -1,6 +1,6 @@
 // Shared scaffolding for the assembled-jsdom snapshots: the real built
 // Shared scaffolding for the assembled-jsdom snapshots: the real built
 // workspace `lib/client.js` artifacts booted through AppWebEntry's
 // workspace `lib/client.js` artifacts booted through AppWebEntry's
-// ModuleLoader path (loadBundle) against the keyless fixture Connection RPC
+// ModuleLoader path (loadBundle) against a test-owned RemoteMock carrier
 // transport. Every file that mounts this graph needs the same boot entry list,
 // 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
 // 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.
 // differs only in what it asserts afterwards, so the scaffolding lives here.
@@ -15,7 +15,11 @@ import { act, cleanup } from '@testing-library/react'
 import { afterEach, beforeEach, vi } from 'vitest'
 import { afterEach, beforeEach, vi } from 'vitest'
 import { bootInjections, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules'
 import { bootInjections, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules'
 import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client'
 import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client'
+import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock'
 import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
 import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
+import {
+  createAssembledRemote, type AssembledRemote, type AssembledRemoteOptions,
+} from './assembled-remote.ts'
 
 
 interface AssembledPlugin extends WebBootEntry {
 interface AssembledPlugin extends WebBootEntry {
   /** Absolute path to the built client artifact declared by this package. */
   /** Absolute path to the built client artifact declared by this package. */
@@ -25,6 +29,8 @@ interface AssembledPlugin extends WebBootEntry {
 interface AssembledBootOptions {
 interface AssembledBootOptions {
   /** Package ids omitted from this mounted composition. */
   /** Package ids omitted from this mounted composition. */
   readonly exclude?: readonly string[]
   readonly exclude?: readonly string[]
+  /** Remote answers owned by this assembled case. */
+  readonly remote?: AssembledRemoteOptions
 }
 }
 
 
 interface ClientPackageManifest {
 interface ClientPackageManifest {
@@ -174,6 +180,7 @@ function bundleTable(graph: WebBootGraph, plugins: readonly AssembledPlugin[]):
 interface FixtureWindow extends Window {
 interface FixtureWindow extends Window {
   __DSH_BOOT__?: WebBootGraph
   __DSH_BOOT__?: WebBootGraph
   __ModuleLoader__?: ClientModuleLoaderTarget
   __ModuleLoader__?: ClientModuleLoaderTarget
+  __DSH_TRANSPORT__?: { readonly rpc: RemoteMock['rpc'] }
 }
 }
 
 
 class ResizeObserverStub {
 class ResizeObserverStub {
@@ -189,6 +196,7 @@ class EventSourceStub {
 
 
 const win = window as FixtureWindow
 const win = window as FixtureWindow
 let unmount: (() => Promise<void>) | undefined
 let unmount: (() => Promise<void>) | undefined
+let mountedRemote: RemoteMock | undefined
 
 
 /**
 /**
  * Register the per-test jsdom setup and teardown the assembled boot needs:
  * Register the per-test jsdom setup and teardown the assembled boot needs:
@@ -214,9 +222,8 @@ export function installAssembledBootEnv(): void {
   beforeEach(() => {
   beforeEach(() => {
     localStorage.clear()
     localStorage.clear()
     // The locale service derives its provisional locale from the browser and
     // The locale service derives its provisional locale from the browser and
-    // takes an explicit choice only from Host settings, which this lane's
-    // fixture transport does not serve; pinning the navigator is what selects
-    // English here.
+    // takes an explicit choice only from Host settings. This scenario serves no
+    // locale setting, so pinning the navigator selects English.
     Object.defineProperty(navigator, 'languages', { value: ['en-US'], configurable: true })
     Object.defineProperty(navigator, 'languages', { value: ['en-US'], configurable: true })
     Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true })
     Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true })
     document.title = 'DeepSeek Harness'
     document.title = 'DeepSeek Harness'
@@ -228,11 +235,23 @@ export function installAssembledBootEnv(): void {
   })
   })
 
 
   afterEach(async () => {
   afterEach(async () => {
-    await act(async () => { await unmount?.() })
+    const failures: unknown[] = []
+    try {
+      await act(async () => { await unmount?.() })
+    } catch (error) {
+      failures.push(error)
+    }
+    try {
+      mountedRemote?.assertNoUnmatched()
+    } catch (error) {
+      failures.push(error)
+    }
     unmount = undefined
     unmount = undefined
+    mountedRemote = undefined
     cleanup()
     cleanup()
     delete win.__DSH_BOOT__
     delete win.__DSH_BOOT__
     delete win.__ModuleLoader__
     delete win.__ModuleLoader__
+    delete win.__DSH_TRANSPORT__
     document.body.innerHTML = ''
     document.body.innerHTML = ''
     document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
     document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
     document.title = ''
     document.title = ''
@@ -243,19 +262,23 @@ export function installAssembledBootEnv(): void {
     delete ownNavigator.languages
     delete ownNavigator.languages
     delete ownNavigator.language
     delete ownNavigator.language
     vi.unstubAllGlobals()
     vi.unstubAllGlobals()
+    if (failures.length > 0) throw new AggregateError(failures, 'assembled boot teardown failed')
   })
   })
 }
 }
 
 
 /**
 /**
- * Mount the assembled application on the fixture transport; the teardown
+ * Mount the assembled application on an isolated RemoteMock transport; the teardown
  * registered by installAssembledBootEnv disposes it.
  * registered by installAssembledBootEnv disposes it.
- * @param search - fixture query string used to select deterministic host behavior.
  * @param options - composition changes applied to this mount.
  * @param options - composition changes applied to this mount.
+ * @returns the test-owned RemoteMock world.
  */
  */
-export function mountAssembledApp(search = '?fixture', options: AssembledBootOptions = {}): void {
+export function mountAssembledApp(options: AssembledBootOptions = {}): AssembledRemote {
   const excluded = new Set(options.exclude)
   const excluded = new Set(options.exclude)
   const plugins = PLUGINS.filter(plugin => !excluded.has(plugin.id))
   const plugins = PLUGINS.filter(plugin => !excluded.has(plugin.id))
-  history.replaceState(null, '', `/${search}`)
+  const remote = createAssembledRemote(options.remote)
+  mountedRemote = remote.mock
+  win.__DSH_TRANSPORT__ = { rpc: remote.mock.rpc }
+  history.replaceState(null, '', '/')
   const root = document.createElement('div')
   const root = document.createElement('div')
   root.id = 'root'
   root.id = 'root'
   document.body.appendChild(root)
   document.body.appendChild(root)
@@ -281,6 +304,7 @@ export function mountAssembledApp(search = '?fixture', options: AssembledBootOpt
     void entry.run()
     void entry.run()
     unmount = () => entry.dispose()
     unmount = () => entry.dispose()
   })
   })
+  return remote
 }
 }
 
 
 /**
 /**

+ 44 - 0
apps/web/tests/assembled-remote.spec.ts

@@ -0,0 +1,44 @@
+/** Stateful behavior of the app-owned RemoteMock scenario. */
+
+import { expect, it } from 'vitest'
+import { createAssembledRemote } from './assembled-remote.ts'
+
+it('advances turns and updates the Session summary after accepted prompts', async () => {
+  const { mock } = createAssembledRemote()
+  await mock.dispatch('session/create', [{ request: { cwd: '/work' } }])
+  await mock.dispatch('session/prompt', [{
+    request: { sessionId: 'fx-1', requestId: 'request-1', mode: 'queue', content: [] },
+  }])
+  await mock.dispatch('session/prompt', [{
+    request: { sessionId: 'fx-1', requestId: 'request-2', mode: 'queue', content: [] },
+  }])
+
+  const listed = await mock.dispatch('session/list', []) as {
+    readonly ok: true
+    readonly value: {
+      readonly items: readonly { readonly sessionId: string; readonly blank: boolean; readonly running: boolean }[]
+    }
+  }
+  expect(listed.ok).toBe(true)
+  expect(listed.value.items.find(item => item.sessionId === 'fx-1')).toMatchObject({
+    blank: false,
+    running: true,
+  })
+
+  const iterator = mock.open('session/follow', [{
+    request: { address: { kind: 'session', sessionId: 'fx-1' } },
+  }], new AbortController().signal)[Symbol.asyncIterator]()
+  await expect(iterator.next()).resolves.toMatchObject({
+    done: false,
+    value: {
+      records: [
+        { event: { type: 'turn/start', data: { turn: 0 } } },
+        { event: { type: 'user/message' } },
+        { event: { type: 'turn/start', data: { turn: 1 } } },
+        { event: { type: 'user/message' } },
+      ],
+    },
+  })
+  await iterator.return?.()
+  mock.assertNoUnmatched()
+})

+ 399 - 0
apps/web/tests/assembled-remote.ts

@@ -0,0 +1,399 @@
+/**
+ * RemoteMock scenario for built-client tests that do not own a Host.
+ * The adjacent JSON is maintained with this module when Remote responses or
+ * the current Session header version change.
+ * Fixture Sessions have no retained Host terminals to restore.
+ */
+
+import { readFileSync } from 'node:fs'
+import { randomUUID } from 'node:crypto'
+import { join } from 'node:path'
+import { ok, RemoteMock } from '@deepseek-ai/dsh-remote-mock'
+import { remoteDefaultResponses } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses.ts'
+
+interface SessionSummary {
+  readonly sessionId: string
+  updatedAt: number
+  running: boolean
+  blank: boolean
+  readonly parentSessionId?: string
+  readonly origin?: 'subagent'
+  readonly cwd?: string
+  readonly projections?: {
+    readonly asOfSeq: number
+    readonly values: Readonly<Record<string, unknown>>
+  }
+}
+
+interface WorkspaceView {
+  readonly workspaceId: string
+  readonly path: string
+  readonly title: string
+  sessionIds: string[]
+  readonly createdAt: string
+  updatedAt: string
+}
+
+interface EventRecord {
+  readonly type: 'event'
+  readonly event: {
+    readonly seq: number
+    readonly time: number
+    readonly type: string
+    readonly data: unknown
+    readonly surfaceOp?: string
+  }
+}
+
+interface FollowSnapshot {
+  readonly type: 'snapshot'
+  readonly header: Readonly<Record<string, unknown>>
+  readonly cursor: number
+  readonly records: readonly EventRecord[]
+  readonly hasMore: boolean
+  readonly projections: Readonly<Record<string, unknown>>
+  readonly assistantStream?: Readonly<Record<string, unknown>>
+}
+
+interface ControlBaseline {
+  readonly type: 'baseline'
+  readonly value: {
+    readonly queues: Readonly<Record<string, readonly unknown[]>>
+    readonly jobs: Readonly<Record<string, readonly unknown[]>>
+    readonly approvals: readonly unknown[]
+    readonly questions: readonly unknown[]
+    readonly projections: Readonly<Record<string, {
+      readonly asOfSeq: number
+      readonly values: Readonly<Record<string, unknown>>
+    }>>
+  }
+}
+
+interface CapturedFixture {
+  readonly sessionList: { readonly ok: true; readonly value: { readonly items: readonly SessionSummary[] } }
+  readonly settingsDescribe: unknown
+  readonly credentialsDescribe: unknown
+  readonly modelCatalog: unknown
+  readonly agentPresets: unknown
+  readonly commands: unknown
+  readonly workspace: {
+    readonly type: 'baseline'
+    readonly value: {
+      readonly items: readonly WorkspaceView[]
+      readonly archivedSessionIds: readonly string[]
+    }
+  }
+  readonly control: ControlBaseline
+  readonly remoteEvents: readonly unknown[]
+  readonly follow: FollowSnapshot
+  readonly attachment: unknown
+}
+
+export interface AssembledRemoteOptions {
+  /** Return the fixture's image-dimension admission error from Session prompt. */
+  readonly rejectPrompt?: boolean
+}
+
+export interface AssembledRemote {
+  readonly mock: RemoteMock
+}
+
+const fixtureSource = readFileSync(
+  join(process.cwd(), 'apps/web/tests/fixtures/assembled-remote.fixture.json'),
+  'utf8',
+)
+
+/** Create one isolated RemoteMock world for an assembled built-client case. */
+export function createAssembledRemote(options: AssembledRemoteOptions = {}): AssembledRemote {
+  const fixture = JSON.parse(fixtureSource) as CapturedFixture
+  const sessions = [...structuredClone(fixture.sessionList.value.items)]
+  const workspaces = [...structuredClone(fixture.workspace.value.items)]
+  const records = new Map<string, EventRecord[]>([[
+    'fx-alpha',
+    structuredClone(fixture.follow.records) as EventRecord[],
+  ]])
+  const nextTurns = new Map([...records].map(([sessionId, sessionRecords]) => {
+    let next = 0
+    for (const { event } of sessionRecords) {
+      if (event.type !== 'turn/start' || !isRecord(event.data)) continue
+      const turn = event.data['turn']
+      if (typeof turn === 'number') next = Math.max(next, turn + 1)
+    }
+    return [sessionId, next] as const
+  }))
+  const attachments = new Map<string, unknown>([['fixture:image', structuredClone(fixture.attachment)]])
+  const blankSessionProjections = fixture.control.value.projections['fx-gamma']
+  if (blankSessionProjections === undefined) {
+    throw new Error('assembled fixture: blank Session projections missing')
+  }
+  let nextSession = 1
+
+  const mock = RemoteMock.create().load(remoteDefaultResponses)
+  mock.load({
+    unary: {
+      'settings/describe': structuredClone(fixture.settingsDescribe),
+      'credentials/describe': structuredClone(fixture.credentialsDescribe),
+      'session/modelCatalog': structuredClone(fixture.modelCatalog),
+      'agentPresets/list': structuredClone(fixture.agentPresets),
+      'commands/list': structuredClone(fixture.commands),
+      'settings/canOpenAgentPresetDirectory': ok(true),
+      'settings/openSettingsDocument': ok({ opened: true }),
+      'settings/openAgentPresetDirectory': ok({ opened: true }),
+      'subagents/list': ok({ entries: [], parentAvailable: true }),
+      'terminal/list': ok([]),
+      'skills/list': ok({ skills: [] }),
+      'session/canOpenWorkspacePath': ok(true),
+      'session/openWorkspacePath': ok({ opened: true }),
+      'session/updateQueue': {
+        ok: false,
+        error: {
+          code: 'session/queue-item-not-found',
+          message: 'assembled fixture has no pending queue item',
+          details: {},
+        },
+      },
+      'session/cancel': ok({ accepted: true }),
+    },
+  })
+
+  mock.stream('$events', (_args, stream) => {
+    for (const frame of fixture.remoteEvents) stream.push(structuredClone(frame))
+  })
+  mock.stream('session/control', (_args, stream) => {
+    stream.push(structuredClone(fixture.control))
+  })
+  mock.stream('workspace/follow', (_args, stream) => {
+    stream.push({
+      type: 'baseline',
+      value: {
+        items: structuredClone(workspaces),
+        archivedSessionIds: structuredClone(fixture.workspace.value.archivedSessionIds),
+      },
+    })
+  })
+  mock.stream('session/follow', ([args], stream) => {
+    const request = recordValue(args, 'request')
+    const sessionId = followedSessionId(request)
+    if (sessionId === 'fx-alpha') {
+      stream.push(structuredClone(fixture.follow))
+      return
+    }
+    const summary = sessions.find(candidate => candidate.sessionId === sessionId)
+    if (summary === undefined) throw new Error(`assembled fixture: no Session ${sessionId}`)
+    const sessionRecords = records.get(sessionId) ?? []
+    stream.push({
+      type: 'snapshot',
+      header: {
+        version: 3,
+        id: sessionId,
+        createdAt: summary.updatedAt,
+        cwd: summary.cwd,
+        isSeeded: false,
+      },
+      cursor: sessionRecords.at(-1)?.event.seq ?? -1,
+      records: structuredClone(sessionRecords),
+      hasMore: false,
+      projections: structuredClone(blankSessionProjections),
+      ...isAssistantStreamRequested(request) ? { assistantStream: { revision: 0 } } : {},
+    })
+  })
+
+  mock.unary('$events/result', (result: unknown) => {
+    const eventId = recordString(result, 'eventId')
+    mock.streams.push('$events', { type: 'cancel', eventId })
+    return ok(undefined)
+  })
+  mock.unary('session/list', () => ok({ items: structuredClone(sessions) }))
+  mock.unary('workspace/create', (request: unknown) => {
+    const path = recordString(recordValue(request, 'request'), 'path')
+    const existing = workspaces.find(workspace => workspace.path === path)
+    if (existing !== undefined) return ok({ workspace: structuredClone(existing), created: false })
+    const now = new Date().toISOString()
+    const workspace = {
+      workspaceId: `fx-ws-${String(workspaces.length + 1)}`,
+      path,
+      title: path.split('/').filter(Boolean).at(-1) ?? path,
+      sessionIds: [],
+      createdAt: now,
+      updatedAt: now,
+    }
+    workspaces.unshift(workspace)
+    mock.streams.push('workspace/follow', { type: 'upsert', workspace: structuredClone(workspace) })
+    return ok({ workspace: structuredClone(workspace), created: true })
+  })
+  mock.unary('session/create', (request: unknown) => {
+    request = recordValue(request, 'request')
+    const requestedId = optionalRecordString(request, 'sessionId')
+    const sessionId = requestedId ?? `fx-${String(nextSession++)}`
+    const existing = sessions.find(candidate => candidate.sessionId === sessionId)
+    if (existing !== undefined) return ok({ sessionId })
+    const workspaceId = optionalRecordString(request, 'workspaceId')
+    const workspace = workspaces.find(candidate => candidate.workspaceId === workspaceId)
+    const cwd = workspace?.path ?? optionalRecordString(request, 'cwd') ?? '/tmp/fixture'
+    const summary: SessionSummary = {
+      sessionId,
+      updatedAt: Date.now(),
+      running: false,
+      blank: true,
+      cwd,
+      projections: structuredClone(blankSessionProjections),
+    }
+    sessions.push(summary)
+    records.set(sessionId, [])
+    nextTurns.set(sessionId, 0)
+    if (workspace !== undefined && !workspace.sessionIds.includes(sessionId)) {
+      workspace.sessionIds = [sessionId, ...workspace.sessionIds]
+      workspace.updatedAt = new Date().toISOString()
+      mock.streams.push('workspace/follow', {
+        type: 'upsert',
+        workspace: structuredClone(workspace),
+      })
+    }
+    mock.streams.push('$events', { type: 'emit', event: 'api-session/added', args: [structuredClone(summary)] })
+    return ok({ sessionId })
+  })
+  mock.unary('session/attachment', (request: unknown) => {
+    request = recordValue(request, 'request')
+    const attachmentId = recordString(request, 'attachmentId')
+    return attachments.get(attachmentId) ?? {
+      ok: false,
+      error: {
+        code: 'session/attachment-invalid',
+        message: `assembled fixture attachment ${attachmentId} is missing`,
+        details: { reason: 'ATTACHMENT_NOT_FOUND' },
+      },
+    }
+  })
+  mock.unary('session/prompt', (request: unknown) => {
+    request = recordValue(request, 'request')
+    if (options.rejectPrompt === true) {
+      return {
+        ok: false,
+        error: {
+          code: 'session/attachment-invalid',
+          message: 'assembled fixture: image side exceeds the deployment limit',
+          details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' },
+        },
+      }
+    }
+    const sessionId = recordString(request, 'sessionId')
+    const requestId = recordString(request, 'requestId')
+    const sessionRecords = records.get(sessionId) ?? []
+    const summary = sessions.find(candidate => candidate.sessionId === sessionId)
+    if (summary === undefined) throw new Error(`assembled fixture: no Session ${sessionId}`)
+    const content = recordArray(request, 'content').map((part) => {
+      if (!isRecord(part) || part.type !== 'image') return part
+      const attachmentId = `assembled:${randomUUID()}`
+      const data = recordString(part, 'data')
+      const attachment = {
+        attachmentId,
+        mediaType: recordString(part, 'mediaType'),
+        bytes: Math.max(1, Math.floor(data.length * 3 / 4)),
+        width: 160,
+        height: 90,
+        ...optionalRecordString(part, 'name') === undefined
+          ? {}
+          : { name: optionalRecordString(part, 'name') },
+      }
+      attachments.set(attachmentId, ok({ attachment, data }))
+      return { type: 'image', attachment }
+    })
+    const turn = nextTurns.get(sessionId) ?? 0
+    nextTurns.set(sessionId, turn + 1)
+    summary.updatedAt = Date.now()
+    summary.blank = false
+    if (!summary.running) {
+      summary.running = true
+      mock.streams.push('$events', { type: 'emit', event: 'api-session/status', args: [sessionId, true] })
+    }
+    const turnEvent = eventOf(sessionRecords.length, 'turn/start', { turn })
+    const userEvent = eventOf(sessionRecords.length + 1, 'user/message', {
+      content,
+      source: { kind: 'user', rpcId: requestId },
+      role: 'user',
+      id: randomUUID(),
+    }, 'append')
+    sessionRecords.push(turnEvent, userEvent)
+    records.set(sessionId, sessionRecords)
+    mock.streams.push('session/follow', turnEvent, follows(sessionId))
+    mock.streams.push('session/follow', userEvent, follows(sessionId))
+    return ok({ accepted: true })
+  })
+  mock.unary('commands/execute', (args: unknown) => {
+    const line = optionalRecordString(args, 'line') ?? ''
+    const name = /^\/(\S+)/u.exec(line.trim())?.[1]
+    if (name === undefined || !['compact', 'echo', 'goal', 'permission', 'plan'].includes(name)) {
+      return ok(undefined)
+    }
+    return ok({
+      commandId: `assembled-command-${randomUUID()}`,
+      result: {
+        kind: 'success',
+        ...name === 'echo' ? { text: line.replace(/^\/echo\s*/u, '') } : {},
+      },
+    })
+  })
+
+  return { mock }
+}
+
+function eventOf(
+  seq: number,
+  type: string,
+  data: unknown,
+  surfaceOp?: string,
+): EventRecord {
+  return {
+    type: 'event',
+    event: {
+      seq,
+      time: Date.now(),
+      type,
+      data,
+      ...(surfaceOp === undefined ? {} : { surfaceOp }),
+    },
+  }
+}
+
+function follows(sessionId: string): (args: readonly unknown[]) => boolean {
+  return ([args]) => followedSessionId(recordValue(args, 'request')) === sessionId
+}
+
+function followedSessionId(value: unknown): string {
+  if (!isRecord(value) || !isRecord(value.address)) throw new TypeError('assembled fixture follow request is invalid')
+  return value.address.kind === 'session'
+    ? recordString(value.address, 'sessionId')
+    : recordString(value.address, 'childSessionId')
+}
+
+function isAssistantStreamRequested(value: unknown): boolean {
+  return isRecord(value) && value.assistantStream === true
+}
+
+function recordString(value: unknown, key: string): string {
+  const selected = isRecord(value) ? value[key] : undefined
+  if (typeof selected !== 'string') throw new TypeError(`assembled fixture ${key} must be a string`)
+  return selected
+}
+
+function optionalRecordString(value: unknown, key: string): string | undefined {
+  const selected = isRecord(value) ? value[key] : undefined
+  if (selected === undefined) return undefined
+  if (typeof selected !== 'string') throw new TypeError(`assembled fixture ${key} must be a string`)
+  return selected
+}
+
+function recordArray(value: unknown, key: string): readonly unknown[] {
+  const selected = isRecord(value) ? value[key] : undefined
+  if (!Array.isArray(selected)) throw new TypeError(`assembled fixture ${key} must be an array`)
+  return selected
+}
+
+function recordValue(value: unknown, key: string): unknown {
+  if (!isRecord(value) || !(key in value)) throw new TypeError(`assembled fixture ${key} is missing`)
+  return value[key]
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null
+}

+ 3 - 3
apps/web/tests/built-boot.expected.e2e.ts

@@ -4,7 +4,7 @@
 // reach a surface only the built bundles expose; this one asserts that the
 // reach a surface only the built bundles expose; this one asserts that the
 // graph assembles at all — staged activation across the immediately tier and
 // graph assembles at all — staged activation across the immediately tier and
 // the inject layers, per-plugin CSS injection, and a rendered journey reaching
 // the inject layers, per-plugin CSS injection, and a rendered journey reaching
-// chat content from the keyless fixture Connection RPC.
+// chat content from the keyless RemoteMock scenario.
 //
 //
 // Component behavior remains owned by per-package suites (SlotTestRuntime
 // Component behavior remains owned by per-package suites (SlotTestRuntime
 // benches over src). This smoke additionally pins the resident interaction
 // benches over src). This smoke additionally pins the resident interaction
@@ -92,7 +92,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
   expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull()
   expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull()
   within(waitingRow).getByText('Waiting for answer')
   within(waitingRow).getByText('Waiting for answer')
 
 
-  // Opening a session reaches chat content through the fixture transport.
+  // Opening a session reaches chat content through the RemoteMock transport.
   fireEvent.click(waitingTitle)
   fireEvent.click(waitingTitle)
   await waitFor(() => {
   await waitFor(() => {
     expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
     expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
@@ -162,7 +162,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
 })
 })
 
 
 it('boots without ui-chat and does not select another conversation view implicitly', async () => {
 it('boots without ui-chat and does not select another conversation view implicitly', async () => {
-  mountAssembledApp('?fixture', { exclude: ['@deepseek-ai/dsh-client-ui-chat'] })
+  mountAssembledApp({ exclude: ['@deepseek-ai/dsh-client-ui-chat'] })
 
 
   const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
   const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
   const boot = Reflect.get(window, '__DSH_BOOT__') as { entries: Array<{ id: string }> } | undefined
   const boot = Reflect.get(window, '__DSH_BOOT__') as { entries: Array<{ id: string }> } | undefined

+ 5 - 3
apps/web/tests/clickable-links-gallery.e2e.ts

@@ -45,6 +45,8 @@ const OVERLAY = fileURLToPath(new URL('./produced-files.overlay.yml', import.met
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 const SEED_ID = 'clickable-links-gallery-web-e2e'
 const SEED_ID = 'clickable-links-gallery-web-e2e'
 const DONE = 'LINK_GALLERY_DONE'
 const DONE = 'LINK_GALLERY_DONE'
+// Seeded events and browser time share a day independently of host timezones.
+const GALLERY_TIME = Date.UTC(2026, 0, 15, 12)
 
 
 const GUIDE_URL = 'https://docs.example.test/guide'
 const GUIDE_URL = 'https://docs.example.test/guide'
 const API_URL = 'https://docs.example.test/api'
 const API_URL = 'https://docs.example.test/api'
@@ -192,7 +194,6 @@ const CALLS: GalleryCall[] = [
  */
  */
 function galleryFixture(imageUrl: string): string {
 function galleryFixture(imageUrl: string): string {
   const session = Session.create(SessionId('clickable-links-gallery-source'))
   const session = Session.create(SessionId('clickable-links-gallery-source'))
-  const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
   session.append('turn/start', { turn: 1 })
   session.append('turn/start', { turn: 1 })
   const user = session.append('user/message', createUserMessage({
   const user = session.append('user/message', createUserMessage({
     content: text('Assemble the link gallery: write the report and styles, inspect the sources, and summarize.'),
     content: text('Assemble the link gallery: write the report and styles, inspect the sources, and summarize.'),
@@ -289,7 +290,7 @@ function galleryFixture(imageUrl: string): string {
     }),
     }),
     ...session.snapshotEvents().map(event => JSON.stringify({
     ...session.snapshotEvents().map(event => JSON.stringify({
       ...event,
       ...event,
-      time: eventTimeOrigin + event.seq * 1_000,
+      time: GALLERY_TIME + event.seq * 1_000,
     })),
     })),
     '',
     '',
   ].join('\n')
   ].join('\n')
@@ -305,9 +306,10 @@ describe('web e2e: clickable links gallery', () => {
   beforeAll(async () => {
   beforeAll(async () => {
     scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
     scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
     imageUrl = new URL('/favicon.svg', scaffold.baseUrl).toString()
     imageUrl = new URL('/favicon.svg', scaffold.baseUrl).toString()
-    await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID)
+    await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID, undefined, { createdAt: GALLERY_TIME })
     browser = await chromium.launch()
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     page = await newEnglishPage(browser)
+    await page.clock.setFixedTime(GALLERY_TIME + 60_000)
     tripwire = watchConsole(page)
     tripwire = watchConsole(page)
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })

+ 1 - 1
apps/web/tests/command-image-envelope.expected.e2e.ts

@@ -1,6 +1,6 @@
 // @vitest-environment jsdom
 // @vitest-environment jsdom
 // The command attachment envelope over the BUILT client graph (real
 // The command attachment envelope over the BUILT client graph (real
-// bundles via AppWebEntry, keyless fixture Connection RPC): an enter
+// bundles via AppWebEntry, keyless RemoteMock transport): an enter
 // submission carrying composer attachments resolves only through a command whose
 // submission carrying composer attachments resolves only through a command whose
 // descriptor declares `input.attachments`. A non-declaring command refuses with
 // descriptor declares `input.attachments`. A non-declaring command refuses with
 // one composer error banner and everything retained; a declaring command
 // one composer error banner and everything retained; a declaring command

+ 3 - 0
apps/web/tests/details-session-lifecycle.e2e.ts

@@ -243,6 +243,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
 
 
     await select(original, 'LIGHTHOUSE')
     await select(original, 'LIGHTHOUSE')
     await open()
     await open()
+    await column.locator('[data-sidebar-right-guide-entry="files"]').click()
     // The content-box panel adds its one rendered border pixel outside the
     // The content-box panel adds its one rendered border pixel outside the
     // CSS width assigned by the grid solver.
     // CSS width assigned by the grid solver.
     await expect.poll(() => sidebarSnapshot(page), { timeout: 5_000 })
     await expect.poll(() => sidebarSnapshot(page), { timeout: 5_000 })
@@ -256,6 +257,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     await expect.poll(() => split.isDisabled()).toBe(false)
     await expect.poll(() => split.isDisabled()).toBe(false)
     await split.click()
     await split.click()
     await expect.poll(() => panes.count()).toBe(2)
     await expect.poll(() => panes.count()).toBe(2)
+    await panes.last().locator('[data-sidebar-right-guide-entry="files"]').click()
     await panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click()
     await panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click()
     await expect.poll(() => panes.first().locator('[data-files-state="tree"]').count()).toBe(1)
     await expect.poll(() => panes.first().locator('[data-files-state="tree"]').count()).toBe(1)
     const retainedA = await paneSnapshot(page)
     const retainedA = await paneSnapshot(page)
@@ -278,6 +280,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     await expect.poll(() => detailsTrack(page)).toBe(0)
     await expect.poll(() => detailsTrack(page)).toBe(0)
     await open()
     await open()
     expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push')
     expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push')
+    await column.locator('[data-sidebar-right-guide-entry="files"]').click()
     await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 })
     await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 })
     const workspaceDirectory = column.locator('[data-files-entry="directory"] > button').filter({ hasText: /^workspace$/ })
     const workspaceDirectory = column.locator('[data-files-entry="directory"] > button').filter({ hasText: /^workspace$/ })
     await workspaceDirectory.waitFor({ timeout: 15_000 })
     await workspaceDirectory.waitFor({ timeout: 15_000 })

+ 1 - 0
apps/web/tests/document-preview.e2e.ts

@@ -134,6 +134,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
 
 
     const column = page.locator('[data-rightbar-col]')
     const column = page.locator('[data-rightbar-col]')
     await page.locator('[data-sidebar-right-expand]').click()
     await page.locator('[data-sidebar-right-expand]').click()
+    await column.locator('[data-sidebar-right-guide-entry="files"]').click()
     await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
     await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
     await column.locator('[data-files-reload]').click()
     await column.locator('[data-files-reload]').click()
     const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
     const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })

+ 1 - 0
apps/web/tests/expected/sidebar-terminal/limit.expected.md

@@ -0,0 +1 @@
+- alert: "Terminal error: The terminal limit has been reached. Close unused terminals and try again. Exited terminals also count toward the limit."

+ 1 - 0
apps/web/tests/expected/sidebar-terminal/running.expected.md

@@ -0,0 +1 @@
+- textbox "Terminal"

+ 3 - 0
apps/web/tests/expected/sidebar-terminal/selection.expected.md

@@ -0,0 +1,3 @@
+- text: Shell
+- button "Shell": bash — /bin/bash
+- button "Start terminal"

+ 5 - 0
apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md

@@ -0,0 +1,5 @@
+- menu:
+  - menuitem "bash — /bin/bash":
+    - text: bash — /bin/bash
+    - img
+  - menuitem "sh — /bin/sh"

+ 5859 - 0
apps/web/tests/fixtures/assembled-remote.fixture.json

@@ -0,0 +1,5859 @@
+{
+  "sessionList": {
+    "ok": true,
+    "value": {
+      "items": [
+        {
+          "sessionId": "fx-alpha",
+          "updatedAt": 1789268875874,
+          "running": true,
+          "blank": false,
+          "cwd": "/tmp/fixture"
+        },
+        {
+          "sessionId": "fx-beta",
+          "updatedAt": 1789268815874,
+          "running": false,
+          "blank": false,
+          "parentSessionId": "fx-alpha",
+          "cwd": "/tmp/fixture"
+        },
+        {
+          "sessionId": "fx-gamma",
+          "updatedAt": 1789268755874,
+          "running": false,
+          "blank": false,
+          "cwd": "/tmp/fixture"
+        }
+      ]
+    }
+  },
+  "settingsDescribe": {
+    "ok": true,
+    "value": {
+      "writable": true,
+      "hasDocument": true,
+      "namespaces": [
+        {
+          "ns": "llm-deepseek",
+          "schema": {},
+          "value": {
+            "apiKeyEnv": "DEEPSEEK_API_KEY"
+          },
+          "applies": "live",
+          "secrets": [
+            {
+              "path": [
+                "apiKey"
+              ],
+              "set": false
+            }
+          ],
+          "revision": 0
+        }
+      ]
+    }
+  },
+  "credentialsDescribe": {
+    "ok": true,
+    "value": {
+      "DEEPSEEK_API_KEY": {
+        "configured": true,
+        "source": "file",
+        "writable": true
+      }
+    }
+  },
+  "modelCatalog": {
+    "ok": true,
+    "value": {
+      "default": {
+        "provider": "deepseek-official",
+        "model": "deepseek-v4-flash"
+      },
+      "routableProviders": [
+        "deepseek-official",
+        "openai",
+        "acme-gateway"
+      ],
+      "groups": [
+        {
+          "id": "deepseek-official",
+          "name": "DeepSeek",
+          "models": [
+            {
+              "id": "deepseek-v4-flash",
+              "name": "DeepSeek-V4-Flash",
+              "description": "快速响应",
+              "reasoning": {
+                "efforts": [
+                  {
+                    "id": "off",
+                    "name": "Off"
+                  },
+                  {
+                    "id": "high",
+                    "name": "High"
+                  },
+                  {
+                    "id": "max",
+                    "name": "Max"
+                  }
+                ],
+                "defaultEffort": "high"
+              }
+            },
+            {
+              "id": "deepseek-v4-pro",
+              "name": "DeepSeek-V4-Pro",
+              "description": "复杂任务",
+              "reasoning": {
+                "efforts": [
+                  {
+                    "id": "off",
+                    "name": "Off"
+                  },
+                  {
+                    "id": "high",
+                    "name": "High"
+                  },
+                  {
+                    "id": "max",
+                    "name": "Max"
+                  }
+                ],
+                "defaultEffort": "high"
+              }
+            }
+          ]
+        },
+        {
+          "id": "openai",
+          "name": "OpenAI",
+          "models": [
+            {
+              "id": "gpt-5",
+              "name": "GPT-5",
+              "reasoning": {
+                "efforts": [
+                  {
+                    "id": "off",
+                    "name": "Off"
+                  },
+                  {
+                    "id": "medium",
+                    "name": "Medium"
+                  },
+                  {
+                    "id": "high",
+                    "name": "High"
+                  },
+                  {
+                    "id": "max",
+                    "name": "Max"
+                  }
+                ],
+                "defaultEffort": "medium"
+              }
+            }
+          ]
+        }
+      ],
+      "failures": []
+    }
+  },
+  "agentPresets": {
+    "ok": true,
+    "value": {
+      "presets": [
+        {
+          "id": "standard",
+          "trust": "system",
+          "isDefault": true
+        },
+        {
+          "id": "minimal",
+          "trust": "system",
+          "isDefault": false
+        },
+        {
+          "id": "my-agent",
+          "trust": "user",
+          "isDefault": false
+        }
+      ],
+      "authorable": true,
+      "modeSelectionEnabled": true
+    }
+  },
+  "commands": {
+    "ok": true,
+    "value": [
+      {
+        "name": "compact",
+        "description": "fixture:压缩当前会话上下文"
+      },
+      {
+        "name": "echo",
+        "description": "fixture:回显参数",
+        "input": {
+          "hint": "text to echo"
+        }
+      },
+      {
+        "definitionId": "@deepseek-ai/dsh-command-goal",
+        "name": "goal",
+        "description": "Set or view the goal for a long-running task",
+        "input": {
+          "hint": "<objective>",
+          "attachments": true
+        }
+      },
+      {
+        "definitionId": "@deepseek-ai/dsh-permission-presets",
+        "name": "permission",
+        "description": "Switch the permission preset (sandbox mode + approval policy)",
+        "input": {
+          "hint": "<preset>"
+        }
+      },
+      {
+        "definitionId": "@deepseek-ai/dsh-plan-mode",
+        "name": "plan",
+        "description": "Enter or leave plan mode",
+        "input": {
+          "hint": "[off|message]",
+          "attachments": true
+        }
+      }
+    ]
+  },
+  "workspace": {
+    "type": "baseline",
+    "value": {
+      "items": [
+        {
+          "workspaceId": "fx-ws-fixture",
+          "path": "/tmp/fixture",
+          "title": "fixture",
+          "sessionIds": [
+            "fx-alpha",
+            "fx-beta",
+            "fx-gamma"
+          ],
+          "createdAt": "2026-09-13T03:02:55.884Z",
+          "updatedAt": "2026-09-13T03:02:55.884Z"
+        },
+        {
+          "workspaceId": "fx-ws-home",
+          "path": "/home/fixture/Documents/project",
+          "title": "project",
+          "sessionIds": [],
+          "createdAt": "2026-09-13T03:02:55.884Z",
+          "updatedAt": "2026-09-13T03:02:55.884Z"
+        }
+      ],
+      "archivedSessionIds": []
+    }
+  },
+  "control": {
+    "type": "baseline",
+    "value": {
+      "queues": {
+        "fx-alpha": [],
+        "fx-beta": [],
+        "fx-gamma": []
+      },
+      "jobs": {
+        "fx-alpha": [],
+        "fx-beta": [],
+        "fx-gamma": []
+      },
+      "approvals": [],
+      "questions": [],
+      "projections": {
+        "fx-alpha": {
+          "asOfSeq": 552,
+          "values": {
+            "modelSelection": {
+              "lastUsed": null,
+              "next": null
+            },
+            "title": "Fixture 历史会话",
+            "todos": [
+              {
+                "content": "梳理需求",
+                "status": "completed"
+              },
+              {
+                "content": "实现 fixture 样本",
+                "status": "in_progress"
+              },
+              {
+                "content": "跑后台构建",
+                "status": "in_progress"
+              },
+              {
+                "content": "浏览器验收",
+                "status": "pending"
+              }
+            ],
+            "permissions": {
+              "options": [
+                {
+                  "value": "workspace-write",
+                  "name": "workspace-write",
+                  "description": "Write inside the workspace and permitted temporary directories; wider retries require approval."
+                },
+                {
+                  "value": "danger-full-access",
+                  "name": "danger-full-access",
+                  "description": "Full file access without approval prompts."
+                }
+              ],
+              "currentValue": "workspace-write"
+            },
+            "plan": {
+              "active": false,
+              "pending": false
+            },
+            "goal": null,
+            "tokenUsage": {
+              "uncachedInputTokens": 1914,
+              "outputTokens": 708,
+              "cacheReadTokens": 6880,
+              "cacheWriteTokens": 32
+            },
+            "contextPressure": {
+              "pressureTokens": 104,
+              "contextWindow": 128000
+            },
+            "contextBreakdown": {
+              "systemTokens": 17,
+              "toolsTokens": 0,
+              "messageTokens": 4427
+            },
+            "sessionStats": {
+              "turns": 75,
+              "steps": 87,
+              "llmMs": 69600,
+              "toolMs": 24800,
+              "ttftMs": 0,
+              "ttftSteps": 0,
+              "decodeMs": 0,
+              "decodeTokens": 0
+            },
+            "imageLimits": {
+              "maxImageBytes": 5242880,
+              "maxImagesPerMessage": 20,
+              "maxMessageImageBytes": 104857600,
+              "maxImagePixels": 40000000,
+              "maxImageDimension": 2000,
+              "mediaTypes": [
+                "image/png",
+                "image/jpeg",
+                "image/webp",
+                "image/gif"
+              ]
+            }
+          }
+        },
+        "fx-beta": {
+          "asOfSeq": -1,
+          "values": {
+            "modelSelection": {
+              "lastUsed": null,
+              "next": null
+            },
+            "todos": null,
+            "permissions": {
+              "options": [
+                {
+                  "value": "workspace-write",
+                  "name": "workspace-write",
+                  "description": "Write inside the workspace and permitted temporary directories; wider retries require approval."
+                },
+                {
+                  "value": "danger-full-access",
+                  "name": "danger-full-access",
+                  "description": "Full file access without approval prompts."
+                }
+              ],
+              "currentValue": "workspace-write"
+            },
+            "plan": {
+              "active": false,
+              "pending": false
+            },
+            "goal": null,
+            "tokenUsage": {
+              "uncachedInputTokens": 0,
+              "outputTokens": 0,
+              "cacheReadTokens": 0,
+              "cacheWriteTokens": 0
+            },
+            "contextPressure": {},
+            "contextBreakdown": {
+              "systemTokens": 0,
+              "toolsTokens": 0,
+              "messageTokens": 0
+            },
+            "sessionStats": {
+              "turns": 0,
+              "steps": 0,
+              "llmMs": 0,
+              "toolMs": 0,
+              "ttftMs": 0,
+              "ttftSteps": 0,
+              "decodeMs": 0,
+              "decodeTokens": 0
+            },
+            "imageLimits": {
+              "maxImageBytes": 5242880,
+              "maxImagesPerMessage": 20,
+              "maxMessageImageBytes": 104857600,
+              "maxImagePixels": 40000000,
+              "maxImageDimension": 2000,
+              "mediaTypes": [
+                "image/png",
+                "image/jpeg",
+                "image/webp",
+                "image/gif"
+              ]
+            }
+          }
+        },
+        "fx-gamma": {
+          "asOfSeq": -1,
+          "values": {
+            "modelSelection": {
+              "lastUsed": null,
+              "next": null
+            },
+            "todos": null,
+            "permissions": {
+              "options": [
+                {
+                  "value": "workspace-write",
+                  "name": "workspace-write",
+                  "description": "Write inside the workspace and permitted temporary directories; wider retries require approval."
+                },
+                {
+                  "value": "danger-full-access",
+                  "name": "danger-full-access",
+                  "description": "Full file access without approval prompts."
+                }
+              ],
+              "currentValue": "workspace-write"
+            },
+            "plan": {
+              "active": false,
+              "pending": false
+            },
+            "goal": null,
+            "tokenUsage": {
+              "uncachedInputTokens": 0,
+              "outputTokens": 0,
+              "cacheReadTokens": 0,
+              "cacheWriteTokens": 0
+            },
+            "contextPressure": {},
+            "contextBreakdown": {
+              "systemTokens": 0,
+              "toolsTokens": 0,
+              "messageTokens": 0
+            },
+            "sessionStats": {
+              "turns": 0,
+              "steps": 0,
+              "llmMs": 0,
+              "toolMs": 0,
+              "ttftMs": 0,
+              "ttftSteps": 0,
+              "decodeMs": 0,
+              "decodeTokens": 0
+            },
+            "imageLimits": {
+              "maxImageBytes": 5242880,
+              "maxImagesPerMessage": 20,
+              "maxMessageImageBytes": 104857600,
+              "maxImagePixels": 40000000,
+              "maxImageDimension": 2000,
+              "mediaTypes": [
+                "image/png",
+                "image/jpeg",
+                "image/webp",
+                "image/gif"
+              ]
+            }
+          }
+        }
+      }
+    }
+  },
+  "remoteEvents": [
+    {
+      "type": "ready",
+      "clientId": "36b204ff-b72f-4720-b58a-c86bebca8c42",
+      "host": {
+        "home": "/home/fixture"
+      }
+    },
+    {
+      "type": "waterfall",
+      "event": "approval/request",
+      "eventId": "fx-interaction-approval",
+      "agentId": "fx-alpha",
+      "request": {
+        "toolName": "dangerous_tool",
+        "reason": "fixture 常驻审批(可答:批准/拒绝后消失)"
+      }
+    },
+    {
+      "type": "waterfall",
+      "event": "user-questions/request",
+      "eventId": "fx-interaction-question",
+      "agentId": "fx-alpha",
+      "request": {
+        "questions": [
+          {
+            "id": "harness-profile",
+            "header": "偏好",
+            "question": "你现在更想招哪类 Agent/Harness 候选人?",
+            "options": [
+              {
+                "label": "工程落地型 (Recommended)",
+                "description": "更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。"
+              },
+              {
+                "label": "研究潜力型",
+                "description": "更看重 Agent 理解、训练评测思路和长期成长空间。"
+              },
+              {
+                "label": "均衡型",
+                "description": "同时要求工程能力和 Agent 认知,但可能筛选门槛更高。"
+              }
+            ]
+          },
+          {
+            "id": "work-mode",
+            "header": "方式",
+            "question": "你希望候选人优先展示哪种工作方式?",
+            "options": [
+              {
+                "label": "先做小型原型 (Recommended)",
+                "description": "用可运行结果尽快验证关键假设。"
+              },
+              {
+                "label": "先写完整设计",
+                "description": "先收敛边界、协议和风险,再开始实现。"
+              }
+            ]
+          },
+          {
+            "id": "signals",
+            "header": "信号",
+            "question": "哪些面试信号最重要?",
+            "detail": "按当前招聘目标选择;跳过则视为不设偏好。",
+            "multiSelect": true,
+            "options": [
+              {
+                "label": "系统设计"
+              },
+              {
+                "label": "代码质量"
+              },
+              {
+                "label": "Agent 产品判断"
+              }
+            ]
+          }
+        ]
+      }
+    }
+  ],
+  "follow": {
+    "type": "snapshot",
+    "header": {
+      "version": 3,
+      "id": "fx-alpha",
+      "createdAt": 1789268875874,
+      "cwd": "/tmp/fixture",
+      "isSeeded": false
+    },
+    "cursor": 552,
+    "records": [
+      {
+        "type": "event",
+        "event": {
+          "seq": 365,
+          "time": 1789265568675,
+          "type": "turn/start",
+          "data": {
+            "turn": 51
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 366,
+          "time": 1789265569475,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 51:fixture 历史消息,用于翻页与渲染验收。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "781d1628-a401-4577-9608-fad94469115f"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 367,
+          "time": 1789265570275,
+          "type": "step/start",
+          "data": {
+            "turn": 51,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 368,
+          "time": 1789265571075,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 51,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "text",
+                  "text": "回答 51:这是 fixture 生成的历史回复正文。"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "41db22fd-d174-446f-8d4f-dd0769071b92"
+            },
+            "usage": {
+              "inputTokens": 21,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265571075,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "text"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265571075,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "text",
+                    "text": "回答 51:这是 fixture 生成的历史回复正文。"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265571075,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 21,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265571075,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 369,
+          "time": 1789265571875,
+          "type": "step/end",
+          "data": {
+            "turn": 51,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 370,
+          "time": 1789265572675,
+          "type": "turn/end",
+          "data": {
+            "turn": 51,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 371,
+          "time": 1789265573475,
+          "type": "turn/start",
+          "data": {
+            "turn": 52
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 372,
+          "time": 1789265574275,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 52:fixture 历史消息,用于翻页与渲染验收。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "ca2e9326-9086-477e-b80d-32005b85cbc6"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 373,
+          "time": 1789265575075,
+          "type": "step/start",
+          "data": {
+            "turn": 52,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 374,
+          "time": 1789265575875,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 52,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "reasoning",
+                  "text": "思考过程 52:这是一段可折叠的 reasoning 内容。"
+                },
+                {
+                  "type": "text",
+                  "text": "回答 52:这是 fixture 生成的历史回复正文。"
+                },
+                {
+                  "type": "tool-call",
+                  "id": "fx-call-52",
+                  "name": "echo",
+                  "arguments": "{\"text\":\"turn 52\"}"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "38c0ab81-0fc5-401d-a876-f0d1e766de03"
+            },
+            "usage": {
+              "inputTokens": 22,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265575875,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "reasoning"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265575875,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "reasoning",
+                    "text": "思考过程 52:这是一段可折叠的 reasoning 内容。"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265575875,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 1,
+                  "blockType": "text"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265575875,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 1,
+                  "block": {
+                    "type": "text",
+                    "text": "回答 52:这是 fixture 生成的历史回复正文。"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265575875,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 2,
+                  "blockType": "tool-call"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265575875,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 2,
+                  "block": {
+                    "type": "tool-call",
+                    "id": "fx-call-52",
+                    "name": "echo",
+                    "arguments": "{\"text\":\"turn 52\"}"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265575875,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 22,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265575875,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 375,
+          "time": 1789265576675,
+          "type": "tool/call",
+          "data": {
+            "turn": 52,
+            "step": 0,
+            "callId": "fx-call-52",
+            "name": "echo",
+            "arguments": "{\"text\":\"turn 52\"}"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 376,
+          "time": 1789265577475,
+          "type": "tool/result",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 52,
+            "step": 0,
+            "message": {
+              "source": {
+                "kind": "tool",
+                "callId": "fx-call-52"
+              },
+              "content": [
+                {
+                  "type": "tool-result",
+                  "toolCallId": "fx-call-52",
+                  "content": [
+                    {
+                      "type": "text",
+                      "text": "ECHO: TURN 52"
+                    }
+                  ],
+                  "isError": false
+                }
+              ],
+              "role": "user",
+              "id": "e9bbfe6c-96f3-44b6-b65c-89997783be1e"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 377,
+          "time": 1789265578275,
+          "type": "step/end",
+          "data": {
+            "turn": 52,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 378,
+          "time": 1789265579075,
+          "type": "step/start",
+          "data": {
+            "turn": 52,
+            "step": 1
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 379,
+          "time": 1789265579875,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 52,
+            "step": 1,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "text",
+                  "text": "工具结果已消化(turn 52)。"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "891ea3be-04ca-4adc-a1b5-70c311b5b0fd"
+            },
+            "usage": {
+              "inputTokens": 22,
+              "outputTokens": 9,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265579875,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "text"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265579875,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "text",
+                    "text": "工具结果已消化(turn 52)。"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265579875,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 22,
+                    "outputTokens": 9,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265579875,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 380,
+          "time": 1789265580675,
+          "type": "step/end",
+          "data": {
+            "turn": 52,
+            "step": 1
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 381,
+          "time": 1789265581475,
+          "type": "turn/end",
+          "data": {
+            "turn": 52,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 382,
+          "time": 1789265582275,
+          "type": "turn/start",
+          "data": {
+            "turn": 53
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 383,
+          "time": 1789265583075,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 53:fixture 历史消息,用于翻页与渲染验收。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "c26142ac-056b-47cb-bf77-6e554e565484"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 384,
+          "time": 1789265583875,
+          "type": "step/start",
+          "data": {
+            "turn": 53,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 385,
+          "time": 1789265584675,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 53,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "text",
+                  "text": "回答 53:这是 fixture 生成的历史回复正文。"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "cc41f0c0-2a74-4bc9-b5a2-f02f4c455569"
+            },
+            "usage": {
+              "inputTokens": 23,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265584675,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "text"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265584675,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "text",
+                    "text": "回答 53:这是 fixture 生成的历史回复正文。"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265584675,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 23,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265584675,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 386,
+          "time": 1789265585475,
+          "type": "step/end",
+          "data": {
+            "turn": 53,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 387,
+          "time": 1789265586275,
+          "type": "turn/end",
+          "data": {
+            "turn": 53,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 388,
+          "time": 1789265587075,
+          "type": "turn/start",
+          "data": {
+            "turn": 54
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 389,
+          "time": 1789265587875,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 54:fixture 历史消息,用于翻页与渲染验收。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "eadaefc3-d83e-471e-ad64-8df6ae40c1eb"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 390,
+          "time": 1789265588675,
+          "type": "step/start",
+          "data": {
+            "turn": 54,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 391,
+          "time": 1789265589475,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 54,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "text",
+                  "text": "回答 54:这是 fixture 生成的历史回复正文。"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "b8f5e883-ce1f-42ef-8106-dfa130620801"
+            },
+            "usage": {
+              "inputTokens": 24,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265589475,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "text"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265589475,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "text",
+                    "text": "回答 54:这是 fixture 生成的历史回复正文。"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265589475,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 24,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265589475,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 392,
+          "time": 1789265590275,
+          "type": "step/end",
+          "data": {
+            "turn": 54,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 393,
+          "time": 1789265591075,
+          "type": "turn/end",
+          "data": {
+            "turn": 54,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 394,
+          "time": 1789265591875,
+          "type": "turn/start",
+          "data": {
+            "turn": 55
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 395,
+          "time": 1789265592675,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 55:fixture 历史消息,用于翻页与渲染验收。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "116535cf-1160-4aff-bd8f-f89e4fc6c41f"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 396,
+          "time": 1789265593475,
+          "type": "step/start",
+          "data": {
+            "turn": 55,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 397,
+          "time": 1789265594275,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 55,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "reasoning",
+                  "text": "思考过程 55:这是一段可折叠的 reasoning 内容。"
+                },
+                {
+                  "type": "text",
+                  "text": "回答 55:这是 fixture 生成的历史回复正文。"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "6a80e9b4-ca99-40ef-8de8-facbc8c27b76"
+            },
+            "usage": {
+              "inputTokens": 20,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265594275,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "reasoning"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265594275,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "reasoning",
+                    "text": "思考过程 55:这是一段可折叠的 reasoning 内容。"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265594275,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 1,
+                  "blockType": "text"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265594275,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 1,
+                  "block": {
+                    "type": "text",
+                    "text": "回答 55:这是 fixture 生成的历史回复正文。"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265594275,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 20,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265594275,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 398,
+          "time": 1789265595075,
+          "type": "step/end",
+          "data": {
+            "turn": 55,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 399,
+          "time": 1789265595875,
+          "type": "turn/end",
+          "data": {
+            "turn": 55,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 400,
+          "time": 1789265596675,
+          "type": "turn/start",
+          "data": {
+            "turn": 56
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 401,
+          "time": 1789265597475,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 56:fixture 历史消息,用于翻页与渲染验收。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "093791f8-cb5b-4106-853d-306ed532feec"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 402,
+          "time": 1789265598275,
+          "type": "step/start",
+          "data": {
+            "turn": 56,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 403,
+          "time": 1789265599075,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 56,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "text",
+                  "text": "回答 56:这是 fixture 生成的历史回复正文。"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "bc0ebb05-d212-4f30-a3eb-c4c209ff7103"
+            },
+            "usage": {
+              "inputTokens": 21,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265599075,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "text"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265599075,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "text",
+                    "text": "回答 56:这是 fixture 生成的历史回复正文。"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265599075,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 21,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265599075,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 404,
+          "time": 1789265599875,
+          "type": "step/end",
+          "data": {
+            "turn": 56,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 405,
+          "time": 1789265600675,
+          "type": "turn/end",
+          "data": {
+            "turn": 56,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 406,
+          "time": 1789265601475,
+          "type": "turn/start",
+          "data": {
+            "turn": 57
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 407,
+          "time": 1789265602275,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 57:fixture 历史消息,用于翻页与渲染验收。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "4196af12-5f06-4244-a83e-281b0e2e2565"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 408,
+          "time": 1789265603075,
+          "type": "step/start",
+          "data": {
+            "turn": 57,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 409,
+          "time": 1789265603875,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 57,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "text",
+                  "text": "回答 57:这是 fixture 生成的历史回复正文。"
+                },
+                {
+                  "type": "tool-call",
+                  "id": "fx-call-57",
+                  "name": "echo",
+                  "arguments": "{\"text\":\"turn 57\"}"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "dc114329-e3f4-43a4-bbd7-3524203a99e9"
+            },
+            "usage": {
+              "inputTokens": 22,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265603875,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "text"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265603875,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "text",
+                    "text": "回答 57:这是 fixture 生成的历史回复正文。"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265603875,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 1,
+                  "blockType": "tool-call"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265603875,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 1,
+                  "block": {
+                    "type": "tool-call",
+                    "id": "fx-call-57",
+                    "name": "echo",
+                    "arguments": "{\"text\":\"turn 57\"}"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265603875,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 22,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265603875,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 410,
+          "time": 1789265604675,
+          "type": "tool/call",
+          "data": {
+            "turn": 57,
+            "step": 0,
+            "callId": "fx-call-57",
+            "name": "echo",
+            "arguments": "{\"text\":\"turn 57\"}"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 411,
+          "time": 1789265605475,
+          "type": "tool/result",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 57,
+            "step": 0,
+            "message": {
+              "source": {
+                "kind": "tool",
+                "callId": "fx-call-57"
+              },
+              "content": [
+                {
+                  "type": "tool-result",
+                  "toolCallId": "fx-call-57",
+                  "content": [
+                    {
+                      "type": "text",
+                      "text": "ECHO: TURN 57"
+                    }
+                  ],
+                  "isError": false
+                }
+              ],
+              "role": "user",
+              "id": "652931ce-6eb0-4400-8173-9735b82fdf8e"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 412,
+          "time": 1789265606275,
+          "type": "step/end",
+          "data": {
+            "turn": 57,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 413,
+          "time": 1789265607075,
+          "type": "step/start",
+          "data": {
+            "turn": 57,
+            "step": 1
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 414,
+          "time": 1789265607875,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 57,
+            "step": 1,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "text",
+                  "text": "工具结果已消化(turn 57)。"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "02030b82-e0d9-4e8f-a17c-bfeaa78a74d0"
+            },
+            "usage": {
+              "inputTokens": 22,
+              "outputTokens": 9,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265607875,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "text"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265607875,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "text",
+                    "text": "工具结果已消化(turn 57)。"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265607875,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 22,
+                    "outputTokens": 9,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265607875,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 415,
+          "time": 1789265608675,
+          "type": "step/end",
+          "data": {
+            "turn": 57,
+            "step": 1
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 416,
+          "time": 1789265609475,
+          "type": "turn/end",
+          "data": {
+            "turn": 57,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 417,
+          "time": 1789265610275,
+          "type": "turn/start",
+          "data": {
+            "turn": 58
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 418,
+          "time": 1789265611075,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 58:fixture 历史消息,用于翻页与渲染验收。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "d08478d2-30ac-49fa-b3ca-f71e8d4517ee"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 419,
+          "time": 1789265611875,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "[fixture] 上下文注入(turn 58)"
+              }
+            ],
+            "source": {
+              "kind": "plugin",
+              "plugin": "fixture"
+            },
+            "role": "user",
+            "id": "cbb94cfa-48f7-453b-9825-e61432572399"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 420,
+          "time": 1789265612675,
+          "type": "step/start",
+          "data": {
+            "turn": 58,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 421,
+          "time": 1789265613475,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 58,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "reasoning",
+                  "text": "思考过程 58:这是一段可折叠的 reasoning 内容。"
+                },
+                {
+                  "type": "text",
+                  "text": "回答 58:这是 fixture 生成的历史回复正文。"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "271e5020-8deb-418e-a4c2-d6609f31aa60"
+            },
+            "usage": {
+              "inputTokens": 23,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265613475,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "reasoning"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265613475,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "reasoning",
+                    "text": "思考过程 58:这是一段可折叠的 reasoning 内容。"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265613475,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 1,
+                  "blockType": "text"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265613475,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 1,
+                  "block": {
+                    "type": "text",
+                    "text": "回答 58:这是 fixture 生成的历史回复正文。"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265613475,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 23,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265613475,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 422,
+          "time": 1789265614275,
+          "type": "step/end",
+          "data": {
+            "turn": 58,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 423,
+          "time": 1789265615075,
+          "type": "turn/end",
+          "data": {
+            "turn": 58,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 424,
+          "time": 1789265615875,
+          "type": "turn/start",
+          "data": {
+            "turn": 59
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 425,
+          "time": 1789265616675,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "用户字面量:# 不渲染 `code` [link](https://example.com)"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "14861e74-05a9-4e2b-b98a-c907999daa42"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 426,
+          "time": 1789265617475,
+          "type": "step/start",
+          "data": {
+            "turn": 59,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 427,
+          "time": 1789265618275,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 59,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "text",
+                  "text": "# Markdown fixture\n\nAssistant output renders **strong text**, *emphasis*, and `inline code`.\n\n- first item\n  - nested item\n\n| Area | State |\n| --- | --- |\n| history | rendered |\n| streaming | stable |\n\n[DeepSeek](https://www.deepseek.com)\n\n```ts\nconst markdown = true\n```"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "07a55bdb-ff08-40b3-8922-0422a2f04a87"
+            },
+            "usage": {
+              "inputTokens": 24,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265618275,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "text"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265618275,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "text",
+                    "text": "# Markdown fixture\n\nAssistant output renders **strong text**, *emphasis*, and `inline code`.\n\n- first item\n  - nested item\n\n| Area | State |\n| --- | --- |\n| history | rendered |\n| streaming | stable |\n\n[DeepSeek](https://www.deepseek.com)\n\n```ts\nconst markdown = true\n```"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265618275,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 24,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265618275,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 428,
+          "time": 1789265619075,
+          "type": "step/end",
+          "data": {
+            "turn": 59,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 429,
+          "time": 1789265619875,
+          "type": "turn/end",
+          "data": {
+            "turn": 59,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 430,
+          "time": 1789265620675,
+          "type": "turn/start",
+          "data": {
+            "turn": 60
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 431,
+          "time": 1789265621475,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 60:bash 样本。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "de206a9a-a621-435f-88f0-9613ff152eb5"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 432,
+          "time": 1789265622275,
+          "type": "step/start",
+          "data": {
+            "turn": 60,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 433,
+          "time": 1789265623075,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 60,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "tool-call",
+                  "id": "fx-call-60",
+                  "name": "bash",
+                  "arguments": "{\"command\":\"ls -la\\necho done\",\"description\":\"fixture 终端样本\",\"workdir\":\"/tmp/fixture\"}"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "4d338006-4348-4002-8a03-c730802f6f51"
+            },
+            "usage": {
+              "inputTokens": 20,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 4
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265623075,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "tool-call"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265623075,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "tool-call",
+                    "id": "fx-call-60",
+                    "name": "bash",
+                    "arguments": "{\"command\":\"ls -la\\necho done\",\"description\":\"fixture 终端样本\",\"workdir\":\"/tmp/fixture\"}"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265623075,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 20,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 4
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265623075,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 434,
+          "time": 1789265623875,
+          "type": "tool/call",
+          "data": {
+            "turn": 60,
+            "step": 0,
+            "callId": "fx-call-60",
+            "name": "bash",
+            "arguments": "{\"command\":\"ls -la\\necho done\",\"description\":\"fixture 终端样本\",\"workdir\":\"/tmp/fixture\"}"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 435,
+          "time": 1789265624675,
+          "type": "tool/result",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 60,
+            "step": 0,
+            "message": {
+              "source": {
+                "kind": "tool",
+                "callId": "fx-call-60"
+              },
+              "content": [
+                {
+                  "type": "tool-result",
+                  "toolCallId": "fx-call-60",
+                  "content": [
+                    {
+                      "type": "text",
+                      "text": "total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt"
+                    }
+                  ],
+                  "isError": false
+                }
+              ],
+              "role": "user",
+              "id": "aa4c1898-7bec-4831-8ba1-0700f4ae897e"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 436,
+          "time": 1789265625475,
+          "type": "step/end",
+          "data": {
+            "turn": 60,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 437,
+          "time": 1789265626275,
+          "type": "turn/end",
+          "data": {
+            "turn": 60,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 438,
+          "time": 1789265627075,
+          "type": "turn/start",
+          "data": {
+            "turn": 61
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 439,
+          "time": 1789265627875,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 61:write 样本。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "6fd7a2c3-2ead-41a0-b954-21883b4bc03d"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 440,
+          "time": 1789265628675,
+          "type": "step/start",
+          "data": {
+            "turn": 61,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 441,
+          "time": 1789265629475,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 61,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "tool-call",
+                  "id": "fx-call-61",
+                  "name": "write",
+                  "arguments": "{\"file_path\":\"notes/demo.txt\",\"content\":\"hello fixture\\n\"}"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "a3c1b7d4-200f-49b7-b550-bbc5ad98dbc9"
+            },
+            "usage": {
+              "inputTokens": 21,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265629475,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "tool-call"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265629475,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "tool-call",
+                    "id": "fx-call-61",
+                    "name": "write",
+                    "arguments": "{\"file_path\":\"notes/demo.txt\",\"content\":\"hello fixture\\n\"}"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265629475,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 21,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265629475,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 442,
+          "time": 1789265630275,
+          "type": "tool/call",
+          "data": {
+            "turn": 61,
+            "step": 0,
+            "callId": "fx-call-61",
+            "name": "write",
+            "arguments": "{\"file_path\":\"notes/demo.txt\",\"content\":\"hello fixture\\n\"}"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 443,
+          "time": 1789265631075,
+          "type": "tool/result",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 61,
+            "step": 0,
+            "message": {
+              "source": {
+                "kind": "tool",
+                "callId": "fx-call-61"
+              },
+              "content": [
+                {
+                  "type": "tool-result",
+                  "toolCallId": "fx-call-61",
+                  "content": [
+                    {
+                      "type": "text",
+                      "text": "wrote notes/demo.txt"
+                    }
+                  ],
+                  "isError": false
+                }
+              ],
+              "role": "user",
+              "id": "4eedd943-773b-43d3-a104-e13550015670"
+            },
+            "meta": {
+              "diffs": [
+                {
+                  "path": "notes/demo.txt",
+                  "oldText": null,
+                  "newText": "hello fixture\n"
+                }
+              ]
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 444,
+          "time": 1789265631875,
+          "type": "step/end",
+          "data": {
+            "turn": 61,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 445,
+          "time": 1789265632675,
+          "type": "turn/end",
+          "data": {
+            "turn": 61,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 446,
+          "time": 1789265633475,
+          "type": "turn/start",
+          "data": {
+            "turn": 62
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 447,
+          "time": 1789265634275,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 62:edit 样本。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "8f4e6682-2963-4daa-a912-84ff053ac32e"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 448,
+          "time": 1789265635075,
+          "type": "step/start",
+          "data": {
+            "turn": 62,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 449,
+          "time": 1789265635875,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 62,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "tool-call",
+                  "id": "fx-call-62",
+                  "name": "edit",
+                  "arguments": "{\"file_path\":\"notes/demo.txt\",\"old_string\":\"hello\",\"new_string\":\"hello fixture\"}"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "f1b3b777-d4ec-4b33-80ac-fa4e9d87885a"
+            },
+            "usage": {
+              "inputTokens": 22,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265635875,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "tool-call"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265635875,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "tool-call",
+                    "id": "fx-call-62",
+                    "name": "edit",
+                    "arguments": "{\"file_path\":\"notes/demo.txt\",\"old_string\":\"hello\",\"new_string\":\"hello fixture\"}"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265635875,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 22,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265635875,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 450,
+          "time": 1789265636675,
+          "type": "tool/call",
+          "data": {
+            "turn": 62,
+            "step": 0,
+            "callId": "fx-call-62",
+            "name": "edit",
+            "arguments": "{\"file_path\":\"notes/demo.txt\",\"old_string\":\"hello\",\"new_string\":\"hello fixture\"}"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 451,
+          "time": 1789265637475,
+          "type": "tool/result",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 62,
+            "step": 0,
+            "message": {
+              "source": {
+                "kind": "tool",
+                "callId": "fx-call-62"
+              },
+              "content": [
+                {
+                  "type": "tool-result",
+                  "toolCallId": "fx-call-62",
+                  "content": [
+                    {
+                      "type": "text",
+                      "text": "已编辑"
+                    }
+                  ],
+                  "isError": false
+                }
+              ],
+              "role": "user",
+              "id": "37d72542-2cbc-4821-8897-329da97fe462"
+            },
+            "meta": {
+              "diffs": [
+                {
+                  "path": "notes/demo.txt",
+                  "oldText": "hello",
+                  "newText": "hello fixture"
+                }
+              ]
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 452,
+          "time": 1789265638275,
+          "type": "step/end",
+          "data": {
+            "turn": 62,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 453,
+          "time": 1789265639075,
+          "type": "turn/end",
+          "data": {
+            "turn": 62,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 454,
+          "time": 1789265639875,
+          "type": "turn/start",
+          "data": {
+            "turn": 63
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 455,
+          "time": 1789265640675,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 63:write 样本。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "8e09ed22-fb26-4b3a-8ef5-1e2ac363e0f8"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 456,
+          "time": 1789265641475,
+          "type": "step/start",
+          "data": {
+            "turn": 63,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 457,
+          "time": 1789265642275,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 63,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "tool-call",
+                  "id": "fx-call-63",
+                  "name": "write",
+                  "arguments": "{\"file_path\":\"notes/new-demo.txt\",\"content\":\"hello fixture\\n\"}"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "65f7aa11-c436-4b13-8715-63437a194d29"
+            },
+            "usage": {
+              "inputTokens": 23,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265642275,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "tool-call"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265642275,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "tool-call",
+                    "id": "fx-call-63",
+                    "name": "write",
+                    "arguments": "{\"file_path\":\"notes/new-demo.txt\",\"content\":\"hello fixture\\n\"}"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265642275,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 23,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265642275,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 458,
+          "time": 1789265643075,
+          "type": "tool/call",
+          "data": {
+            "turn": 63,
+            "step": 0,
+            "callId": "fx-call-63",
+            "name": "write",
+            "arguments": "{\"file_path\":\"notes/new-demo.txt\",\"content\":\"hello fixture\\n\"}"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 459,
+          "time": 1789265643875,
+          "type": "tool/result",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 63,
+            "step": 0,
+            "message": {
+              "source": {
+                "kind": "tool",
+                "callId": "fx-call-63"
+              },
+              "content": [
+                {
+                  "type": "tool-result",
+                  "toolCallId": "fx-call-63",
+                  "content": [
+                    {
+                      "type": "text",
+                      "text": "已写入"
+                    }
+                  ],
+                  "isError": false
+                }
+              ],
+              "role": "user",
+              "id": "0d0c76cf-f5b2-4f77-97cb-b0b66a67d135"
+            },
+            "meta": {
+              "diffs": [
+                {
+                  "path": "notes/new-demo.txt",
+                  "oldText": null,
+                  "newText": "hello fixture\n"
+                }
+              ]
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 460,
+          "time": 1789265644675,
+          "type": "step/end",
+          "data": {
+            "turn": 63,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 461,
+          "time": 1789265645475,
+          "type": "turn/end",
+          "data": {
+            "turn": 63,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 462,
+          "time": 1789265646275,
+          "type": "turn/start",
+          "data": {
+            "turn": 64
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 463,
+          "time": 1789265647075,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 64:edit 样本。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "99fcaad3-6e35-4e4f-a359-f97c673c72a2"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 464,
+          "time": 1789265647875,
+          "type": "step/start",
+          "data": {
+            "turn": 64,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 465,
+          "time": 1789265648675,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 64,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "tool-call",
+                  "id": "fx-call-64",
+                  "name": "edit",
+                  "arguments": "{\"file_path\":\"src/config.ts\",\"old_string\":\"const timeout = 30\",\"new_string\":\"const timeout = 60\"}"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "e0ae34f7-02f8-4093-a01b-e92d87fcbc3c"
+            },
+            "usage": {
+              "inputTokens": 24,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265648675,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "tool-call"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265648675,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "tool-call",
+                    "id": "fx-call-64",
+                    "name": "edit",
+                    "arguments": "{\"file_path\":\"src/config.ts\",\"old_string\":\"const timeout = 30\",\"new_string\":\"const timeout = 60\"}"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265648675,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 24,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265648675,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 466,
+          "time": 1789265649475,
+          "type": "tool/call",
+          "data": {
+            "turn": 64,
+            "step": 0,
+            "callId": "fx-call-64",
+            "name": "edit",
+            "arguments": "{\"file_path\":\"src/config.ts\",\"old_string\":\"const timeout = 30\",\"new_string\":\"const timeout = 60\"}"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 467,
+          "time": 1789265650275,
+          "type": "tool/result",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 64,
+            "step": 0,
+            "message": {
+              "source": {
+                "kind": "tool",
+                "callId": "fx-call-64"
+              },
+              "content": [
+                {
+                  "type": "tool-result",
+                  "toolCallId": "fx-call-64",
+                  "content": [
+                    {
+                      "type": "text",
+                      "text": "已编辑"
+                    }
+                  ],
+                  "isError": false
+                }
+              ],
+              "role": "user",
+              "id": "11a489c7-466c-45ba-a5de-228fecd455f2"
+            },
+            "meta": {
+              "diffs": [
+                {
+                  "path": "src/config.ts",
+                  "oldText": "const timeout = 30",
+                  "newText": "const timeout = 60"
+                },
+                {
+                  "path": "src/config.ts",
+                  "oldText": "retries: 1",
+                  "newText": "retries: 3"
+                }
+              ]
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 468,
+          "time": 1789265651075,
+          "type": "step/end",
+          "data": {
+            "turn": 64,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 469,
+          "time": 1789265651875,
+          "type": "turn/end",
+          "data": {
+            "turn": 64,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 470,
+          "time": 1789265652675,
+          "type": "turn/start",
+          "data": {
+            "turn": 65
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 471,
+          "time": 1789265653475,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 65:run_code 样本。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "350becdf-2087-439b-be5e-cb19abf1dfff"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 472,
+          "time": 1789265654275,
+          "type": "step/start",
+          "data": {
+            "turn": 65,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 473,
+          "time": 1789265655075,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 65,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "tool-call",
+                  "id": "fx-call-65",
+                  "name": "run_code",
+                  "arguments": "{\"code\":\"const listing = await tools.bash({ command: \\\"ls notes\\\", description: \\\"List notes\\\" })\\nconst demo = await tools.read({ file_path: \\\"notes/demo.txt\\\" })\\nawait tools.read({ file_path: \\\"notes/missing.txt\\\" }).catch(() => \\\"tolerated\\\")\\nreturn { listing, demo }\",\"description\":\"Read the notes files and summarize\"}"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "4af251a1-bd9f-4e7a-a7e8-9d4ac6f0f8e5"
+            },
+            "usage": {
+              "inputTokens": 20,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265655075,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "tool-call"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265655075,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "tool-call",
+                    "id": "fx-call-65",
+                    "name": "run_code",
+                    "arguments": "{\"code\":\"const listing = await tools.bash({ command: \\\"ls notes\\\", description: \\\"List notes\\\" })\\nconst demo = await tools.read({ file_path: \\\"notes/demo.txt\\\" })\\nawait tools.read({ file_path: \\\"notes/missing.txt\\\" }).catch(() => \\\"tolerated\\\")\\nreturn { listing, demo }\",\"description\":\"Read the notes files and summarize\"}"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265655075,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 20,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265655075,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 474,
+          "time": 1789265655875,
+          "type": "tool/call",
+          "data": {
+            "turn": 65,
+            "step": 0,
+            "callId": "fx-call-65",
+            "name": "run_code",
+            "arguments": "{\"code\":\"const listing = await tools.bash({ command: \\\"ls notes\\\", description: \\\"List notes\\\" })\\nconst demo = await tools.read({ file_path: \\\"notes/demo.txt\\\" })\\nawait tools.read({ file_path: \\\"notes/missing.txt\\\" }).catch(() => \\\"tolerated\\\")\\nreturn { listing, demo }\",\"description\":\"Read the notes files and summarize\"}"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 475,
+          "time": 1789265656675,
+          "type": "tool/ptc-dispatch-start",
+          "data": {
+            "rootCallId": "fx-call-65",
+            "parentCallId": "fx-call-65",
+            "subCallId": "fx-call-65:ptc:1",
+            "name": "bash",
+            "arguments": {
+              "command": "ls notes",
+              "description": "List notes"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 476,
+          "time": 1789265657475,
+          "type": "tool/ptc-dispatch",
+          "data": {
+            "rootCallId": "fx-call-65",
+            "parentCallId": "fx-call-65",
+            "subCallId": "fx-call-65:ptc:1",
+            "name": "bash",
+            "arguments": {
+              "command": "ls notes",
+              "description": "List notes"
+            },
+            "isError": false,
+            "content": [
+              {
+                "type": "text",
+                "text": "demo.txt\nnew-demo.txt"
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 477,
+          "time": 1789265658275,
+          "type": "tool/ptc-dispatch-start",
+          "data": {
+            "rootCallId": "fx-call-65",
+            "parentCallId": "fx-call-65",
+            "subCallId": "fx-call-65:ptc:2",
+            "name": "read",
+            "arguments": {
+              "file_path": "notes/demo.txt"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 478,
+          "time": 1789265659075,
+          "type": "tool/ptc-dispatch",
+          "data": {
+            "rootCallId": "fx-call-65",
+            "parentCallId": "fx-call-65",
+            "subCallId": "fx-call-65:ptc:2",
+            "name": "read",
+            "arguments": {
+              "file_path": "notes/demo.txt"
+            },
+            "isError": false,
+            "content": [
+              {
+                "type": "text",
+                "text": "hello fixture\n"
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 479,
+          "time": 1789265659875,
+          "type": "tool/ptc-dispatch-start",
+          "data": {
+            "rootCallId": "fx-call-65",
+            "parentCallId": "fx-call-65",
+            "subCallId": "fx-call-65:ptc:3",
+            "name": "read",
+            "arguments": {
+              "file_path": "notes/missing.txt"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 480,
+          "time": 1789265660675,
+          "type": "tool/ptc-dispatch",
+          "data": {
+            "rootCallId": "fx-call-65",
+            "parentCallId": "fx-call-65",
+            "subCallId": "fx-call-65:ptc:3",
+            "name": "read",
+            "arguments": {
+              "file_path": "notes/missing.txt"
+            },
+            "isError": true,
+            "content": [
+              {
+                "type": "text",
+                "text": "Error: ENOENT: notes/missing.txt not found"
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 481,
+          "time": 1789265661475,
+          "type": "tool/result",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 65,
+            "step": 0,
+            "message": {
+              "source": {
+                "kind": "tool",
+                "callId": "fx-call-65"
+              },
+              "content": [
+                {
+                  "type": "tool-result",
+                  "toolCallId": "fx-call-65",
+                  "content": [
+                    {
+                      "type": "text",
+                      "text": "{\"listing\":\"demo.txt\\nnew-demo.txt\",\"demo\":\"hello fixture\\n\"}"
+                    }
+                  ],
+                  "isError": false
+                }
+              ],
+              "role": "user",
+              "id": "ca689e1a-ebfb-4465-b187-43c3490abb9b"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 482,
+          "time": 1789265662275,
+          "type": "step/end",
+          "data": {
+            "turn": 65,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 483,
+          "time": 1789265663075,
+          "type": "turn/end",
+          "data": {
+            "turn": 65,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 484,
+          "time": 1789265663875,
+          "type": "turn/start",
+          "data": {
+            "turn": 66
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 485,
+          "time": 1789265664675,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 66:bash 样本。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "4a5bb1d5-3698-407a-bde1-5f8acd048eab"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 486,
+          "time": 1789265665475,
+          "type": "step/start",
+          "data": {
+            "turn": 66,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 487,
+          "time": 1789265666275,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 66,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "tool-call",
+                  "id": "fx-call-66",
+                  "name": "bash",
+                  "arguments": "{\"command\":\"pnpm run check\",\"description\":\"fixture 终端样本\",\"workdir\":\"/tmp/fixture/deep/nested\"}"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "68d3d448-1cac-44fd-8a6d-5446684ac98a"
+            },
+            "usage": {
+              "inputTokens": 21,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265666275,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "tool-call"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265666275,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "tool-call",
+                    "id": "fx-call-66",
+                    "name": "bash",
+                    "arguments": "{\"command\":\"pnpm run check\",\"description\":\"fixture 终端样本\",\"workdir\":\"/tmp/fixture/deep/nested\"}"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265666275,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 21,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265666275,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 488,
+          "time": 1789265667075,
+          "type": "tool/call",
+          "data": {
+            "turn": 66,
+            "step": 0,
+            "callId": "fx-call-66",
+            "name": "bash",
+            "arguments": "{\"command\":\"pnpm run check\",\"description\":\"fixture 终端样本\",\"workdir\":\"/tmp/fixture/deep/nested\"}"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 489,
+          "time": 1789265667875,
+          "type": "tool/result",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 66,
+            "step": 0,
+            "message": {
+              "source": {
+                "kind": "tool",
+                "callId": "fx-call-66"
+              },
+              "content": [
+                {
+                  "type": "tool-result",
+                  "toolCallId": "fx-call-66",
+                  "content": [
+                    {
+                      "type": "text",
+                      "text": "\u001b[1mRunning 4 checks\u001b[0m\n\u001b[32m✓\u001b[0m typecheck                                          1.82s\n\u001b[32m✓\u001b[0m lint                                               0.94s\n\u001b[32m✓\u001b[0m duplication                                        2.10s\n\u001b[31m✗\u001b[0m unit                                               8.41s\n\n\u001b[90mpackages/client/ui-primitives/tests/terminal-block.client.spec.tsx\u001b[0m\n  \u001b[31mFAIL\u001b[0m caps output at the configured line budget\n    expected 16 lines, received 24\n\nNAME                        LINES    BRANCHES    FUNCTIONS    UNCOVERED\nTerminalBlock.tsx           100%     100%        100%         -\nansi.ts                     100%     100%        100%         -\nclipboard.ts                100%     100%        100%         -\nCodeBlock.tsx               98.4%    96.2%       100%         41-43\nhighlight.ts                100%     100%        100%         -\nPill.tsx                    100%     100%        100%         -\nStateDot.tsx                100%     100%        100%         -\nmarkdown/Markdown.tsx       100%     100%        100%         -\n\n\u001b[31m1 of 4 checks failed\u001b[0m\n[exit code: 1]"
+                    }
+                  ],
+                  "isError": false
+                }
+              ],
+              "role": "user",
+              "id": "0da2afe6-5036-4d36-b9f3-ff562585507f"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 490,
+          "time": 1789265668675,
+          "type": "step/end",
+          "data": {
+            "turn": 66,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 491,
+          "time": 1789265669475,
+          "type": "turn/end",
+          "data": {
+            "turn": 66,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 492,
+          "time": 1789265670275,
+          "type": "turn/start",
+          "data": {
+            "turn": 67
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 493,
+          "time": 1789265671075,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 67:grep 样本。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "8dab4a18-bae1-492c-a43a-8337ae80abc3"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 494,
+          "time": 1789265671875,
+          "type": "step/start",
+          "data": {
+            "turn": 67,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 495,
+          "time": 1789265672675,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 67,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "tool-call",
+                  "id": "fx-call-67",
+                  "name": "grep",
+                  "arguments": "{\"pattern\":\"SEARCH_MAX_LINES\",\"path\":\"packages/client\"}"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "53469dfd-6af1-4eb2-9273-53ea475b6e94"
+            },
+            "usage": {
+              "inputTokens": 22,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265672675,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "tool-call"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265672675,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "tool-call",
+                    "id": "fx-call-67",
+                    "name": "grep",
+                    "arguments": "{\"pattern\":\"SEARCH_MAX_LINES\",\"path\":\"packages/client\"}"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265672675,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 22,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265672675,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 496,
+          "time": 1789265673475,
+          "type": "tool/call",
+          "data": {
+            "turn": 67,
+            "step": 0,
+            "callId": "fx-call-67",
+            "name": "grep",
+            "arguments": "{\"pattern\":\"SEARCH_MAX_LINES\",\"path\":\"packages/client\"}"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 497,
+          "time": 1789265674275,
+          "type": "tool/result",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 67,
+            "step": 0,
+            "message": {
+              "source": {
+                "kind": "tool",
+                "callId": "fx-call-67"
+              },
+              "content": [
+                {
+                  "type": "tool-result",
+                  "toolCallId": "fx-call-67",
+                  "content": [
+                    {
+                      "type": "text",
+                      "text": "Found 9 of 42 matches\n\npackages/client/ui-primitives/src/SearchBlock.tsx\nLine 16: export const DEFAULT_SEARCH_MAX_LINES = 16\nLine 138: export function SearchBlock(props: SearchBlockProps) {\nLine 141:   const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())\npackages/client/ui-tool/src/client/tool/models/search-card-model.ts\nLine 45: export const CHAT_SEARCH_MAX_LINES = 8\nLine 130: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {\npackages/client/ui-tool/src/client/tool/toolviews/search-row.tsx\nLine 34: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {\nLine 36:   const search = searchCardModel(block)\nLine 56:       search={search}\nLine 78:       yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)\n\n(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)"
+                    }
+                  ],
+                  "isError": false
+                }
+              ],
+              "role": "user",
+              "id": "9a3df388-356c-4440-b0bf-b09e0dda0387"
+            },
+            "meta": {
+              "shape": "matches",
+              "files": [
+                {
+                  "path": "packages/client/ui-primitives/src/SearchBlock.tsx",
+                  "matches": [
+                    {
+                      "lineNumber": 16,
+                      "line": "export const DEFAULT_SEARCH_MAX_LINES = 16"
+                    },
+                    {
+                      "lineNumber": 138,
+                      "line": "export function SearchBlock(props: SearchBlockProps) {"
+                    },
+                    {
+                      "lineNumber": 141,
+                      "line": "  const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())"
+                    }
+                  ]
+                },
+                {
+                  "path": "packages/client/ui-tool/src/client/tool/models/search-card-model.ts",
+                  "matches": [
+                    {
+                      "lineNumber": 45,
+                      "line": "export const CHAT_SEARCH_MAX_LINES = 8"
+                    },
+                    {
+                      "lineNumber": 130,
+                      "line": "export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {"
+                    }
+                  ]
+                },
+                {
+                  "path": "packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx",
+                  "matches": [
+                    {
+                      "lineNumber": 34,
+                      "line": "export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {"
+                    },
+                    {
+                      "lineNumber": 36,
+                      "line": "  const search = searchCardModel(block)"
+                    },
+                    {
+                      "lineNumber": 56,
+                      "line": "      search={search}"
+                    },
+                    {
+                      "lineNumber": 78,
+                      "line": "      yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)"
+                    }
+                  ]
+                }
+              ],
+              "truncated": true,
+              "total": 42
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 498,
+          "time": 1789265675075,
+          "type": "step/end",
+          "data": {
+            "turn": 67,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 499,
+          "time": 1789265675875,
+          "type": "turn/end",
+          "data": {
+            "turn": 67,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 500,
+          "time": 1789265676675,
+          "type": "turn/start",
+          "data": {
+            "turn": 68
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 501,
+          "time": 1789265677475,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 68:glob 样本。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "728f42ae-723b-485f-bf63-11b28892a89d"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 502,
+          "time": 1789265678275,
+          "type": "step/start",
+          "data": {
+            "turn": 68,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 503,
+          "time": 1789265679075,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 68,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "tool-call",
+                  "id": "fx-call-68",
+                  "name": "glob",
+                  "arguments": "{\"pattern\":\"**/SearchBlock*\",\"path\":\"packages/client\"}"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "b6c6950d-d67b-45eb-a718-1a4fdb241e05"
+            },
+            "usage": {
+              "inputTokens": 23,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265679075,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "tool-call"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265679075,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "tool-call",
+                    "id": "fx-call-68",
+                    "name": "glob",
+                    "arguments": "{\"pattern\":\"**/SearchBlock*\",\"path\":\"packages/client\"}"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265679075,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 23,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265679075,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 504,
+          "time": 1789265679875,
+          "type": "tool/call",
+          "data": {
+            "turn": 68,
+            "step": 0,
+            "callId": "fx-call-68",
+            "name": "glob",
+            "arguments": "{\"pattern\":\"**/SearchBlock*\",\"path\":\"packages/client\"}"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 505,
+          "time": 1789265680675,
+          "type": "tool/result",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 68,
+            "step": 0,
+            "message": {
+              "source": {
+                "kind": "tool",
+                "callId": "fx-call-68"
+              },
+              "content": [
+                {
+                  "type": "tool-result",
+                  "toolCallId": "fx-call-68",
+                  "content": [
+                    {
+                      "type": "text",
+                      "text": "packages/client/ui-primitives/src/SearchBlock.tsx\npackages/client/ui-primitives/src/SearchBlock.module.css\npackages/client/ui-tool/src/client/tool/models/search-card-model.ts\npackages/client/ui-tool/src/client/tool/toolviews/search-row.tsx\npackages/client/ui-tool/tests/search-card.client.spec.tsx\n\n(Showing 5 of 23 paths. Full sorted result stored at: fixture://spill/glob-67. Read it to see every path.)"
+                    }
+                  ],
+                  "isError": false
+                }
+              ],
+              "role": "user",
+              "id": "e6d88943-d180-472b-9c1b-d59a21a50ab9"
+            },
+            "meta": {
+              "shape": "paths",
+              "paths": [
+                "packages/client/ui-primitives/src/SearchBlock.tsx",
+                "packages/client/ui-primitives/src/SearchBlock.module.css",
+                "packages/client/ui-tool/src/client/tool/models/search-card-model.ts",
+                "packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx",
+                "packages/client/ui-tool/tests/search-card.client.spec.tsx"
+              ],
+              "truncated": true,
+              "total": 23
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 506,
+          "time": 1789265681475,
+          "type": "step/end",
+          "data": {
+            "turn": 68,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 507,
+          "time": 1789265682275,
+          "type": "turn/end",
+          "data": {
+            "turn": 68,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 508,
+          "time": 1789265683075,
+          "type": "turn/start",
+          "data": {
+            "turn": 69
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 509,
+          "time": 1789265683875,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 69:read 样本。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "f0564417-a09b-4e3a-8dd3-c0bd41604c23"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 510,
+          "time": 1789265684675,
+          "type": "step/start",
+          "data": {
+            "turn": 69,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 511,
+          "time": 1789265685475,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 69,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "tool-call",
+                  "id": "fx-call-69",
+                  "name": "read",
+                  "arguments": "{\"file_path\":\"packages/client/ui-primitives/src/ReadBlock.tsx\",\"offset\":41}"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "bfbff068-730d-4cf8-b647-6086e170221a"
+            },
+            "usage": {
+              "inputTokens": 24,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265685475,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "tool-call"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265685475,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "tool-call",
+                    "id": "fx-call-69",
+                    "name": "read",
+                    "arguments": "{\"file_path\":\"packages/client/ui-primitives/src/ReadBlock.tsx\",\"offset\":41}"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265685475,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 24,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265685475,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 512,
+          "time": 1789265686275,
+          "type": "tool/call",
+          "data": {
+            "turn": 69,
+            "step": 0,
+            "callId": "fx-call-69",
+            "name": "read",
+            "arguments": "{\"file_path\":\"packages/client/ui-primitives/src/ReadBlock.tsx\",\"offset\":41}"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 513,
+          "time": 1789265687075,
+          "type": "tool/result",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 69,
+            "step": 0,
+            "message": {
+              "source": {
+                "kind": "tool",
+                "callId": "fx-call-69"
+              },
+              "content": [
+                {
+                  "type": "tool-result",
+                  "toolCallId": "fx-call-69",
+                  "content": [
+                    {
+                      "type": "text",
+                      "text": "<path>packages/client/ui-primitives/src/ReadBlock.tsx</path>\n<type>file</type>\n<content>\n41: export interface ReadBlockProps {\n42:   label?: string | undefined\n43:   lines: readonly ReadBlockLine[]\n44:   totalLines: number\n45:   lang?: string | undefined\n46:   maxLines?: number | undefined\n47:   className?: string | undefined\n48: }\n49: \n50: // A windowed read keeps the file line numbers in the gutter.\n51: const marker = \"fixture read sample\"\n\n(Showing lines 41-51 of 180. Use offset=52 to continue.)\n</content>"
+                    }
+                  ],
+                  "isError": false
+                }
+              ],
+              "role": "user",
+              "id": "937c02dd-63d8-4106-9310-a9df2055f0e9"
+            },
+            "meta": {
+              "path": "packages/client/ui-primitives/src/ReadBlock.tsx",
+              "offset": 41,
+              "lines": [
+                {
+                  "number": 41,
+                  "text": "export interface ReadBlockProps {"
+                },
+                {
+                  "number": 42,
+                  "text": "  label?: string | undefined"
+                },
+                {
+                  "number": 43,
+                  "text": "  lines: readonly ReadBlockLine[]"
+                },
+                {
+                  "number": 44,
+                  "text": "  totalLines: number"
+                },
+                {
+                  "number": 45,
+                  "text": "  lang?: string | undefined"
+                },
+                {
+                  "number": 46,
+                  "text": "  maxLines?: number | undefined"
+                },
+                {
+                  "number": 47,
+                  "text": "  className?: string | undefined"
+                },
+                {
+                  "number": 48,
+                  "text": "}"
+                },
+                {
+                  "number": 49,
+                  "text": ""
+                },
+                {
+                  "number": 50,
+                  "text": "// A windowed read keeps the file line numbers in the gutter."
+                },
+                {
+                  "number": 51,
+                  "text": "const marker = \"fixture read sample\""
+                }
+              ],
+              "totalLines": 180,
+              "lang": "ts"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 514,
+          "time": 1789265687875,
+          "type": "step/end",
+          "data": {
+            "turn": 69,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 515,
+          "time": 1789265688675,
+          "type": "turn/end",
+          "data": {
+            "turn": 69,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 516,
+          "time": 1789265689475,
+          "type": "turn/start",
+          "data": {
+            "turn": 70
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 517,
+          "time": 1789265690275,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 70:web_search 样本。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "c7ba3fc7-e437-4d77-b212-e766b1ca1c47"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 518,
+          "time": 1789265691075,
+          "type": "step/start",
+          "data": {
+            "turn": 70,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 519,
+          "time": 1789265691875,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 70,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "tool-call",
+                  "id": "fx-call-70",
+                  "name": "web_search",
+                  "arguments": "{\"queries\":[\"deepseek harness architecture\"]}"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "70f9aaef-f006-4b53-986d-631458679ef6"
+            },
+            "usage": {
+              "inputTokens": 20,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 4
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265691875,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "tool-call"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265691875,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "tool-call",
+                    "id": "fx-call-70",
+                    "name": "web_search",
+                    "arguments": "{\"queries\":[\"deepseek harness architecture\"]}"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265691875,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 20,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 4
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265691875,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 520,
+          "time": 1789265692675,
+          "type": "tool/call",
+          "data": {
+            "turn": 70,
+            "step": 0,
+            "callId": "fx-call-70",
+            "name": "web_search",
+            "arguments": "{\"queries\":[\"deepseek harness architecture\"]}"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 521,
+          "time": 1789265693475,
+          "type": "tool/result",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 70,
+            "step": 0,
+            "message": {
+              "source": {
+                "kind": "tool",
+                "callId": "fx-call-70"
+              },
+              "content": [
+                {
+                  "type": "tool-result",
+                  "toolCallId": "fx-call-70",
+                  "content": [
+                    {
+                      "type": "text",
+                      "text": "Search results for deepseek harness architecture."
+                    }
+                  ],
+                  "isError": false
+                }
+              ],
+              "role": "user",
+              "id": "c2ddb04d-74c9-4a2f-83f6-47a48fc6fbc2"
+            },
+            "meta": {
+              "answer": "DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.",
+              "sources": [
+                {
+                  "url": "https://github.com/deepseek-ai/deepseek-harness",
+                  "title": "DeepSeek Harness — plugin-based agent harness",
+                  "snippet": "Everything is a plugin: session, tools, agent-loop, and LLM adapters all mount on the same Cordis context.",
+                  "publishedAt": "2026-07-01"
+                },
+                {
+                  "url": "https://www.deepseek.com/blog/harness-architecture",
+                  "snippet": "The capability-seam pattern splits each capability into interface, implementation, and consumer packages."
+                },
+                {
+                  "url": "https://docs.deepseek.com/harness/plugins",
+                  "title": "Writing a harness plugin",
+                  "publishedAt": "2026-06-15"
+                }
+              ],
+              "truncated": true
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 522,
+          "time": 1789265694275,
+          "type": "step/end",
+          "data": {
+            "turn": 70,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 523,
+          "time": 1789265695075,
+          "type": "turn/end",
+          "data": {
+            "turn": 70,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 524,
+          "time": 1789265695875,
+          "type": "turn/start",
+          "data": {
+            "turn": 71
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 525,
+          "time": 1789265696675,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 71:web_fetch 样本。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "b164bd50-e038-4ada-a92e-42b483efab9a"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 526,
+          "time": 1789265697475,
+          "type": "step/start",
+          "data": {
+            "turn": 71,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 527,
+          "time": 1789265698275,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 71,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "tool-call",
+                  "id": "fx-call-71",
+                  "name": "web_fetch",
+                  "arguments": "{\"url\":\"https://www.deepseek.com/blog/harness-architecture\"}"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "72a35941-d5a4-44ec-9b4d-f14303cf165b"
+            },
+            "usage": {
+              "inputTokens": 21,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265698275,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "tool-call"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265698275,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "tool-call",
+                    "id": "fx-call-71",
+                    "name": "web_fetch",
+                    "arguments": "{\"url\":\"https://www.deepseek.com/blog/harness-architecture\"}"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265698275,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 21,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265698275,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 528,
+          "time": 1789265699075,
+          "type": "tool/call",
+          "data": {
+            "turn": 71,
+            "step": 0,
+            "callId": "fx-call-71",
+            "name": "web_fetch",
+            "arguments": "{\"url\":\"https://www.deepseek.com/blog/harness-architecture\"}"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 529,
+          "time": 1789265699875,
+          "type": "tool/result",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 71,
+            "step": 0,
+            "message": {
+              "source": {
+                "kind": "tool",
+                "callId": "fx-call-71"
+              },
+              "content": [
+                {
+                  "type": "tool-result",
+                  "toolCallId": "fx-call-71",
+                  "content": [
+                    {
+                      "type": "text",
+                      "text": "# Harness architecture\n\nEverything is a plugin."
+                    }
+                  ],
+                  "isError": false
+                }
+              ],
+              "role": "user",
+              "id": "57cf5cd3-3daf-4e2c-bb16-f8695bc2ab8c"
+            },
+            "meta": {
+              "url": "https://www.deepseek.com/blog/harness-architecture",
+              "statusCode": 200,
+              "truncated": false
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 530,
+          "time": 1789265700675,
+          "type": "step/end",
+          "data": {
+            "turn": 71,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 531,
+          "time": 1789265701475,
+          "type": "turn/end",
+          "data": {
+            "turn": 71,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 532,
+          "time": 1789265702275,
+          "type": "turn/start",
+          "data": {
+            "turn": 72
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 533,
+          "time": 1789265703075,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 72:请完整列出全部一百条条目。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "35a4608a-bd98-46a2-af6a-233b28470390"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 534,
+          "time": 1789265703875,
+          "type": "step/start",
+          "data": {
+            "turn": 72,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 535,
+          "time": 1789265704675,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 72,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "text",
+                  "text": "条目 1:第一条。条目 2:第二条。条目 3:这一条写到一半被"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "006a595a-cd1e-4137-af5c-e36dc98f354e"
+            },
+            "usage": {
+              "inputTokens": 22,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265704675,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "text"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265704675,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "text",
+                    "text": "条目 1:第一条。条目 2:第二条。条目 3:这一条写到一半被"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265704675,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 22,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265704675,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 536,
+          "time": 1789265705475,
+          "type": "step/end",
+          "data": {
+            "turn": 72,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 537,
+          "time": 1789265706275,
+          "type": "turn/end",
+          "data": {
+            "turn": 72,
+            "reason": {
+              "kind": "max-tokens"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 538,
+          "time": 1789265707075,
+          "type": "turn/start",
+          "data": {
+            "turn": 73
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 539,
+          "time": 1789265707875,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "image",
+                "attachment": {
+                  "attachmentId": "fixture:image",
+                  "mediaType": "image/png",
+                  "bytes": 247,
+                  "width": 160,
+                  "height": 90,
+                  "name": "fixture-image.png"
+                }
+              },
+              {
+                "type": "text",
+                "text": "历史用户图片"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "f7aa4d0e-5b76-4ccd-a566-150a227b0c27"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 540,
+          "time": 1789265708675,
+          "type": "step/start",
+          "data": {
+            "turn": 73,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 541,
+          "time": 1789265709475,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 73,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "text",
+                  "text": "结构化模型图片:"
+                },
+                {
+                  "type": "image",
+                  "attachment": {
+                    "attachmentId": "fixture:image",
+                    "mediaType": "image/png",
+                    "bytes": 247,
+                    "width": 160,
+                    "height": 90,
+                    "name": "fixture-image.png"
+                  }
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-vision"
+              },
+              "id": "35080466-4027-4eba-95b8-538f1447867b"
+            },
+            "usage": {
+              "inputTokens": 23,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265709475,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "text"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265709475,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "text",
+                    "text": "结构化模型图片:"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265709475,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 1,
+                  "blockType": "image"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265709475,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 1,
+                  "block": {
+                    "type": "image",
+                    "attachment": {
+                      "attachmentId": "fixture:image",
+                      "mediaType": "image/png",
+                      "bytes": 247,
+                      "width": 160,
+                      "height": 90,
+                      "name": "fixture-image.png"
+                    }
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265709475,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 23,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265709475,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 542,
+          "time": 1789265710275,
+          "type": "step/end",
+          "data": {
+            "turn": 73,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 543,
+          "time": 1789265711075,
+          "type": "turn/end",
+          "data": {
+            "turn": 73,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 544,
+          "time": 1789265711875,
+          "type": "turn/start",
+          "data": {
+            "turn": 74
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 545,
+          "time": 1789265712675,
+          "type": "user/message",
+          "surfaceOp": "append",
+          "data": {
+            "content": [
+              {
+                "type": "text",
+                "text": "问题 74:todo_write 样本。"
+              }
+            ],
+            "source": {
+              "kind": "user"
+            },
+            "role": "user",
+            "id": "df61981f-bcff-4984-9d9e-de8754da7bc8"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 546,
+          "time": 1789265713475,
+          "type": "step/start",
+          "data": {
+            "turn": 74,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 547,
+          "time": 1789265714275,
+          "type": "assistant/message",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 74,
+            "step": 0,
+            "message": {
+              "role": "assistant",
+              "content": [
+                {
+                  "type": "tool-call",
+                  "id": "fx-call-74",
+                  "name": "todo_write",
+                  "arguments": "{\"todos\":[{\"content\":\"梳理需求\",\"status\":\"completed\"},{\"content\":\"实现 fixture 样本\",\"status\":\"in_progress\"},{\"content\":\"跑后台构建\",\"status\":\"in_progress\"},{\"content\":\"浏览器验收\",\"status\":\"pending\"}]}"
+                }
+              ],
+              "source": {
+                "kind": "model",
+                "provider": "fixture",
+                "model": "fx-1"
+              },
+              "id": "c346330a-d740-45f6-9cbe-fb7b20d9f741"
+            },
+            "usage": {
+              "inputTokens": 24,
+              "outputTokens": 8,
+              "cacheReadTokens": 80,
+              "cacheWriteTokens": 0
+            },
+            "stream": [
+              {
+                "type": "chunk",
+                "time": 1789265714275,
+                "chunk": {
+                  "type": "block-start",
+                  "index": 0,
+                  "blockType": "tool-call"
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265714275,
+                "chunk": {
+                  "type": "block-end",
+                  "index": 0,
+                  "block": {
+                    "type": "tool-call",
+                    "id": "fx-call-74",
+                    "name": "todo_write",
+                    "arguments": "{\"todos\":[{\"content\":\"梳理需求\",\"status\":\"completed\"},{\"content\":\"实现 fixture 样本\",\"status\":\"in_progress\"},{\"content\":\"跑后台构建\",\"status\":\"in_progress\"},{\"content\":\"浏览器验收\",\"status\":\"pending\"}]}"
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265714275,
+                "chunk": {
+                  "type": "usage",
+                  "usage": {
+                    "inputTokens": 24,
+                    "outputTokens": 8,
+                    "cacheReadTokens": 80,
+                    "cacheWriteTokens": 0
+                  }
+                }
+              },
+              {
+                "type": "chunk",
+                "time": 1789265714275,
+                "chunk": {
+                  "type": "finish",
+                  "reason": {
+                    "kind": "stop"
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 548,
+          "time": 1789265715075,
+          "type": "tool/call",
+          "data": {
+            "turn": 74,
+            "step": 0,
+            "callId": "fx-call-74",
+            "name": "todo_write",
+            "arguments": "{\"todos\":[{\"content\":\"梳理需求\",\"status\":\"completed\"},{\"content\":\"实现 fixture 样本\",\"status\":\"in_progress\"},{\"content\":\"跑后台构建\",\"status\":\"in_progress\"},{\"content\":\"浏览器验收\",\"status\":\"pending\"}]}"
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "type": "todo/write",
+          "time": 1789265715475,
+          "data": {
+            "todos": [
+              {
+                "content": "梳理需求",
+                "status": "completed"
+              },
+              {
+                "content": "实现 fixture 样本",
+                "status": "in_progress"
+              },
+              {
+                "content": "跑后台构建",
+                "status": "in_progress"
+              },
+              {
+                "content": "浏览器验收",
+                "status": "pending"
+              }
+            ]
+          },
+          "seq": 549
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 550,
+          "time": 1789265715875,
+          "type": "tool/result",
+          "surfaceOp": "append",
+          "data": {
+            "turn": 74,
+            "step": 0,
+            "message": {
+              "source": {
+                "kind": "tool",
+                "callId": "fx-call-74"
+              },
+              "content": [
+                {
+                  "type": "tool-result",
+                  "toolCallId": "fx-call-74",
+                  "content": [
+                    {
+                      "type": "text",
+                      "text": "Updated todo list: 1 pending, 2 in progress, 1 completed."
+                    }
+                  ],
+                  "isError": false
+                }
+              ],
+              "role": "user",
+              "id": "499968fd-a83f-45ad-80fe-b1d73651ff13"
+            }
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 551,
+          "time": 1789265716675,
+          "type": "step/end",
+          "data": {
+            "turn": 74,
+            "step": 0
+          }
+        }
+      },
+      {
+        "type": "event",
+        "event": {
+          "seq": 552,
+          "time": 1789265717475,
+          "type": "turn/end",
+          "data": {
+            "turn": 74,
+            "reason": {
+              "kind": "completed"
+            }
+          }
+        }
+      }
+    ],
+    "hasMore": true,
+    "projections": {
+      "asOfSeq": 552,
+      "values": {
+        "modelSelection": {
+          "lastUsed": null,
+          "next": null
+        },
+        "title": "Fixture 历史会话",
+        "todos": [
+          {
+            "content": "梳理需求",
+            "status": "completed"
+          },
+          {
+            "content": "实现 fixture 样本",
+            "status": "in_progress"
+          },
+          {
+            "content": "跑后台构建",
+            "status": "in_progress"
+          },
+          {
+            "content": "浏览器验收",
+            "status": "pending"
+          }
+        ],
+        "permissions": {
+          "options": [
+            {
+              "value": "workspace-write",
+              "name": "workspace-write",
+              "description": "Write inside the workspace and permitted temporary directories; wider retries require approval."
+            },
+            {
+              "value": "danger-full-access",
+              "name": "danger-full-access",
+              "description": "Full file access without approval prompts."
+            }
+          ],
+          "currentValue": "workspace-write"
+        },
+        "plan": {
+          "active": false,
+          "pending": false
+        },
+        "goal": null,
+        "tokenUsage": {
+          "uncachedInputTokens": 1914,
+          "outputTokens": 708,
+          "cacheReadTokens": 6880,
+          "cacheWriteTokens": 32
+        },
+        "contextPressure": {
+          "pressureTokens": 104,
+          "contextWindow": 128000
+        },
+        "contextBreakdown": {
+          "systemTokens": 17,
+          "toolsTokens": 0,
+          "messageTokens": 4427
+        },
+        "sessionStats": {
+          "turns": 75,
+          "steps": 87,
+          "llmMs": 69600,
+          "toolMs": 24800,
+          "ttftMs": 0,
+          "ttftSteps": 0,
+          "decodeMs": 0,
+          "decodeTokens": 0
+        },
+        "imageLimits": {
+          "maxImageBytes": 5242880,
+          "maxImagesPerMessage": 20,
+          "maxMessageImageBytes": 104857600,
+          "maxImagePixels": 40000000,
+          "maxImageDimension": 2000,
+          "mediaTypes": [
+            "image/png",
+            "image/jpeg",
+            "image/webp",
+            "image/gif"
+          ]
+        }
+      }
+    },
+    "assistantStream": {
+      "revision": 0
+    }
+  },
+  "attachment": {
+    "ok": true,
+    "value": {
+      "attachment": {
+        "attachmentId": "fixture:image",
+        "mediaType": "image/png",
+        "bytes": 247,
+        "width": 160,
+        "height": 90,
+        "name": "fixture-image.png"
+      },
+      "data": "iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg=="
+    }
+  }
+}

+ 8 - 0
apps/web/tests/fixtures/sidebar-terminal.patch.yml

@@ -0,0 +1,8 @@
+- id: terminal-controller
+  config:
+    maxTerminals: 2
+    shellCandidates: [/bin/bash, /bin/sh]
+    shell:
+      path: /bin/bash
+      name: bash
+      args: [--noprofile, --norc, -i]

+ 15 - 16
apps/web/tests/goal-bar.e2e.ts

@@ -1,18 +1,19 @@
-// Keyless assembled-browser coverage for the goal bar over the shipped Web
-// bundles and the fixture Connection RPC. The command creates a real projected
-// goal in the fixture session; the golden pins the active strip, while the
-// clear gesture proves the acknowledged tombstone leaves neither stale chrome
-// nor a duplicate-mutation error.
+// Keyless browser coverage for the goal bar over the shipped Web composition.
+// The command creates a real projected goal in a real Host session. The
+// goldens pin the active and disarmed strips, while the clear gesture proves
+// the acknowledged tombstone leaves neither stale chrome nor a
+// duplicate-mutation error.
 import { fileURLToPath } from 'node:url'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
 import { join } from 'node:path'
 import type { Browser, Page } from 'playwright'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type {} from '@deepseek-ai/dsh-goal'
 import {
 import {
   assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
   assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
   launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
-import { newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/goal-bar', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/goal-bar', import.meta.url))
 const ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'active.expected.md')
 const ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'active.expected.md')
@@ -27,14 +28,13 @@ describe('web e2e: goal bar clear convergence', () => {
   let tripwire: ReturnType<typeof watchConsole>
   let tripwire: ReturnType<typeof watchConsole>
 
 
   beforeAll(async () => {
   beforeAll(async () => {
-    scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, welcomeNoticePending: true })
+    scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
     browser = await chromium.launch()
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
     tripwire = watchConsole(page)
-    const login = await page.context().request.get(scaffold.authenticatedUrl, { maxRedirects: 0 })
-    expect(login.status()).toBe(303)
-    await page.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)
   }, 120_000)
 
 
   afterAll(async () => {
   afterAll(async () => {
@@ -44,8 +44,6 @@ describe('web e2e: goal bar clear convergence', () => {
 
 
   it('renders one active goal and clears it without exposing a stale error', async () => {
   it('renders one active goal and clears it without exposing a stale error', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear'))
-    // Startup reuses the fixture workspace's blank session, keeping this
-    // command independent of alpha's running replay and pending question.
     const input = page.locator('[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
     const input = page.locator('[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
     await input.waitFor({ timeout: 10_000 })
     await input.waitFor({ timeout: 10_000 })
     await input.fill('/goal guard rapid clear clicks')
     await input.fill('/goal guard rapid clear clicks')
@@ -53,15 +51,16 @@ describe('web e2e: goal bar clear convergence', () => {
 
 
     const bar = page.locator('[data-goal-bar]')
     const bar = page.locator('[data-goal-bar]')
     await bar.waitFor({ timeout: 10_000 })
     await bar.waitFor({ timeout: 10_000 })
-    await expect.poll(() => bar.getByRole('button', { name: 'Pause goal' }).count(), {
+    const pause = bar.getByRole('button', { name: 'Pause goal' })
+    await expect.poll(() => pause.count(), {
       timeout: 10_000,
       timeout: 10_000,
     }).toBe(1)
     }).toBe(1)
     const snapshot = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd)
     const snapshot = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(ACTIVE_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(ACTIVE_EXPECTED, snapshot, MODE)
 
 
-    await page.evaluate(() => {
-      (globalThis as unknown as { __fxTiming?: { disarmOnlyGoal(): void } }).__fxTiming?.disarmOnlyGoal()
-    })
+    const agents = scaffold.ctx.agents.list()
+    expect(agents).toHaveLength(1)
+    scaffold.ctx.goals.disarm(agents[0]!)
     await expect.poll(() => bar.getByRole('button', { name: 'Resume goal' }).count(), {
     await expect.poll(() => bar.getByRole('button', { name: 'Resume goal' }).count(), {
       timeout: 10_000,
       timeout: 10_000,
     }).toBe(1)
     }).toBe(1)

+ 3 - 4
apps/web/tests/goal-bar.overlay.yml

@@ -1,5 +1,4 @@
-# The client-side FixtureApiClient intentionally rejects settings traffic, so
-# this goal-only scenario omits the settings shell and the onboarding steps it
-# would mount. Onboarding owns separate assembled-browser coverage.
-- id: ui-settings-general
+# This scenario owns Goal bar actions, not autonomous Goal rounds. Keeping the
+# driver out prevents an active Goal from issuing an unrelated model request.
+- id: goal-round-driver
   disabled: true
   disabled: true

+ 3 - 3
apps/web/tests/image-display.expected.e2e.ts

@@ -1,6 +1,6 @@
 // @vitest-environment jsdom
 // @vitest-environment jsdom
 // Multimodal image surfaces over the BUILT client graph (the ptc-fixture
 // Multimodal image surfaces over the BUILT client graph (the ptc-fixture
-// idiom: real bundles via AppWebEntry, keyless fixture Connection RPC).
+// idiom: real bundles via AppWebEntry, keyless RemoteMock transport).
 // Opens the fixture history session whose turn 73 carries an image in BOTH a
 // Opens the fixture history session whose turn 73 carries an image in BOTH a
 // user message and an assistant message, and pins the product surfaces: the
 // user message and an assistant message, and pins the product surfaces: the
 // history ImageGallery loading real fixture bytes through the authorized
 // history ImageGallery loading real fixture bytes through the authorized
@@ -169,7 +169,7 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov
 
 
   // A file drag anywhere over the page raises the full-viewport overlay whose
   // A file drag anywhere over the page raises the full-viewport overlay whose
   // desc line carries the projected limits — copy that can only render after
   // desc line carries the projected limits — copy that can only render after
-  // the imageLimits projection crossed the real fixture transport.
+  // the imageLimits projection crossed the RemoteMock transport.
   const image = new File([new Uint8Array([137, 80, 78, 71])], 'dropped.png', { type: 'image/png' })
   const image = new File([new Uint8Array([137, 80, 78, 71])], 'dropped.png', { type: 'image/png' })
   const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
   const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
   fireEvent.dragEnter(document.body, { dataTransfer })
   fireEvent.dragEnter(document.body, { dataTransfer })
@@ -207,7 +207,7 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov
 })
 })
 
 
 it('renders a host dimension rejection with the projected 2000px limit', async () => {
 it('renders a host dimension rejection with the projected 2000px limit', async () => {
-  mountAssembledApp('?fixture&fixturePrompt=reject')
+  mountAssembledApp({ remote: { rejectPrompt: true } })
 
 
   const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
   const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
   const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
   const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')

+ 6 - 1
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -290,7 +290,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       try {
       try {
         await input.press('Enter')
         await input.press('Enter')
         if (MODE !== 'record') {
         if (MODE !== 'record') {
-          const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]')
+          const thinking = page.locator('[data-variant="think"][data-state="running"]')
+          const disclosure = thinking.getByRole('button')
+          await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
+          await disclosure.click()
+          await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
+          const liveTail = thinking.locator('[data-follow-end]')
           await expect.poll(async () => {
           await expect.poll(async () => {
             if (await liveTail.count() !== 1) return false
             if (await liveTail.count() !== 1) return false
             return await liveTail.evaluate((element) => {
             return await liveTail.evaluate((element) => {

+ 43 - 5
apps/web/tests/markdown-wide-table.e2e.ts

@@ -56,6 +56,7 @@ const TAIL_MARKER = 'MWT_TABLES_DONE'
 const FILL_MARKER = 'MWT_FILL_C1'
 const FILL_MARKER = 'MWT_FILL_C1'
 const WIDE_MARKER = 'MWT_WIDE_C01'
 const WIDE_MARKER = 'MWT_WIDE_C01'
 const LONG_CELL_MARKER = 'MWT_LONGCELL_F1'
 const LONG_CELL_MARKER = 'MWT_LONGCELL_F1'
+const SHORT_MARKER = 'MWT_SHORT_C1'
 const MARKERS = [FILL_MARKER, WIDE_MARKER, LONG_CELL_MARKER]
 const MARKERS = [FILL_MARKER, WIDE_MARKER, LONG_CELL_MARKER]
 /** Golden-facing names, in {@link MARKERS} order. */
 /** Golden-facing names, in {@link MARKERS} order. */
 const TABLE_NAMES = ['fill', 'wide', 'long-cell']
 const TABLE_NAMES = ['fill', 'wide', 'long-cell']
@@ -76,13 +77,20 @@ const SENTENCE = 'This cell carries one full sentence so the unwrapped table is
 const LONG_TOKEN = 'workspace/deepseek-harness/packages/client/ui-primitives/src/markdown/render.tsx/'.repeat(3)
 const LONG_TOKEN = 'workspace/deepseek-harness/packages/client/ui-primitives/src/markdown/render.tsx/'.repeat(3)
 const CJK_SENTENCE = '这个单元格包含一段较长的中文说明,用来验证长内容在窄列宽下按最小可读宽度换行而不是把列压缩到无法阅读。'
 const CJK_SENTENCE = '这个单元格包含一段较长的中文说明,用来验证长内容在窄列宽下按最小可读宽度换行而不是把列压缩到无法阅读。'
 
 
-/** The assistant markdown: one 3-column fill, one 12-column wide, one long-cell table. */
+/** The assistant markdown includes fitting and overflowing wide tables. */
 function tablesMarkdown(): string {
 function tablesMarkdown(): string {
   const wideHeader = [WIDE_MARKER, ...Array.from({ length: 11 }, (_, i) => `C${String(i + 2).padStart(2, '0')}`)]
   const wideHeader = [WIDE_MARKER, ...Array.from({ length: 11 }, (_, i) => `C${String(i + 2).padStart(2, '0')}`)]
   const wideRow = (row: number): string[] =>
   const wideRow = (row: number): string[] =>
     Array.from({ length: 12 }, (_, i) => `v${String(row)}${String(i + 1).padStart(2, '0')}`)
     Array.from({ length: 12 }, (_, i) => `v${String(row)}${String(i + 1).padStart(2, '0')}`)
   return [
   return [
-    'Three markdown tables exercise the wide-table layout rules.',
+    'Markdown tables exercise the wide-table layout rules.',
+    '',
+    `| ${SHORT_MARKER} | C2 | C3 | C4 |`,
+    '| --- | --- | --- | --- |',
+    '| 1 | 2 | 3 | 4 |',
+    '| 5 | 6 | 7 | 8 |',
+    '',
+    'The paragraph after the short table stays in place.',
     '',
     '',
     `| ${FILL_MARKER} | Current approach | Proposed approach |`,
     `| ${FILL_MARKER} | Current approach | Proposed approach |`,
     '| --- | --- | --- |',
     '| --- | --- | --- |',
@@ -103,7 +111,7 @@ function tablesMarkdown(): string {
   ].join('\n')
   ].join('\n')
 }
 }
 
 
-/** Build one closed, invariant-checked session fixture carrying the three tables. */
+/** Build one closed, invariant-checked session fixture carrying the tables. */
 function wideTableFixture(): string {
 function wideTableFixture(): string {
   const session = Session.create(SessionId('markdown-wide-table-source'))
   const session = Session.create(SessionId('markdown-wide-table-source'))
   const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
   const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
@@ -257,7 +265,8 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro
   beforeAll(async () => {
   beforeAll(async () => {
     scaffold = await launchWebScaffold({})
     scaffold = await launchWebScaffold({})
     await seedSession(scaffold, wideTableFixture(), SEED_ID)
     await seedSession(scaffold, wideTableFixture(), SEED_ID)
-    browser = await chromium.launch()
+    // The geometry assertions include the space occupied by native scrollbars.
+    browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
     page = await newEnglishPage(browser)
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
     tripwire = watchConsole(page)
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
@@ -386,13 +395,42 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro
     // Resting hidden overflow keeps the scroll position reachable and intact.
     // Resting hidden overflow keeps the scroll position reachable and intact.
     expect(await wide.evaluate(element => element.scrollLeft)).toBeGreaterThanOrEqual(0)
     expect(await wide.evaluate(element => element.scrollLeft)).toBeGreaterThanOrEqual(0)
     await wide.hover()
     await wide.hover()
-    await expect.poll(overflowState, { timeout: 5_000 }).toBe('auto 0px')
+    await expect.poll(overflowState, { timeout: 5_000 }).toBe('scroll 0px')
     // Pointer leaves: the bar rests hidden again.
     // Pointer leaves: the bar rests hidden again.
     await page.mouse.move(4, 4)
     await page.mouse.move(4, 4)
     await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px')
     await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px')
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
   }, 120_000)
   }, 120_000)
 
 
+  it('keeps a fitting wide table and its following paragraph stationary during interaction', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-short-table-height'))
+    await settleAt(1680)
+    const short = page.locator('[class*="tableScroll"]', { hasText: SHORT_MARKER })
+    await short.evaluate((element) => { element.scrollIntoView({ block: 'center', behavior: 'instant' }) })
+    await page.mouse.move(4, 4)
+    await short.evaluate((element) => { element.blur() })
+    expect(await short.evaluate(element => element.classList.contains('md-table-wide'))).toBe(true)
+    expect(await short.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1)
+    const position = () => short.evaluate((element) => {
+      const following = element.nextElementSibling
+      if (following === null) throw new Error('short table has no following paragraph')
+      return {
+        height: element.getBoundingClientRect().height,
+        followingTop: following.getBoundingClientRect().top,
+      }
+    })
+    const resting = await position()
+    await short.hover()
+    await expect.poll(position).toEqual(resting)
+    await page.mouse.move(4, 4)
+    await short.focus()
+    expect(await short.evaluate(element => document.activeElement === element)).toBe(true)
+    await expect.poll(position).toEqual(resting)
+    await short.evaluate((element) => { element.blur() })
+    await expect.poll(position).toEqual(resting)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 120_000)
+
   it('keeps the fill/scroll relations under page zoom', async () => {
   it('keeps the fill/scroll relations under page zoom', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-zoom'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-zoom'))
     await sweep()
     await sweep()

+ 1 - 1
apps/web/tests/max-tokens-notice.expected.e2e.ts

@@ -1,7 +1,7 @@
 // @vitest-environment jsdom
 // @vitest-environment jsdom
 // Assembled max-tokens snapshot: boots the real built `packages/client/*/lib/
 // Assembled max-tokens snapshot: boots the real built `packages/client/*/lib/
 // client.js` bundles through AppWebEntry's ModuleLoader path against the
 // client.js` bundles through AppWebEntry's ModuleLoader path against the
-// keyless fixture Connection RPC, opens the fixture session, and pins the
+// keyless RemoteMock transport, opens the fixture session, and pins the
 // surface its max-tokens turn (72) reaches — the turn-end notice row that a
 // surface its max-tokens turn (72) reaches — the turn-end notice row that a
 // provider output-cap truncation must render instead of ending silently.
 // provider output-cap truncation must render instead of ending silently.
 //
 //

+ 22 - 3
apps/web/tests/navigation-panes.e2e.ts

@@ -24,6 +24,7 @@ import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './supp
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url))
 const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
 const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
+const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md')
 const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
 const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
 const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
 const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
@@ -97,7 +98,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
       const raw = await readFile(SEED, 'utf8')
       const raw = await readFile(SEED, 'utf8')
       expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the two drive prompts')
       expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the two drive prompts')
         .toEqual([PROMPT_TURN1, PROMPT_TURN2])
         .toEqual([PROMPT_TURN1, PROMPT_TURN2])
-      await seedSession(scaffold, raw, SEED_ID)
+      // The inspector's calendar date must not depend on the day the test runs.
+      await seedSession(scaffold, raw, SEED_ID, undefined, { createdAt: Date.UTC(2026, 0, 1) })
     }
     }
     browser = await chromium.launch()
     browser = await chromium.launch()
   }, 120_000)
   }, 120_000)
@@ -264,13 +266,30 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
     await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
     await page.getByRole('tab', { name: 'Result' }).click()
     await page.getByRole('tab', { name: 'Result' }).click()
     await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
     await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
-    expect(await page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').count()).toBe(0)
+    expect(await page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').count()).toBeGreaterThan(0)
     const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
     const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
       .split(SEED_ID).join('{{seededId}}')
       .split(SEED_ID).join('{{seededId}}')
     await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
     await details.getByRole('button', { name: 'Close details' }).click()
     await details.getByRole('button', { name: 'Close details' }).click()
   }, 60_000)
   }, 60_000)
 
 
+  it.skipIf(MODE === 'record')('restores Assistant timing from recorded history', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timing'))
+    await ensureSeedOpen(page)
+    await page.getByRole('tab', { name: 'Trajectory' }).click()
+    await page.getByRole('button', { name: 'Request #1', exact: true }).click()
+    const details = page.getByRole('complementary', { name: 'Event details' })
+    await details.getByRole('tab', { name: 'Timing', exact: true }).click()
+    const panel = details.getByRole('tabpanel', { name: 'Timing' })
+    for (const metric of ['TTFT', 'Generation', 'Throughput']) {
+      const value = panel.getByText(metric, { exact: true }).locator('..').locator('dd')
+      await expect.poll(() => value.textContent()).toMatch(/^\d/)
+    }
+    expect(await panel.getByText('First token unavailable', { exact: true }).count()).toBe(0)
+    const snapshot = await captureStableAria(page, '#trajectory-detail-panel', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(TIMING_EXPECTED, snapshot, MODE)
+  }, 60_000)
+
   it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => {
   it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export'))
     await ensureSeedOpen(page)
     await ensureSeedOpen(page)
@@ -504,7 +523,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
   it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
   it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
     await assertFixtureInventory(SNAPSHOT_DIR, [
       'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
       'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
-      'terminal-card.expected.md',
+      'terminal-card.expected.md', 'timing.expected.md',
     ])
     ])
   })
   })
 })
 })

+ 44 - 1
apps/web/tests/ptc-round.e2e.ts

@@ -8,13 +8,15 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
 import {
-  acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts,
+  acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
 import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.v3.jsonl', import.meta.url))
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.v3.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url))
+const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/trajectory.expected.md', import.meta.url))
+const CODE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/code.expected.md', import.meta.url))
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 
 
 // Elicits the successful and failed sub-rows this scenario asserts.
 // Elicits the successful and failed sub-rows this scenario asserts.
@@ -151,6 +153,47 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
   })
   })
 
 
+  it.skipIf(MODE === 'record')('inspects recorded PTC source, wrapping, and original JSON in the trajectory', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-inspector'))
+    const call = sessionEvents.find(event => event.type === 'tool/call' && event.data.name === 'run_code')
+    if (call?.type !== 'tool/call') throw new Error('recorded PTC call missing')
+    const args = JSON.parse(call.data.arguments) as { code: string; description: string }
+    await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+    const row = page.locator('tr[data-kind="tool"]').filter({ hasText: 'run_code' }).first()
+    await row.click()
+    await page.getByRole('tab', { name: 'Code', exact: true }).waitFor()
+    expect(await page.getByRole('tabpanel').textContent()).toContain(args.description)
+    expect(await page.getByRole('tabpanel').locator('dl').first().locator('dt').allTextContents())
+      .toEqual(['Hierarchy', 'Status'])
+    const overview = await Promise.all([1, 2].map(index => captureStableAria(
+      page, `[role="tabpanel"] [class*="overviewSections"] > section:nth-child(${index})`, scaffold.workspaceCwd,
+    )))
+    await compareOrRefreshGolden(TRAJECTORY_EXPECTED, overview.join('\n'), MODE)
+
+    await page.getByRole('button', { name: 'Code', exact: true }).click()
+    const source = page.locator('[data-line-numbers] pre')
+    await source.waitFor()
+    expect(await source.textContent()).toBe(args.code.endsWith('\n') ? args.code.slice(0, -1) : args.code)
+    const wrap = page.getByRole('button', { name: 'Wrap lines', exact: true })
+    expect(await wrap.getAttribute('aria-pressed')).toBe('false')
+    const content = page.locator('[data-wrap]').filter({ has: source })
+    expect(await content.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true)
+    await wrap.click()
+    expect(await wrap.getAttribute('aria-pressed')).toBe('true')
+    await expect.poll(() => content.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1)
+    const code = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(CODE_EXPECTED, code, MODE)
+
+    await page.getByRole('button', { name: 'Original JSON' }).click()
+    const json = page.getByRole('tree', { name: 'parameters JSON' })
+    await json.waitFor()
+    expect(await json.textContent()).toContain(args.description)
+    await page.getByRole('button', { name: 'Original JSON' }).click()
+    expect(await source.textContent()).toBe(args.code.endsWith('\n') ? args.code.slice(0, -1) : args.code)
+    await page.getByRole('tab', { name: 'Summary', exact: true }).click()
+    expect(await wrap.getAttribute('aria-pressed')).toBe('true')
+  })
+
   it.skipIf(MODE === 'record')('stayed clean: no page errors, no reconnect churn', () => {
   it.skipIf(MODE === 'record')('stayed clean: no page errors, no reconnect churn', () => {
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])

+ 1 - 1
apps/web/tests/replay-round-trip.e2e.ts

@@ -213,7 +213,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
   it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => {
   it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think'))
     // Interaction over the REAL wire-delivered transcript (the fixture-client
     // Interaction over the REAL wire-delivered transcript (the fixture-client
-    // tier pins the same gesture against the fixture Connection RPC; this one runs on
+    // tier pins the same gesture against RemoteMock; this one runs on
     // follow-stream-fed state). Runs after the golden capture so the committed
     // follow-stream-fed state). Runs after the golden capture so the committed
     // aria surface stays the untouched settled state.
     // aria surface stays the untouched settled state.
     await expandTurnProcesses(page)
     await expandTurnProcesses(page)

+ 1 - 1
apps/web/tests/search-card.expected.e2e.ts

@@ -1,7 +1,7 @@
 // @vitest-environment jsdom
 // @vitest-environment jsdom
 // Assembled search-card snapshot: boots the real built workspace client bundles
 // Assembled search-card snapshot: boots the real built workspace client bundles
 // through AppWebEntry's ModuleLoader path against the keyless
 // through AppWebEntry's ModuleLoader path against the keyless
-// fixture Connection RPC (no API key, no model round), opens the fixture
+// RemoteMock transport (no API key, no model round), opens the fixture
 // session, and pins the search card the `grep` turn (fixture turn 67) renders in
 // session, and pins the search card the `grep` turn (fixture turn 67) renders in
 // the assembled application. The built-boot smoke proves the graph boots but
 // the assembled application. The built-boot smoke proves the graph boots but
 // intentionally carries no behavior assertions; this is the assembled-output check
 // intentionally carries no behavior assertions; this is the assembled-output check

+ 19 - 9
apps/web/tests/sidebar-right.e2e.ts

@@ -132,6 +132,7 @@ async function resetSidebar(page: Page): Promise<Locator> {
   const column = page.locator('[data-rightbar-col]')
   const column = page.locator('[data-rightbar-col]')
   await expandOf(page).waitFor({ timeout: 15_000 })
   await expandOf(page).waitFor({ timeout: 15_000 })
   await ensureExpanded(page, column)
   await ensureExpanded(page, column)
+  await column.locator('[data-sidebar-right-guide-entry="files"]').click()
   await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
   await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
   await width(column)
   await width(column)
   return column
   return column
@@ -390,6 +391,10 @@ describe('web e2e: shipped right Sidebar', () => {
         expect(await centreY(selector), selector).toBe(textLine)
         expect(await centreY(selector), selector).toBe(textLine)
       }
       }
 
 
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
+      await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2)
+      await column.locator('[data-sidebar-right-guide-entry="files"]').click()
+
       // A manual guide is closable beside Files and suppresses another add
       // A manual guide is closable beside Files and suppresses another add
       // control in its pane until it is closed.
       // control in its pane until it is closed.
       const addTab = column.locator('[data-dockkit-add-tab]')
       const addTab = column.locator('[data-dockkit-add-tab]')
@@ -761,10 +766,13 @@ describe('web e2e: shipped right Sidebar', () => {
       )
       )
       await expect.poll(async () => await panes.count()).toBe(2)
       await expect.poll(async () => await panes.count()).toBe(2)
 
 
-      const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
-      expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(1)
-      await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5))
+      const splitGuide = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
+      expect(await splitGuide.locator('[data-dockkit-tab-close]').count()).toBe(1)
+      await dragTo(page, splitGuide, await pointIn(panes.first(), 0.5, 0.5))
       await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME])
       await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME])
+      const movedGuide = panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
+      await movedGuide.hover()
+      await movedGuide.locator('[data-dockkit-tab-close]').click()
 
 
       // Neither pane holds a guide, so both offer an add control.
       // Neither pane holds a guide, so both offer an add control.
       const filePane = panes.filter({ has: page.locator('[data-dockkit-tab-title]', { hasText: SAMPLE_NAME }) })
       const filePane = panes.filter({ has: page.locator('[data-dockkit-tab-title]', { hasText: SAMPLE_NAME }) })
@@ -901,7 +909,7 @@ describe('web e2e: shipped right Sidebar', () => {
       expect(await panes.count()).toBe(2)
       expect(await panes.count()).toBe(2)
       expect(await splitButtons.count()).toBe(0)
       expect(await splitButtons.count()).toBe(0)
 
 
-      // 5. A manual guide and the document float while Files stays docked.
+      // 5. The split's guide and the document float while Files stays docked.
       const floatOne = panes.last().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
       const floatOne = panes.last().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
       await floatByDrag(page, floatOne)
       await floatByDrag(page, floatOne)
       await expect.poll(async () => await floats.count()).toBe(1)
       await expect.poll(async () => await floats.count()).toBe(1)
@@ -910,8 +918,7 @@ describe('web e2e: shipped right Sidebar', () => {
       await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 })
       await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 })
       await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x)
       await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x)
 
 
-      await panes.last().locator('[data-dockkit-add-tab]').click()
-      await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Files', 'Start'])
+      await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Start'])
       const second = panes.last().locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
       const second = panes.last().locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
       await floatByDrag(page, second)
       await floatByDrag(page, second)
       await expect.poll(async () => await floats.count()).toBe(2)
       await expect.poll(async () => await floats.count()).toBe(2)
@@ -966,7 +973,7 @@ describe('web e2e: shipped right Sidebar', () => {
       // Any other tab standing alone closes together with the column. Open the
       // Any other tab standing alone closes together with the column. Open the
       // sample file, close the guide (an ordinary close with two tabs), then
       // sample file, close the guide (an ordinary close with two tabs), then
       // close the file: the column collapses in the same gesture, and the
       // close the file: the column collapses in the same gesture, and the
-      // settle rule reseeds the current default, so reopening shows Files.
+      // settle rule reseeds the current default, so reopening shows Start.
       await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
       await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
       await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
       await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
       await column.locator('[data-dockkit-tab]').first().hover()
       await column.locator('[data-dockkit-tab]').first().hover()
@@ -976,8 +983,8 @@ describe('web e2e: shipped right Sidebar', () => {
       await column.locator('[data-dockkit-tab-close]').first().click()
       await column.locator('[data-dockkit-tab-close]').first().click()
       await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
       await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
       await expandOf(page).click()
       await expandOf(page).click()
-      await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
-      expect(await column.locator('[data-files-state="tree"]').count()).toBe(1)
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
+      expect(await column.locator('[data-sidebar-right-guide]').count()).toBe(1)
 
 
       expect(tripwire.pageErrors).toEqual([])
       expect(tripwire.pageErrors).toEqual([])
       expect(tripwire.warnings).toEqual([])
       expect(tripwire.warnings).toEqual([])
@@ -1003,6 +1010,7 @@ describe('web e2e: shipped right Sidebar', () => {
 
 
       await ensureExpanded(page, column)
       await ensureExpanded(page, column)
       await expect.poll(async () => await column.locator('[data-dockkit-tab]').count()).toBeGreaterThan(0)
       await expect.poll(async () => await column.locator('[data-dockkit-tab]').count()).toBeGreaterThan(0)
+      await column.locator('[data-sidebar-right-guide-entry="files"]').click()
       await column.locator('[data-dockkit-add-tab]').click()
       await column.locator('[data-dockkit-add-tab]').click()
       await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start'])
       await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start'])
       // No "more" control on the chip: the chip carries its close, and the menu
       // No "more" control on the chip: the chip carries its close, and the menu
@@ -1050,6 +1058,8 @@ describe('web e2e: shipped right Sidebar', () => {
         const column = zhPage.locator('[data-rightbar-col]')
         const column = zhPage.locator('[data-rightbar-col]')
         await expandOf(zhPage).waitFor({ timeout: 20_000 })
         await expandOf(zhPage).waitFor({ timeout: 20_000 })
         await expandOf(zhPage).click()
         await expandOf(zhPage).click()
+        await expect.poll(async () => await tabTitles(column)).toEqual(['开始'])
+        await column.locator('[data-sidebar-right-guide-entry="files"]').click()
         await expect.poll(async () => await tabTitles(column)).toEqual(['文件'])
         await expect.poll(async () => await tabTitles(column)).toEqual(['文件'])
         await column.locator('[data-dockkit-add-tab]').click()
         await column.locator('[data-dockkit-add-tab]').click()
 
 

+ 227 - 0
apps/web/tests/sidebar-terminal.e2e.ts

@@ -0,0 +1,227 @@
+/** Shipped sidebar terminal over the real Loader, Remote mux, Chromium and local PTY. */
+import { mkdir } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterEach, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest'
+import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
+import type {} from '@deepseek-ai/dsh-api-terminal-controller'
+import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
+import { createProcessInspector, type ProcessIdentity } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
+import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const expected = fileURLToPath(new URL('./expected/sidebar-terminal/running.expected.md', import.meta.url))
+const shots = fileURLToPath(new URL('../../../.artifacts/screenshots/sidebar-terminal/', import.meta.url))
+
+async function openTerminal(page: Page, waitForShell = true): Promise<void> {
+  const expand = page.locator('[data-sidebar-right-expand]')
+  if (await expand.isVisible()) await expand.click()
+  const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
+  if (!await entry.isVisible()) await page.locator('[data-dockkit-add-tab]').click()
+  await entry.click()
+  await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
+  if (waitForShell) await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-')
+}
+
+async function command(page: Page, text: string): Promise<void> {
+  await page.locator('.xterm-helper-textarea:visible').click()
+  await page.keyboard.insertText(text)
+  await page.keyboard.press('Enter')
+}
+
+describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  let handles: SubprocessTerminalHandle[]
+  const inspector = createProcessInspector()
+  const alive = (identity: ProcessIdentity) => inspector.isAlive(identity)
+  const processIdentity = (index: number): ProcessIdentity => {
+    const pid = handles[index]!.pid
+    const identity = inspector.snapshot().tree(pid).find(member => member.pid === pid)
+    if (identity === undefined) throw new Error(`Terminal process ${pid} is missing`)
+    return identity
+  }
+
+  beforeEach(async () => {
+    scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
+    const agent = scaffold.ctx.agents.list()[0]
+    if (agent === undefined) throw new Error('Workspace did not create a Session')
+    handles = []
+    const subprocess = agent.ctx.get('subprocess')
+    if (subprocess === undefined) throw new Error('Session subprocess provider is missing')
+    const spawn = subprocess.spawnTerminal.bind(subprocess)
+    vi.spyOn(subprocess, 'spawnTerminal').mockImplementation(async (spec) => {
+      const handle = await spawn(spec)
+      handles.push(handle)
+      return handle
+    })
+    agent.session.append('turn/start', { turn: 1 })
+    agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Open a terminal.' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
+    agent.session.append('step/start', { turn: 1, step: 1 })
+    agent.session.append('assistant/message', { stream: [], turn: 1, step: 1, message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'Ready for terminal input.' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } }) }, { surfaceOp: 'append' })
+    agent.session.append('step/end', { turn: 1, step: 1 })
+    agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    await scaffold.ctx.sessions.flush(agent.session)
+    await page.getByText('Ready for terminal input.').waitFor()
+    await mkdir(shots, { recursive: true })
+  }, 180_000)
+
+  afterEach(async () => {
+    try { await browser?.close() } finally {
+      try { await scaffold?.close() } finally { vi.restoreAllMocks() }
+    }
+  })
+
+  it('completes commands, preserves the process through collapse and reload, resizes, and kills on tab close', async () => {
+    onTestFailed(() => saveFailureShot(page, 'sidebar-terminal'))
+    await openTerminal(page)
+    const terminal = page.locator('[data-sidebar-terminal]')
+    await command(page, "PS1=''; printf '\\033cTERMINAL_READY\\n'")
+    const screen = page.locator('.xterm-rows:visible')
+    await expect.poll(async () => await screen.innerText()).toContain('TERMINAL_READY')
+    const aria = await terminal.ariaSnapshot()
+    await compareOrRefreshGolden(expected, aria, webSnapshotMode())
+    await command(page, "printf 'DSH_PID:%s\\n' \"$$\"")
+    await expect.poll(async () => await screen.innerText()).toMatch(/DSH_PID:\d+/u)
+    const pid = Number((await screen.innerText()).match(/DSH_PID:(\d+)/u)?.[1])
+    const firstProcess = processIdentity(0)
+    expect(alive(firstProcess)).toBe(true)
+    await command(page, 'dsh_terminal_completion_probe(){ printf "completed_from_shell\\n"; }')
+    await page.keyboard.press('Control+l')
+    await page.keyboard.insertText('dsh_terminal_completion_pro')
+    await page.keyboard.press('Tab')
+    await page.keyboard.press('Enter')
+    await expect.poll(async () => await screen.innerText()).toContain('completed_from_shell')
+    await command(page, "printf 'PERSIST:%s\\n' \"$TERM\"")
+    await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
+    await page.locator('[data-dockkit-tab-title]').getByText('bash', { exact: true }).dblclick()
+    await page.getByRole('textbox', { name: 'Terminal name', exact: true }).fill('Development')
+    await page.getByRole('textbox', { name: 'Terminal name', exact: true }).press('Enter')
+    await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toContain('Development')
+    await openTerminal(page)
+    await command(page, "printf 'SECOND_PID:%s\\n' \"$$\"")
+    await expect.poll(async () => await screen.innerText()).toMatch(/SECOND_PID:\d+/u)
+    const secondPid = Number((await screen.innerText()).match(/SECOND_PID:(\d+)/u)?.[1])
+    // Shell PIDs belong to the sandbox namespace; process liveness uses Host identities.
+    const secondProcess = processIdentity(1)
+    expect(secondProcess.pid).not.toBe(firstProcess.pid)
+    await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).click()
+    await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
+    expect(alive(secondProcess)).toBe(true)
+    await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
+    expect(alive(firstProcess)).toBe(true)
+    await page.locator('[data-sidebar-right-expand]').click()
+    const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id)
+    const dockedCols = terminals()[0]!.cols
+    await page.getByRole('button', { name: 'Fullscreen', exact: true }).click()
+    await expect.poll(() => terminals()[0]!.cols).toBeGreaterThan(dockedCols)
+    await command(page, "printf 'SIZE:'; stty size")
+    await expect.poll(async () => await screen.innerText()).toContain(`SIZE:${terminals()[0]!.rows} ${terminals()[0]!.cols}`)
+    await page.screenshot({ path: `${shots}/fullscreen.png`, fullPage: true })
+    await page.reload({ waitUntil: 'load' })
+    await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).waitFor({ timeout: 15_000 })
+    await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toEqual(['Development', 'bash'])
+    expect(terminals()).toHaveLength(2)
+    expect(alive(firstProcess)).toBe(true)
+    expect(alive(secondProcess)).toBe(true)
+    await expect.poll(async () => await screen.innerText()).toContain(`SECOND_PID:${secondPid}`)
+    const secondTab = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' })
+    await secondTab.hover()
+    await secondTab.locator('[data-dockkit-tab-close]').click()
+    await expect.poll(async () => await secondTab.count()).toBe(0)
+    await expect.poll(() => alive(secondProcess), { timeout: 10_000 }).toBe(false)
+    await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
+    await command(page, "printf 'RECOVERED_PID:%s\\n' \"$$\"")
+    await expect.poll(async () => await screen.innerText()).toContain(`RECOVERED_PID:${pid}`)
+    await page.screenshot({ path: `${shots}/recovered.png`, fullPage: true })
+    const previousMembers = new Set(inspector.snapshot().tree(firstProcess.pid).map(member => member.pid))
+    await command(page, "sleep 120 & printf 'CHILD_PID:%s\\n' $!")
+    await expect.poll(async () => await screen.innerText()).toMatch(/CHILD_PID:\d+/u)
+    const descendants = inspector.snapshot().tree(firstProcess.pid).filter(member => member.pid !== firstProcess.pid)
+    expect(descendants.some(member => !previousMembers.has(member.pid))).toBe(true)
+    expect(descendants.every(alive)).toBe(true)
+    const tab = page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' })
+    await tab.hover()
+    await tab.locator('[data-dockkit-tab-close]').click()
+    await expect.poll(() => alive(firstProcess), { timeout: 10_000 }).toBe(false)
+    await expect.poll(() => descendants.some(alive), { timeout: 10_000 }).toBe(false)
+    await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).length).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
+  it('offers installed shells, remembers the choice after reload, and restores without a picker', async () => {
+    onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-shell-choice'))
+    await page.locator('[data-sidebar-right-expand]').click()
+    const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
+    expect(await entry.innerText()).toBe('New terminal\nRun commands in the Session workspace')
+    await page.locator('[data-sidebar-right-guide]').screenshot({ path: `${shots}/terminal-guide.png`, animations: 'disabled' })
+    await entry.click()
+    const selector = page.getByRole('button', { name: 'Shell', exact: true })
+    await selector.waitFor()
+    expect(await selector.innerText()).toContain('bash — /bin/bash')
+    expect(handles).toHaveLength(0)
+    await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/selection.expected.md', import.meta.url)),
+      await page.locator('[data-sidebar-terminal]').ariaSnapshot(), webSnapshotMode())
+    await selector.click()
+    await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/shell-menu.expected.md', import.meta.url)),
+      await page.getByRole('menu').ariaSnapshot(), webSnapshotMode())
+    await page.screenshot({ path: `${shots}/shell-menu.png`, fullPage: true })
+    await page.getByRole('menuitem', { name: 'sh — /bin/sh', exact: true }).click()
+    expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh')
+    await page.screenshot({ path: `${shots}/shell-choice.png`, fullPage: true })
+    await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
+    await command(page, "printf 'CHOSEN_SHELL:%s\\n' \"$0\"")
+    const screen = page.locator('.xterm-rows:visible')
+    await expect.poll(() => screen.innerText()).toContain('CHOSEN_SHELL:/bin/sh')
+    expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh')
+    const retained = processIdentity(0)
+    await page.reload({ waitUntil: 'load' })
+    await page.locator('.xterm-helper-textarea:visible').waitFor()
+    expect(await selector.count()).toBe(0)
+    expect(alive(retained)).toBe(true)
+    await page.locator('[data-dockkit-add-tab]').click()
+    await page.locator('[data-sidebar-right-guide-entry="terminal"]').click()
+    await selector.waitFor()
+    expect(await selector.innerText()).toContain('sh — /bin/sh')
+    expect(handles).toHaveLength(1)
+    await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
+    await expect.poll(() => handles.length).toBe(2)
+    expect(scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).map(info => info.shell.path)).toEqual(['/bin/sh', '/bin/sh'])
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
+  it('explains that exited terminals count toward the quota and permits creation after closing one', async () => {
+    onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-quota'))
+    const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id)
+    for (let count = 1; count <= 2; count++) {
+      await openTerminal(page)
+      await command(page, 'exit')
+      await expect.poll(() => terminals().filter(info => info.state === 'exited').length).toBe(count)
+    }
+    await openTerminal(page, false)
+    const alert = page.getByRole('alert')
+    await expect.poll(async () => await alert.innerText()).toContain('Exited terminals also count toward the limit.')
+    const expectedLimit = fileURLToPath(new URL('./expected/sidebar-terminal/limit.expected.md', import.meta.url))
+    await compareOrRefreshGolden(expectedLimit, await alert.ariaSnapshot(), webSnapshotMode())
+    const failed = page.locator('[data-dockkit-tab][aria-selected="true"]')
+    await failed.hover()
+    await failed.locator('[data-dockkit-tab-close]').click()
+    const exited = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' }).first()
+    await exited.hover()
+    await exited.locator('[data-dockkit-tab-close]').click()
+    await expect.poll(() => terminals().length).toBe(1)
+    await openTerminal(page)
+    await expect.poll(() => terminals().filter(info => info.state === 'running').length).toBe(1)
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
+})

+ 1 - 1
apps/web/tests/submission-echo.e2e.ts

@@ -1,5 +1,5 @@
 // @vitest-environment jsdom
 // @vitest-environment jsdom
-// Local submission echo over the BUILT client graph (keyless fixture Connection RPC
+// Local submission echo over the BUILT client graph (keyless RemoteMock
 // transport): a text-plus-image send paints its echo bubble synchronously on
 // transport): a text-plus-image send paints its echo bubble synchronously on
 // the submit keystroke — before serialization, transport, or the fixture's
 // the submit keystroke — before serialization, transport, or the fixture's
 // durable admission — with the composer already cleared and editable, and the
 // durable admission — with the composer already cleared and editable, and the

+ 1 - 1
apps/web/tests/todo-row.expected.e2e.ts

@@ -1,7 +1,7 @@
 // @vitest-environment jsdom
 // @vitest-environment jsdom
 // Assembled todo snapshot: boots the real built `packages/client/*/lib/
 // Assembled todo snapshot: boots the real built `packages/client/*/lib/
 // client.js` bundles through AppWebEntry's ModuleLoader path against the
 // client.js` bundles through AppWebEntry's ModuleLoader path against the
-// keyless fixture Connection RPC, opens the fixture session, and pins the
+// keyless RemoteMock transport, opens the fixture session, and pins the
 // two surfaces the fixture's parallel plan (turn 74, two items `in_progress`)
 // two surfaces the fixture's parallel plan (turn 74, two items `in_progress`)
 // reaches — the `todo_write` tool row and the dock's plan strip.
 // reaches — the `todo_write` tool row and the dock's plan strip.
 //
 //

+ 1 - 1
apps/web/tests/trajectory-image-display.expected.e2e.ts

@@ -1,6 +1,6 @@
 // @vitest-environment jsdom
 // @vitest-environment jsdom
 // Trajectory image surfaces over the BUILT client graph (the ptc-fixture
 // Trajectory image surfaces over the BUILT client graph (the ptc-fixture
-// idiom: real bundles via AppWebEntry, keyless fixture Connection RPC).
+// idiom: real bundles via AppWebEntry, keyless RemoteMock transport).
 // Opens the fixture history session whose turn 73 carries an image in BOTH a
 // Opens the fixture history session whose turn 73 carries an image in BOTH a
 // user message and an assistant message, and pins the Trajectory surfaces:
 // user message and an assistant message, and pins the Trajectory surfaces:
 // selecting the ledger record renders the shared ui-attachment gallery from
 // selecting the ledger record renders the shared ui-attachment gallery from

+ 2 - 2
apps/web/tests/turn-tail-actions.e2e.ts

@@ -201,8 +201,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
     await timeTrigger.click()
     await timeTrigger.click()
     const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' })
     const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' })
     expect(await timeDialog.count()).toBe(1)
     expect(await timeDialog.count()).toBe(1)
-    expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0)
-    expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0)
+    expect(await timeDialog.getByText(/tok\/s/).count()).toBeGreaterThan(0)
+    expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(1)
     await page.keyboard.press('Escape')
     await page.keyboard.press('Escape')
     await trigger.click()
     await trigger.click()
 
 

+ 7 - 0
apps/web/tsconfig.json

@@ -101,6 +101,7 @@
     "tests/feedback-release.e2e.ts",
     "tests/feedback-release.e2e.ts",
     "tests/agent-team-panel.e2e.ts",
     "tests/agent-team-panel.e2e.ts",
     "tests/sidebar-right.e2e.ts",
     "tests/sidebar-right.e2e.ts",
+    "tests/sidebar-terminal.e2e.ts",
     "tests/startup-auto-selection.e2e.ts",
     "tests/startup-auto-selection.e2e.ts",
     "tests/produced-files.e2e.ts",
     "tests/produced-files.e2e.ts",
     "tests/produced-file-mentions.e2e.ts",
     "tests/produced-file-mentions.e2e.ts",
@@ -135,6 +136,12 @@
     },
     },
     {
     {
       "path": "../../packages/client/modules"
       "path": "../../packages/client/modules"
+    },
+    {
+      "path": "../../packages/test-support/client-runtime"
+    },
+    {
+      "path": "../../packages/test-support/remote-mock"
     }
     }
   ]
   ]
 }
 }

+ 2 - 2
docs/architecture.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/architecture.md
 #   pnpm run verify-translation-pairing --write docs/architecture.md
-architecture.md: 4c8b78554cd744838a26da9bde39a405b275b739
-architecture.zh.md: 0eea56b18ae79b297178dfb831ce78e5aed66699
+architecture.md: 3addf70ab997590d97a2581ea0a459e6d4e86911
+architecture.zh.md: 078d99dc06cf49233d0ca4c036bab89a3f664d57

+ 2 - 2
docs/architecture.md

@@ -106,7 +106,7 @@ turn/end
 
 
 Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does.
 Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does.
 
 
-`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)).
+`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)).
 
 
 The loop sends immutable requests while keeping cancellation live. It reuses message-freeze evidence only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules.
 The loop sends immutable requests while keeping cancellation live. It reuses message-freeze evidence only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules.
 
 
@@ -118,7 +118,7 @@ The session log is the source of the context the model sees. `deriveMessages()`
 
 
 Session consumers know only the current logical format. Header-only `stat` and `list` rescan each Session directory, select its numerically highest canonical generation, and translate a supported historical header without loading events or publishing a successor. A stored-session `open` selects that same generation, refuses a future version, or decodes and composes the static adjacent migration chain once before returning validated current logical events. A read open uses that in-memory result without publishing a successor; a write open first encodes, verifies, and exclusively publishes the final version-named successor beside the unchanged source. Ordinary repair of an unsealed interrupted tail remains a handle consumer responsibility; migration inserts a missing interrupted `turn/end` only for the bounded released restart already sealed by a later `turn/start`. JSONL v0 uses `session.jsonl[.zstd]`, v1 and later use lowercase `session.vN.jsonl[.zstd]`, and committed generation paths are never renamed, replaced, or deleted. The JSONL provider owns physical framing, compression, generation selection, and exclusive publication, while each adjacent migration package owns exactly one `vN -> vN+1` step ([decision](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)).
 Session consumers know only the current logical format. Header-only `stat` and `list` rescan each Session directory, select its numerically highest canonical generation, and translate a supported historical header without loading events or publishing a successor. A stored-session `open` selects that same generation, refuses a future version, or decodes and composes the static adjacent migration chain once before returning validated current logical events. A read open uses that in-memory result without publishing a successor; a write open first encodes, verifies, and exclusively publishes the final version-named successor beside the unchanged source. Ordinary repair of an unsealed interrupted tail remains a handle consumer responsibility; migration inserts a missing interrupted `turn/end` only for the bounded released restart already sealed by a later `turn/start`. JSONL v0 uses `session.jsonl[.zstd]`, v1 and later use lowercase `session.vN.jsonl[.zstd]`, and committed generation paths are never renamed, replaced, or deleted. The JSONL provider owns physical framing, compression, generation selection, and exclusive publication, while each adjacent migration package owns exactly one `vN -> vN+1` step ([decision](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)).
 
 
-**Model-visible means logged.** Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it. This is why a new model-visible input requires a new session event: extend `SessionEventMap` and render from the log.
+**Model-visible means logged.** Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it. A new model-visible input requires a session event. Plugins that change existing message content register [pure message projections](subsystems/session.md#plugin-owned-message-projections); detached readers supply the same definitions explicitly.
 
 
 **Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, host consumers read one typed state with `stateOf()`, and carriers batch cropped client views with `snapshot()`. A host reader either requires this service during activation or fails explicitly when the registry or required key is absent. Contributors may retain `ctx.inject(['sessionProjections'], ...)` registration without silently defaulting a missing host value. The agent loop registers shared `turnBoundary` state for its readers ([decision](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md)).
 **Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, host consumers read one typed state with `stateOf()`, and carriers batch cropped client views with `snapshot()`. A host reader either requires this service during activation or fails explicitly when the registry or required key is absent. Contributors may retain `ctx.inject(['sessionProjections'], ...)` registration without silently defaulting a missing host value. The agent loop registers shared `turnBoundary` state for its readers ([decision](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md)).
 
 

+ 2 - 2
docs/architecture.zh.md

@@ -110,7 +110,7 @@ turn/end
 
 
 输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。
 输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。
 
 
-`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。
+`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。
 
 
 循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。
 循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。
 
 
@@ -122,7 +122,7 @@ turn/end
 
 
 Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list` 会重新扫描每个 Session 目录,选择数值最高的规范 generation,并在不加载事件或发布后继的情况下转换受支持的历史 header。已存储 Session 的 `open` 选择同一 generation,拒绝未来版本,或只 Decode 并组合一次构建时静态确定的相邻迁移链,再返回经过校验的当前逻辑事件。只读 open 直接使用这份内存结果,不发布后继;写 open 则先编码、校验并在未改变源的旁边排他发布最终版本命名的后继。未被后续事件封住的普通中断尾部仍由句柄消费方修复;只有在后续 `turn/start` 已经封住一种有限的已发布 restart 时,migration 才会插入缺失的 interrupted `turn/end`。JSONL v0 使用 `session.jsonl[.zstd]`,v1 及后续版本使用小写 `session.vN.jsonl[.zstd]`;已提交 generation 路径绝不重命名、替换或删除。JSONL provider 负责物理 framing、压缩、generation 选择与排他发布,每个相邻迁移包只负责一个 `vN -> vN+1` 步骤([决策](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。
 Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list` 会重新扫描每个 Session 目录,选择数值最高的规范 generation,并在不加载事件或发布后继的情况下转换受支持的历史 header。已存储 Session 的 `open` 选择同一 generation,拒绝未来版本,或只 Decode 并组合一次构建时静态确定的相邻迁移链,再返回经过校验的当前逻辑事件。只读 open 直接使用这份内存结果,不发布后继;写 open 则先编码、校验并在未改变源的旁边排他发布最终版本命名的后继。未被后续事件封住的普通中断尾部仍由句柄消费方修复;只有在后续 `turn/start` 已经封住一种有限的已发布 restart 时,migration 才会插入缺失的 interrupted `turn/end`。JSONL v0 使用 `session.jsonl[.zstd]`,v1 及后续版本使用小写 `session.vN.jsonl[.zstd]`;已提交 generation 路径绝不重命名、替换或删除。JSONL provider 负责物理 framing、压缩、generation 选择与排他发布,每个相邻迁移包只负责一个 `vN -> vN+1` 步骤([决策](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。
 
 
-**模型可见即已记录。** 抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言这一点。因此,新增一项模型可见输入就需要新增一个会话事件:扩展 `SessionEventMap` 并从日志渲染
+**模型可见即已记录。** 抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言这一点。新增模型可见输入需要一个会话事件。修改现有消息内容的插件注册[纯消息投影](subsystems/session.zh.md#plugin-owned-message-projections),独立读取器显式传入相同的处理器
 
 
 **投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,host 消费方通过 `stateOf()` 读取单个类型化状态,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。host 读取方要么在激活时要求该服务,要么在注册表或必需 key 缺席时明确失败。贡献方可以保留 `ctx.inject(['sessionProjections'], ...)` 注册,但不能为缺失的 host 值静默提供默认值。agent loop 为读取方注册共享的 `turnBoundary` 状态([决策](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md))。
 **投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,host 消费方通过 `stateOf()` 读取单个类型化状态,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。host 读取方要么在激活时要求该服务,要么在注册表或必需 key 缺席时明确失败。贡献方可以保留 `ctx.inject(['sessionProjections'], ...)` 注册,但不能为缺失的 host 值静默提供默认值。agent loop 为读取方注册共享的 `turnBoundary` 状态([决策](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md))。
 
 

+ 2 - 2
docs/capability-seams.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/capability-seams.md
 #   pnpm run verify-translation-pairing --write docs/capability-seams.md
-capability-seams.md: 009f2247bfb91f22ac87e9e67ce46ba4fd11aee5
-capability-seams.zh.md: 07172bdca05eb5cca9442af90686b17481f1261b
+capability-seams.md: 839d241869f8f88125cd7db6ad75646f25b4460d
+capability-seams.zh.md: eb6088bf63e51e6a4bb1c41453dd8a2af102b610

+ 4 - 0
docs/capability-seams.md

@@ -58,6 +58,8 @@ flowchart LR
   svc_settingsController["ctx.settingsController<br/>Host settings-surface Remote controller"]
   svc_settingsController["ctx.settingsController<br/>Host settings-surface Remote controller"]
   pkg_api_workspace_files["api-workspace-files"]
   pkg_api_workspace_files["api-workspace-files"]
   svc_workspaceFiles["ctx.workspaceFiles<br/>Host workspace file Remote service"]
   svc_workspaceFiles["ctx.workspaceFiles<br/>Host workspace file Remote service"]
+  pkg_api_terminal_controller["api-terminal-controller"]
+  svc_terminalController["ctx.terminalController<br/>Session interactive terminal Remote controller"]
   pkg_api_workspace_controller["api-workspace-controller"]
   pkg_api_workspace_controller["api-workspace-controller"]
   svc_workspaceController["ctx.workspaceController<br/>Host Workspace Remote controller"]
   svc_workspaceController["ctx.workspaceController<br/>Host Workspace Remote controller"]
   svc_directoryPickerController["ctx.directoryPickerController<br/>Host directory-picking Remote controller"]
   svc_directoryPickerController["ctx.directoryPickerController<br/>Host directory-picking Remote controller"]
@@ -247,6 +249,7 @@ flowchart LR
   pkg_api_session_controller --> svc_sessionSkillCatalog
   pkg_api_session_controller --> svc_sessionSkillCatalog
   pkg_api_settings_controller --> svc_credentialsController
   pkg_api_settings_controller --> svc_credentialsController
   pkg_api_settings_controller --> svc_settingsController
   pkg_api_settings_controller --> svc_settingsController
+  pkg_api_terminal_controller --> svc_terminalController
   pkg_api_workspace_controller --> svc_directoryPickerController
   pkg_api_workspace_controller --> svc_directoryPickerController
   pkg_api_workspace_controller --> svc_workspaceController
   pkg_api_workspace_controller --> svc_workspaceController
   pkg_api_workspace_files --> svc_workspaceFiles
   pkg_api_workspace_files --> svc_workspaceFiles
@@ -521,6 +524,7 @@ flowchart LR
 | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. |
 | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. |
 | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. |
 | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. |
 | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session's workspace root, confined by lstat, containment, and a stat re-check. |
 | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session's workspace root, confined by lstat, containment, and a stat re-check. |
+| `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | Owns user terminal processes, default shell resolution and bounded screen recovery through the subprocess provider and typed Remote transport. |
 | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. |
 | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. |
 | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. |
 | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. |
 | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
 | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |

+ 4 - 0
docs/capability-seams.zh.md

@@ -60,6 +60,8 @@ flowchart LR
   svc_settingsController["ctx.settingsController<br/>Host settings-surface Remote controller"]
   svc_settingsController["ctx.settingsController<br/>Host settings-surface Remote controller"]
   pkg_api_workspace_files["api-workspace-files"]
   pkg_api_workspace_files["api-workspace-files"]
   svc_workspaceFiles["ctx.workspaceFiles<br/>Host workspace file Remote service"]
   svc_workspaceFiles["ctx.workspaceFiles<br/>Host workspace file Remote service"]
+  pkg_api_terminal_controller["api-terminal-controller"]
+  svc_terminalController["ctx.terminalController<br/>Session interactive terminal Remote controller"]
   pkg_api_workspace_controller["api-workspace-controller"]
   pkg_api_workspace_controller["api-workspace-controller"]
   svc_workspaceController["ctx.workspaceController<br/>Host Workspace Remote controller"]
   svc_workspaceController["ctx.workspaceController<br/>Host Workspace Remote controller"]
   svc_directoryPickerController["ctx.directoryPickerController<br/>Host directory-picking Remote controller"]
   svc_directoryPickerController["ctx.directoryPickerController<br/>Host directory-picking Remote controller"]
@@ -249,6 +251,7 @@ flowchart LR
   pkg_api_session_controller --> svc_sessionSkillCatalog
   pkg_api_session_controller --> svc_sessionSkillCatalog
   pkg_api_settings_controller --> svc_credentialsController
   pkg_api_settings_controller --> svc_credentialsController
   pkg_api_settings_controller --> svc_settingsController
   pkg_api_settings_controller --> svc_settingsController
+  pkg_api_terminal_controller --> svc_terminalController
   pkg_api_workspace_controller --> svc_directoryPickerController
   pkg_api_workspace_controller --> svc_directoryPickerController
   pkg_api_workspace_controller --> svc_workspaceController
   pkg_api_workspace_controller --> svc_workspaceController
   pkg_api_workspace_files --> svc_workspaceFiles
   pkg_api_workspace_files --> svc_workspaceFiles
@@ -523,6 +526,7 @@ flowchart LR
 | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 |
 | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 |
 | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 |
 | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 |
 | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | 为会话工作区根内的文件提供 stat、分页文本、字节窗口、目录列举与变更流,经 lstat、包含关系与 stat 重检限定。 |
 | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | 为会话工作区根内的文件提供 stat、分页文本、字节窗口、目录列举与变更流,经 lstat、包含关系与 stat 重检限定。 |
+| `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | 通过子进程提供方与类型化 Remote 传输管理用户终端进程、解析默认 shell,并恢复有界终端屏幕。 |
 | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 |
 | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 |
 | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 |
 | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 |
 | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 |
 | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 |

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 8949c8bb942416761f79fae692225af53274f11f
-config-catalog.zh.md: 0285b00ac21cdac04e91cc19a7707b720d614dac
+config-catalog.md: ad1d7d977e0cea23c76256fca626988d51429fb6
+config-catalog.zh.md: 4a3d1692355c0a6ff7306a929fd87050f61ed06b

+ 42 - 1
docs/config-catalog.md

@@ -229,6 +229,45 @@ export interface Config {
 
 
 Source: [`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts)
 Source: [`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts)
 
 
+<a id="deepseek-aidsh-api-terminal-controller"></a>
+
+## `@deepseek-ai/dsh-api-terminal-controller`
+
+Requires: `subprocess` · `sandboxPolicy` · `sessionProjections` · `typert`
+
+```ts config-catalog
+/** Deployment limits and an optional shell profile. */
+export interface Config {
+  /** Explicit shell profile; omission uses the execution environment's default shell. */
+  readonly shell?: {
+    /** Executable path or PATH name, verified by the subprocess provider. */
+    path: string
+    /** User-visible profile name. */
+    name: string
+    /** Arguments passed to the interactive shell. */
+    args: string[]
+  } | undefined
+  /** Executable names or paths checked for the new-terminal shell selector. */
+  readonly shellCandidates: string[]
+  /** Maximum retained terminals and pending allocations per Session. */
+  readonly maxTerminals: number
+  /** Maximum terminal width in columns. */
+  readonly maxCols: number
+  /** Maximum terminal height in rows. */
+  readonly maxRows: number
+  /** Screen history rows retained for reconnecting clients. */
+  readonly scrollback: number
+  /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */
+  readonly maxBufferedBytes: number
+  /** Maximum UTF-8 bytes in one input request. */
+  readonly maxInputBytes: number
+  /** Provider process-termination grace period in milliseconds. */
+  readonly disposeGraceMs: number
+}
+```
+
+Source: [`packages/api/terminal-controller/src/index.ts:28`](../packages/api/terminal-controller/src/index.ts)
+
 <a id="deepseek-aidsh-api-workspace-files"></a>
 <a id="deepseek-aidsh-api-workspace-files"></a>
 
 
 ## `@deepseek-ai/dsh-api-workspace-files`
 ## `@deepseek-ai/dsh-api-workspace-files`
@@ -1489,7 +1528,7 @@ export interface ReplayModelConfig {
 
 
 Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
 Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
 
 
-Source: [`packages/test-support/llm-replay/src/index.ts:1123`](../packages/test-support/llm-replay/src/index.ts)
+Source: [`packages/test-support/llm-replay/src/index.ts:1122`](../packages/test-support/llm-replay/src/index.ts)
 
 
 <a id="deepseek-aidsh-llm-retry"></a>
 <a id="deepseek-aidsh-llm-retry"></a>
 
 
@@ -3580,6 +3619,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` ([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` ([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-right` ([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-right` ([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-sidebar-terminal` ([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
@@ -3592,6 +3632,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-command-feedback` — requires `commands` ([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts))
 - `@deepseek-ai/dsh-command-feedback` — requires `commands` ([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts))
 - `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts))
 - `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts))
 - `@deepseek-ai/dsh-commands` ([`packages/interaction/commands/src/index.ts`](../packages/interaction/commands/src/index.ts))
 - `@deepseek-ai/dsh-commands` ([`packages/interaction/commands/src/index.ts`](../packages/interaction/commands/src/index.ts))
+- `@deepseek-ai/dsh-compaction-image-offload` — requires `agents` · `sessions` ([`packages/compaction/compaction-image-offload/src/index.ts`](../packages/compaction/compaction-image-offload/src/index.ts))
 - `@deepseek-ai/dsh-computer-use` ([`packages/computer-use/computer-use/src/index.ts`](../packages/computer-use/computer-use/src/index.ts))
 - `@deepseek-ai/dsh-computer-use` ([`packages/computer-use/computer-use/src/index.ts`](../packages/computer-use/computer-use/src/index.ts))
 - `@deepseek-ai/dsh-cordis-client-runner` ([`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts))
 - `@deepseek-ai/dsh-cordis-client-runner` ([`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts))
 - `@deepseek-ai/dsh-deepseek-llm-api-extensions` ([`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts))
 - `@deepseek-ai/dsh-deepseek-llm-api-extensions` ([`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts))

+ 42 - 1
docs/config-catalog.zh.md

@@ -231,6 +231,45 @@ export interface Config {
 
 
 来源:[`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts)
 来源:[`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts)
 
 
+<a id="deepseek-aidsh-api-terminal-controller"></a>
+
+## `@deepseek-ai/dsh-api-terminal-controller`
+
+Requires: `subprocess` · `sandboxPolicy` · `sessionProjections` · `typert`
+
+```ts config-catalog
+/** Deployment limits and an optional shell profile. */
+export interface Config {
+  /** Explicit shell profile; omission uses the execution environment's default shell. */
+  readonly shell?: {
+    /** Executable path or PATH name, verified by the subprocess provider. */
+    path: string
+    /** User-visible profile name. */
+    name: string
+    /** Arguments passed to the interactive shell. */
+    args: string[]
+  } | undefined
+  /** Executable names or paths checked for the new-terminal shell selector. */
+  readonly shellCandidates: string[]
+  /** Maximum retained terminals and pending allocations per Session. */
+  readonly maxTerminals: number
+  /** Maximum terminal width in columns. */
+  readonly maxCols: number
+  /** Maximum terminal height in rows. */
+  readonly maxRows: number
+  /** Screen history rows retained for reconnecting clients. */
+  readonly scrollback: number
+  /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */
+  readonly maxBufferedBytes: number
+  /** Maximum UTF-8 bytes in one input request. */
+  readonly maxInputBytes: number
+  /** Provider process-termination grace period in milliseconds. */
+  readonly disposeGraceMs: number
+}
+```
+
+来源: [`packages/api/terminal-controller/src/index.ts:28`](../packages/api/terminal-controller/src/index.ts)
+
 <a id="deepseek-aidsh-api-workspace-files"></a>
 <a id="deepseek-aidsh-api-workspace-files"></a>
 
 
 ## `@deepseek-ai/dsh-api-workspace-files`
 ## `@deepseek-ai/dsh-api-workspace-files`
@@ -1491,7 +1530,7 @@ export interface ReplayModelConfig {
 
 
 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
 
 
-来源:[`packages/test-support/llm-replay/src/index.ts:1123`](../packages/test-support/llm-replay/src/index.ts)
+来源:[`packages/test-support/llm-replay/src/index.ts:1122`](../packages/test-support/llm-replay/src/index.ts)
 
 
 <a id="deepseek-aidsh-llm-retry"></a>
 <a id="deepseek-aidsh-llm-retry"></a>
 
 
@@ -3582,6 +3621,7 @@ export interface Config {
 - `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-right`([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-right`([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-sidebar-terminal`([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-skill`([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-skill`([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-subagent`([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-subagent`([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-theme`([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-theme`([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
@@ -3594,6 +3634,7 @@ export interface Config {
 - `@deepseek-ai/dsh-command-feedback` — 需要 `commands`([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts))
 - `@deepseek-ai/dsh-command-feedback` — 需要 `commands`([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts))
 - `@deepseek-ai/dsh-command-goal` — 需要 `commands` · `goals`([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts))
 - `@deepseek-ai/dsh-command-goal` — 需要 `commands` · `goals`([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts))
 - `@deepseek-ai/dsh-commands`([`packages/interaction/commands/src/index.ts`](../packages/interaction/commands/src/index.ts))
 - `@deepseek-ai/dsh-commands`([`packages/interaction/commands/src/index.ts`](../packages/interaction/commands/src/index.ts))
+- `@deepseek-ai/dsh-compaction-image-offload`,需要 `agents` 和 `sessions`([`packages/compaction/compaction-image-offload/src/index.ts`](../packages/compaction/compaction-image-offload/src/index.ts))
 - `@deepseek-ai/dsh-computer-use` ([`packages/computer-use/computer-use/src/index.ts`](../packages/computer-use/computer-use/src/index.ts))
 - `@deepseek-ai/dsh-computer-use` ([`packages/computer-use/computer-use/src/index.ts`](../packages/computer-use/computer-use/src/index.ts))
 - `@deepseek-ai/dsh-cordis-client-runner`([`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts))
 - `@deepseek-ai/dsh-cordis-client-runner`([`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts))
 - `@deepseek-ai/dsh-deepseek-llm-api-extensions`([`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts))
 - `@deepseek-ai/dsh-deepseek-llm-api-extensions`([`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts))

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: d58336c9d782a4d61a9df295ac64623e1b533408
-event-producer-consumer.zh.md: 2a97fdae3fd629e210cd80aeeb7968606292dc57
+event-producer-consumer.md: b727570dff92aba1bfa0cbb1fba86f9ffe9d9bff
+event-producer-consumer.zh.md: 8fc9c6263e133b4f1fd8703167d44ee530f8b079

+ 3 - 2
docs/event-producer-consumer.md

@@ -18,7 +18,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
 | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
 | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:347`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
 | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:347`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
@@ -30,6 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+| `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:99`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
@@ -81,7 +82,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 
 
 | Event string | Dispatchers | Listeners |
 | Event string | Dispatchers | Listeners |
 | --- | --- | --- |
 | --- | --- | --- |
-| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
+| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), `terminal-controller`, [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
 | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` |
 | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |
 | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |

+ 3 - 2
docs/event-producer-consumer.zh.md

@@ -20,7 +20,7 @@
 | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
 | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
 | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:347`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
 | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:347`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
@@ -32,6 +32,7 @@
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+| `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:99`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
@@ -83,7 +84,7 @@
 
 
 | Event string | Dispatchers | Listeners |
 | Event string | Dispatchers | Listeners |
 | --- | --- | --- |
 | --- | --- | --- |
-| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
+| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), `terminal-controller`, [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
 | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` |
 | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |
 | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 1a81dbf19ce2cc51a91c89ae310229954fd7c0b5
-module-graph.zh.md: 4f609375c3ada6fb857021a4248e16d64cd4c28a
+module-graph.md: 4478407e3c4f8f839c341c41dd2c7b4bb0ab0b22
+module-graph.zh.md: 8cf238d9c49e436cf2738ebffcd26111277c5574

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio