Sfoglia il codice sorgente

Merge remote-tracking branch 'origin/master' into worktree/issue-3515-feedback-dialog

# Conflicts:
#	apps/web/tests/message-feedback-layout.e2e.ts
creatixchu 3 settimane fa
parent
commit
0b5fca5c68
87 ha cambiato i file con 1187 aggiunte e 153 eliminazioni
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.i18n.yaml
  2. 25 0
      .agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.md
  3. 25 0
      .agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.zh.md
  4. 6 0
      .agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml
  5. 21 0
      .agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md
  6. 21 0
      .agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml
  8. 3 1
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
  9. 3 1
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md
  10. 2 2
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
  11. 2 0
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
  12. 2 0
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
  13. 2 2
      .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml
  14. 1 1
      .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
  15. 1 1
      .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md
  16. 6 0
      .agents/notes/implemented/testing/2026-09-08-ci-completion-observations.i18n.yaml
  17. 45 0
      .agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md
  18. 45 0
      .agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md
  19. 2 5
      .github/review-ownership/check-approval.mjs
  20. 3 2
      .github/review-ownership/check-approval.test.mjs
  21. 1 3
      .github/workflows/weighted-approval-review-event.yml
  22. 2 2
      apps/web/tests/README.i18n.yaml
  23. 4 0
      apps/web/tests/README.md
  24. 4 0
      apps/web/tests/README.zh.md
  25. 1 1
      apps/web/tests/clickable-links-gallery.e2e.ts
  26. 72 0
      apps/web/tests/composer-placeholder.e2e.ts
  27. 6 1
      apps/web/tests/details-session-lifecycle.e2e.ts
  28. 1 1
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  29. 8 0
      apps/web/tests/expected/composer-placeholder/visibility.expected.md
  30. 20 1
      apps/web/tests/github-ready-review.e2e.ts
  31. 121 0
      apps/web/tests/present-svg.e2e.ts
  32. 1 1
      apps/web/tests/produced-file-mentions.e2e.ts
  33. 19 5
      apps/web/tests/produced-files.e2e.ts
  34. 46 12
      apps/web/tests/queue-image.e2e.ts
  35. 19 2
      apps/web/tests/steering.e2e.ts
  36. 25 6
      apps/web/tests/workspace-management.e2e.ts
  37. 2 0
      apps/web/tsconfig.json
  38. 2 2
      benchmarks/agent-continuation/README.i18n.yaml
  39. 1 1
      benchmarks/agent-continuation/README.md
  40. 1 1
      benchmarks/agent-continuation/README.zh.md
  41. 7 1
      benchmarks/agent-continuation/agent-continuation.bench.ts
  42. 2 2
      docs/tool-catalog.i18n.yaml
  43. 1 1
      docs/tool-catalog.md
  44. 1 1
      docs/tool-catalog.zh.md
  45. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  46. 1 1
      packages/client/ui-conversation/README.md
  47. 1 1
      packages/client/ui-conversation/README.zh.md
  48. 1 1
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  49. 43 0
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  50. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  51. 1 1
      packages/client/ui-deliverables/README.md
  52. 1 1
      packages/client/ui-deliverables/README.zh.md
  53. 2 2
      packages/client/ui-deliverables/src/client/locales.ts
  54. 3 3
      packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
  55. 95 0
      packages/code-runtime/code-runtime-worker-thread/tests/budget.spec.ts
  56. 2 2
      packages/experimental/webworker-runtime/README.i18n.yaml
  57. 2 0
      packages/experimental/webworker-runtime/README.md
  58. 2 0
      packages/experimental/webworker-runtime/README.zh.md
  59. 7 2
      packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts
  60. 50 2
      packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts
  61. 2 2
      packages/fs/tool-present/README.i18n.yaml
  62. 1 1
      packages/fs/tool-present/README.md
  63. 1 1
      packages/fs/tool-present/README.zh.md
  64. 4 1
      packages/fs/tool-present/src/index.ts
  65. 2 2
      packages/host/directory-picker-native/README.i18n.yaml
  66. 2 1
      packages/host/directory-picker-native/README.md
  67. 2 1
      packages/host/directory-picker-native/README.zh.md
  68. 3 1
      packages/host/directory-picker-native/src/index.ts
  69. 9 0
      packages/host/directory-picker-native/src/win32-dialog-bindings.ts
  70. 4 3
      packages/host/directory-picker-native/src/win32-dialog-host.ts
  71. 15 0
      packages/host/directory-picker-native/src/win32-dialog-logic.ts
  72. 6 3
      packages/host/directory-picker-native/src/win32-dialog-worker.ts
  73. 20 1
      packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts
  74. 11 3
      packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts
  75. 56 0
      packages/host/open-in-app/tests/launch-detached.spec.ts
  76. 0 12
      packages/host/open-in-app/tests/resolver.spec.ts
  77. 11 2
      packages/lsp/lsp-stdio/tests/instance.spec.ts
  78. 48 34
      packages/shell/pwsh-local/tests/executor.spec.ts
  79. 1 1
      scripts/ci-workflow.spec.ts
  80. 1 1
      snapshots/web/cordis-tool-round/tool-schemas.expected.json
  81. 1 1
      snapshots/web/fresh-round-trip/tool-schemas.expected.json
  82. 14 0
      snapshots/web/present-svg/session.v3.jsonl
  83. 9 0
      snapshots/web/present-svg/snapshot.yml
  84. 73 0
      snapshots/web/present-svg/ui.expected.md
  85. 83 0
      snapshots/web/present-svg/workspace.expected/von-neumann.svg
  86. 1 1
      snapshots/web/ptc-round/system-prompt.expected.md
  87. 2 0
      tsconfig.host.json

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.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/bug-fix/2026-09-07-win32-picker-foreground-alt-key.md
+2026-09-07-win32-picker-foreground-alt-key.md: cd1bae56845c74bf8fb0b3c0e6b8e6bab8032daf
+2026-09-07-win32-picker-foreground-alt-key.zh.md: 8cbacaee97892b2ba6feb3d0250212fa9d393376

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.md

@@ -0,0 +1,25 @@
+# Agent Note: Foreground activation for the Win32 picker via a synthesized Alt press
+
+Status: implemented
+
+English | [中文](2026-09-07-win32-picker-foreground-alt-key.zh.md)
+
+## Problem
+
+The web GUI host picks a workspace directory through the native Win32 folder dialog, which runs in a child process the host spawns (issue #3543). Windows grants the foreground only to the foreground process, to a process it started, or to a process that received recent input; a child of a background server process qualifies for none of these, so the dialog that `Show` opens sits behind every visible window even though it is the child's first window. The first-window activation assumption behind the spawn design ([archived feature note](../../archived/feature/2026-08-02-win32-in-process-folder-dialog.md)) holds only when the spawner chain owns the console foreground, as in a console-launched CLI.
+
+## Decision
+
+`runFolderDialog` calls a new `pressAltForForeground` binding between the `showing` notice and the blocking `Show`. The binding synthesizes one Alt press (`keybd_event` with `VK_MENU`, down then up) on the dialog thread, which makes Windows count this process as the most recent input owner — one of the documented grounds for foreground activation — so the dialog window `Show` creates activates as foreground. The bindings module already loads koffi's `user32`, so the change adds one function fetch and two invocations. The press is unconditional on Windows. When the process already holds foreground rights (a console-launched CLI), the dialog activates anyway and the press is inert; the window focused at that moment still receives the lone Alt and may briefly highlight its menu bar. Environments that suppress injected input (secure desktops, restricted remote sessions, an elevated foreground window) leave the dialog behind other windows, and the package README records that limit.
+
+## Alternatives considered
+
+**Custom URL protocol with a browser click gesture.** Draft PR #3544 granted the foreground by navigating the foreground browser to a registered `dsh-picker://` URL, which makes the shell launch the dialog process as a foreground descendant. The grant is deterministic by design, but the mechanism spans registry and VBS launcher files, a protocol entry point, a picker-result HTTP route with per-boot tokens, and a first-use browser confirmation, and it adds a server route the browser can reach. The synthesized press removes that entire surface.
+
+**AllowSetForegroundWindow from the clicker.** The API must be called by the current foreground process — the browser — and may name only one permitted process; the spawner cannot invoke it on the browser's behalf.
+
+**AttachThreadInput to the focused thread.** Attaching the dialog thread to the focused window's thread also bypasses the foreground restriction and avoids the keystroke side effect, but it is equally undocumented, needs the focused window's thread id at show time, and fails when the focused window belongs to a higher-integrity process; it was not prototyped.
+
+## Consequences
+
+The picker keeps its single spawned-child design and gains foreground behavior in the background-host case at the cost of one koffi call pair. The bindings spec pins the Alt down/up sequence and its position immediately before `Show` over the fake COM world; the logic spec pins the full showing → press → `Show` order. The Windows CI lane still opens and abort-closes a real dialog with the press present but asserts no activation. Validation on a Windows 11 machine with the foreground lock forced to its maximum reproduced the failure without the press (dialog behind other windows) and the foreground dialog with it in five of five repeat runs; Windows 10 is unverified. Synthesized input is consumed asynchronously by the raw input thread, so the activation grant is in principle race-prone; no miss appeared across the repeat runs, and fragile environments stay a documented package limitation rather than a second mechanism, because the browse backend remains the composition-level answer where native picking cannot be trusted.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: 通过合成的 Alt 按键让 Win32 选择器获得前台激活
+
+Status: implemented
+
+[English](2026-09-07-win32-picker-foreground-alt-key.md) | 中文
+
+## Problem
+
+web GUI 宿主通过原生 Win32 文件夹对话框选择工作区目录,对话框运行在宿主 spawn 的子进程中(issue #3543)。Windows 只把前台授予前台进程、由前台进程启动的进程或最近收到输入的进程;后台服务器进程的子进程三者都不满足,因此 `Show` 打开的对话框即使属于子进程的首个窗口,也会落在所有可见窗口之后。spawn 设计背后的"首窗口即激活"假设([归档功能记录](../../archived/feature/2026-08-02-win32-in-process-folder-dialog.md))只在 spawn 链持有控制台前台时成立,例如控制台启动的 CLI。
+
+## Decision
+
+`runFolderDialog` 在 `showing` 通知与阻塞式 `Show` 之间调用新增的 `pressAltForForeground` 绑定。该绑定在对话框线程上合成一次 Alt 按键(`keybd_event` 携带 `VK_MENU`,先按下后抬起),使 Windows 把该进程计为最近的输入所有者——文档记载的允许前台激活的理由之一——于是 `Show` 创建的对话框窗口以前台方式激活。bindings 模块已经加载 koffi 的 `user32`,因此改动只增加一次函数获取与两次调用。该按键在 Windows 上无条件执行。当进程已经持有前台权利(控制台启动的 CLI)时,对话框本来就会激活,按键不起作用;此刻获得焦点的窗口仍会收到这一次单独的 Alt,可能短暂高亮其菜单栏。在合成输入被抑制的环境(安全桌面、受限远程会话、提权前台窗口)中,对话框仍会落在其他窗口后面,包 README 记录了该限制。
+
+## Alternatives considered
+
+**自定义 URL 协议加浏览器点击手势。** 草稿 PR #3544 通过让前台浏览器导航到已注册的 `dsh-picker://` URL 来授予前台,使 shell 把对话框进程作为前台进程的后代启动。该授权按设计具有确定性,但机制横跨注册表与 VBS 启动器文件、协议入口点、携带每次启动令牌的 picker-result HTTP 路由,以及首次使用时的浏览器确认,还增加了一条浏览器可达的服务端路由。合成按键删除了整个这一面。
+
+**由点击方调用 AllowSetForegroundWindow。** 该 API 必须由当前前台进程——浏览器——调用,并且只能点名一个被允许的进程;spawner 无法代替浏览器调用它。
+
+**对聚焦线程 AttachThreadInput。** 把对话框线程附着到焦点窗口所属线程同样能绕过前台限制且没有按键副作用,但同样没有文档契约,需要在显示时取得焦点窗口的线程 id,并在焦点窗口属于更高完整性进程时失败;未做原型验证。
+
+## Consequences
+
+选择器保持单一 spawn 子进程设计,并在后台宿主场景获得前台行为,代价是一次 koffi 调用对。bindings spec 在假 COM 世界上固定了 Alt 按下/抬起序列及其紧邻 `Show` 之前的位置;logic spec 固定完整的 showing → press → `Show` 顺序。Windows CI lane 仍会真实打开并中止关闭一个对话框(按键存在),但不断言激活。在一台把前台锁强制到最大值的 Windows 11 机器上做了验证:不加按键复现失败(对话框在其他窗口后面),加上按键后对话框获得前台,五轮重复全部成功;Windows 10 尚未验证。合成输入由 raw input thread 异步消费,因此激活授权原则上存在竞争窗口;重复运行中未出现错过,脆弱环境仍是记录的包限制而非第二套机制,因为浏览后端仍是原生选择不可信场景在组合层面的答案。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.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/bug-fix/2026-09-09-composer-placeholder-whitespace.md
+2026-09-09-composer-placeholder-whitespace.md: c5d778f8fb1fdfc742636819a9ff27ddeeb0234e
+2026-09-09-composer-placeholder-whitespace.zh.md: b5f82bcd86f8567976bba96095d4de9d42bae9b1

+ 21 - 0
.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md

@@ -0,0 +1,21 @@
+# Agent Note: Composer placeholder emptiness
+
+Status: implemented
+
+English | [中文](2026-09-09-composer-placeholder-whitespace.zh.md)
+
+## Problem
+
+Sharing the whitespace-trimmed submission check with placeholder rendering leaves guidance drawn over a draft containing spaces.
+
+## Decision
+
+The Composer hides its placeholder whenever the raw draft is nonempty. Submission keeps its trimmed-content check. Attachments and claimed commands retain their existing placeholder suppression.
+
+## Alternatives considered
+
+**Reuse the submission check.** Whitespace has no sendable message content, but it occupies the editor and moves its caret. A shared check conflates these two states.
+
+## Consequences
+
+All placeholder variants, including queued-message steering guidance, disappear after whitespace input and return after deletion. A whitespace-only draft without attachments remains unsendable. [Component tests](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) cover visibility, composition, rerendering and submission; the [browser regression](../../../../apps/web/tests/composer-placeholder.e2e.ts) checks keyboard and clipboard gestures against built UI.

+ 21 - 0
.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md

@@ -0,0 +1,21 @@
+# Agent Note: Composer 占位提示的判空规则
+
+Status: implemented
+
+[English](2026-09-09-composer-placeholder-whitespace.md) | 中文
+
+## Problem
+
+占位提示复用去除首尾空白后的提交判断,会让提示文字覆盖已经包含空格的草稿。
+
+## Decision
+
+原始草稿非空时,Composer 隐藏占位提示。提交仍检查去除首尾空白后的内容。附件和已认领指令沿用现有的占位提示隐藏规则。
+
+## Alternatives considered
+
+**复用提交判断。** 空白字符没有可发送的消息内容,但会占据编辑器并移动光标。共用判断会混淆这两种状态。
+
+## Consequences
+
+所有占位提示,包括排队消息的插话提示,都会在输入空白字符后隐藏,删除后恢复。没有附件的纯空白草稿仍无法发送。[组件测试](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) 覆盖显示、输入法组合、重新渲染和提交;[浏览器回归](../../../../apps/web/tests/composer-placeholder.e2e.ts) 使用构建后的界面检查键盘和剪贴板操作。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
-2026-09-08-present-workspace-source-files.md: 6239c9bd920849f3c8cf4fdee2e1ded4b758b01d
-2026-09-08-present-workspace-source-files.zh.md: 7aa5bebc97558b9b0cb74406303d038483c39d94
+2026-09-08-present-workspace-source-files.md: 6a8ebfcf1fd7be22a5bda5a209d0fdc3586931bb
+2026-09-08-present-workspace-source-files.zh.md: d9361f2281eeb027e4c44b84c7f01af1639b66f2

+ 3 - 1
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md

@@ -12,9 +12,11 @@ Users need to open and edit the files produced in their workspace, including she
 
 The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular files inside the calling Session's workspace. It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them.
 
+The tool description requires `present` after writing a file the user asked to receive and before the final response, including files created through Bash or code execution. A prose path reference does not replace the call. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) uses a user request that does not name `present`, and checks the resulting file, delivery event, and card. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations.
+
 The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results.
 
-An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The existing produced-file row retains its separate text-preview behavior.
+An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery.
 
 ## Alternatives considered
 

+ 3 - 1
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md

@@ -12,9 +12,11 @@ Status: implemented
 
 [present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付调用方 Session 工作区中已存在的普通文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。
 
+工具说明要求在写好用户要求接收的文件后、最终回复前调用 `present`,包括通过 Bash 或代码执行创建的文件。正文中的路径引用不能替代调用。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)使用未提及 `present` 的用户请求,检查生成文件、交付事件和卡片。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。
+
 工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。
 
-经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。原有产出文件行保留独立的文本预览行为。
+经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。
 
 ## 考虑过的替代方案
 

+ 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;
 # 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
-2026-07-24-web-gui-browser-e2e-lane.md: 07a37ada9c2a43f04612048f9bff6b22d022ec40
-2026-07-24-web-gui-browser-e2e-lane.zh.md: 668e612712175821d6ad123ce364a7cb96e01272
+2026-07-24-web-gui-browser-e2e-lane.md: 8276ed1982a7472ba1b825837a933058c32bf840
+2026-07-24-web-gui-browser-e2e-lane.zh.md: 3a7e5ea5547cf5584a2ad3f61b90c91326379de5

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

@@ -26,6 +26,8 @@ Keyless model displacement is the disabled adapter row plus `installLlmReplay` f
 
 The barrier stack for replay-mode browser assertions is, in order: (1) host-side `await agent.whenIdle()` under a timeout, keyed off the in-process `turn/end` — the idle flip follows the persistence flush, so one await covers turn completion and durability; (2) browser settled poll (streaming detached, final text visible). Record-mode log harvest runs after `whenIdle()` and before scaffold disposal while the live session remains available. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync); polling persistence files as a turn-completion or durability barrier is banned (slow on NFS, superseded by `whenIdle`), while a tool-controlled temp readiness marker may be polled only as an interaction gate before that completion barrier; `networkidle` is banned outright (never resolves while an SSE stream is open). Navigation assertions arm both initial `session.list` and `workspace.list` responses before page load, then wait for the seeded DOM projection; the mounted shell alone is not readiness because late bootstrap can replace controlled state.
 
+Layout assertions wait for loaded fonts, completed frame transitions, and the Conversation width publication before measuring; a synthetic resize also waits for the scheduled React update before reading a portaled panel. Directory-tree reads use the same Remote-read budget for child and root listings. Workspace reload scenarios wait for restored Session selection and composer focus before opening another path editor, because that late focus can cancel its draft. Responsive file-chip scenarios keep their measured lane inside a container-query band with explicit margin for platform font metrics.
+
 No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted through the ordered `agent/assistant-stream` follow path, while the final durable `assistant/message` or `assistant/attempt` embeds the exact stream used for replay. `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly.
 
 Pagination drivers wait for the interactive load row to leave its pending state and record the pre-request row count before scrolling. An immediately committed resident page therefore remains observable instead of becoming the baseline for a request that the scroll gesture does not repeat.

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

@@ -10,6 +10,8 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
 
 ## 决策
 
+布局断言先等待字体加载、框架过渡完成及 Conversation 宽度发布,再读取尺寸;触发合成 resize 后,还需等待其调度的 React 更新,才能测量通过 portal 挂载的面板。目录树的子目录和根目录读取使用相同的 Remote 等待预算。工作区重载场景在打开另一个路径编辑器前,等待 Session 选择恢复及输入框获得焦点,因为迟到的聚焦会取消路径草稿。响应式文件标签场景将实测通道宽度放在容器查询档位内部,并为平台字体度量保留明确余量。
+
 `pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放;用户可见状态使用规范化的 aria 预期输出,持久化的世界状态则使用进程内断言。配套的产品约定包括 `dsh-llm-replay` 的节奏控制、消费检查与已校验的索引式覆写 patch;跨包的 `dsh-llm` 失败通过自有数据属性保留经校验的提供方信息;已交付的 web 组合挂载 `llm-retry`,以处理瞬态模型失败。
 
 ### Scaffold:`apps/web/tests/scaffold.ts`

+ 2 - 2
.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
-2026-09-06-backend-continuation-performance.md: 62d8af10015cc2da7b399aae3c9d197f75931753
-2026-09-06-backend-continuation-performance.zh.md: 7c8904fa019b27362e2cdb0e50697921913e5d09
+2026-09-06-backend-continuation-performance.md: 94e9192df473515d39014c8be9e82e0413df47bc
+2026-09-06-backend-continuation-performance.zh.md: 92556ad8e3434a3219ea585503bec9b17bbf576a

+ 1 - 1
.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md

@@ -25,7 +25,7 @@ The tool execution pipeline, request preparation, Session projections required b
 
 The SDK fixture explicitly inserts `fs-local` and `str_replace_editor` through its profile patch. This preserves the calibrated file-view workload independently of the [minimal profile's shell-only defaults](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.md). File reads, timing endpoints, and budgets remain the same.
 
-Five samples report raw wall time, CPU user/system time, peak RSS, endpoint counts, and the minimum, median, and maximum total wall time. Budgets enforce the unrounded median. Continuation additionally measures retained heap against an initialized Host: two explicit GCs separated by an event-loop yield precede and follow the timed operation, while the idle Agent remains reachable. The measured delta therefore includes the resident historical Session and live additions, not just newly appended turns. GC and teardown are outside timing; flush is inside. Request-history retention starts after resume and is diagnostic only. Catalog peak RSS is diagnostic; no retained-heap budget claims to measure already-released child observations.
+Five samples report raw wall time, CPU user/system time, peak RSS, endpoint counts, and the minimum, median, and maximum total wall time. Each aggregate report also records CPU models, available parallelism, platform, architecture, and Node/V8 versions after the timed workers exit. Budgets enforce the unrounded median. Continuation additionally measures retained heap against an initialized Host: two explicit GCs separated by an event-loop yield precede and follow the timed operation, while the idle Agent remains reachable. The measured delta therefore includes the resident historical Session and live additions, not just newly appended turns. GC and teardown are outside timing; flush is inside. Request-history retention starts after resume and is diagnostic only. Catalog peak RSS is diagnostic; no retained-heap budget claims to measure already-released child observations.
 
 The parent bounds every child to 60 seconds, checks timeout, signal, exit, and report independently, awaits process close, and removes private roots after failures. Context and Agent teardown run in finally blocks. Seed processes cannot warm the measured process's caches. Filesystem caches are not forcibly evicted: cold means a fresh process, not cold physical storage.
 

+ 1 - 1
.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md

@@ -25,7 +25,7 @@ Status: implemented
 
 SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor`。这使经校准的文件查看负载不依赖[极简 profile 只提供 shell 的默认组合](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md)。文件读取、计时终点和预算保持不变。
 
-五个样本报告原始壁钟时间、CPU 用户态/内核态时间、峰值 RSS、终点计数及总壁钟时间的最小值、中位数和最大值。预算约束未经舍入的中位数。续聊还相对已初始化 Host 测量保留堆内存:计时操作前后各执行两次显式 GC,中间让出一次事件循环,空闲 Agent 始终可达。因此该增量包含常驻历史 Session 和实时追加,而不只是新轮次。GC 与资源释放不计时;flush 计时。请求历史的内存基线从恢复后开始,只作诊断。目录峰值 RSS 仅作诊断;没有保留堆预算声称衡量已经释放的子会话观察。
+五个样本报告原始壁钟时间、CPU 用户态/内核态时间、峰值 RSS、终点计数及总壁钟时间的最小值、中位数和最大值。每份汇总报告还在计时 worker 退出后记录 CPU 型号、可用并行度、平台、架构以及 Node/V8 版本。预算约束未经舍入的中位数。续聊还相对已初始化 Host 测量保留堆内存:计时操作前后各执行两次显式 GC,中间让出一次事件循环,空闲 Agent 始终可达。因此该增量包含常驻历史 Session 和实时追加,而不只是新轮次。GC 与资源释放不计时;flush 计时。请求历史的内存基线从恢复后开始,只作诊断。目录峰值 RSS 仅作诊断;没有保留堆预算声称衡量已经释放的子会话观察。
 
 父进程为每个子进程设置 60 秒上限,独立检查超时、信号、退出状态和报告,等待进程关闭,并在失败后删除私有根目录。Context 和 Agent 在 finally 中释放。播种进程无法预热被测进程的缓存。不强制清除文件系统缓存:冷指新进程,不指冷物理存储。
 

+ 6 - 0
.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.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/testing/2026-09-08-ci-completion-observations.md
+2026-09-08-ci-completion-observations.md: 8af4685a5e2c51c1edf4a41b47088916223e7a6c
+2026-09-08-ci-completion-observations.zh.md: e3147bb7ac39877b46820862e9af4645803a37a2

+ 45 - 0
.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md

@@ -0,0 +1,45 @@
+# Agent Note: CI fixture completion and isolation
+
+Status: implemented
+
+English | [中文](2026-09-08-ci-completion-observations.zh.md)
+
+## Problem
+
+The [reference CI run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34206953049) reports a webhook-created Session absent after a one-second poll and empty PowerShell output before a five-second read deadline. HTTP acceptance, projected UI state, process startup, and durable completion are separate observations. Tests need an explicit completion condition and controls that prevent an intermediate state from satisfying it. The [completion-wait decision](2026-09-08-ci-readiness-and-completion.md) owns those conditions and lane budgets; these fixtures make their ordering and cleanup observable under controlled delays.
+
+## Decision
+
+The [GitHub review browser test](../../../../apps/web/tests/github-ready-review.e2e.ts) holds real Workspace creation after HTTP 202, verifies that neither the Agent nor the model request exists, then releases creation and awaits the matching Session's `turn/end`. Cleanup releases the barrier, restores the method, and removes the event listener even when the test times out. Workspace membership, request counts, prompt content, and browser expectations retain their original assertions.
+
+The [PowerShell executor tests](../../../../packages/shell/pwsh-local/tests/executor.spec.ts) hold startup and consuming reads at private file barriers. The test controls when later output becomes available; final stdin/environment output is read after `done`. Polling uses the active test budget, and every constructed Context is registered before plugin initialization. Teardown captures Contexts and directories before awaiting disposal and removes directories only after that disposal completes.
+
+The [queued-image test](../../../../apps/web/tests/queue-image.e2e.ts) separately holds admission and attachment retrieval, then captures the admitted row's loaded thumbnail. Cleanup shares one promise, releases held requests, and drains their handlers before closing the browser.
+
+The [Details Session-lifecycle test](../../../../apps/web/tests/details-session-lifecycle.e2e.ts) awaits the frame's captured animation promises after closed state appears, then checks the zero-width track. Cancelled transitions also reach that assertion; animation settlement cannot make a persistent nonzero track pass.
+
+The [whole-queue steering test](../../../../apps/web/tests/steering.e2e.ts) waits for enabled steering actions and the composer's queue-steering hint. A model-stream barrier keeps the following question-composer takeover pending while the test observes steering. Teardown releases that barrier before browser closure.
+
+The [workspace-management test](../../../../apps/web/tests/workspace-management.e2e.ts) waits for restored composer focus before the next directory-dialog gesture. Its archive case gives the known seed id an explicit user title through the Session controller, then uses that exact title to identify the row across reload. An unrelated restored row cannot satisfy that locator; the durable archive assertion still checks the seed id and retained log.
+
+The [worker budget tests](../../../../packages/code-runtime/code-runtime-worker-thread/tests/budget.spec.ts) retain real worker execution and binding transport while controlling host timers and ELU samples. They acknowledge binding entry before exercising idle, active, and wall-clock decisions, so a bootstrap timeout cannot stand in for a budget decision during a binding. The [real-worker tests](../../../../packages/code-runtime/code-runtime-worker-thread/tests/runtime.spec.ts) independently retain actual ELU, idle-binding, and hot-loop coverage.
+
+The [detached-launch tests](../../../../packages/host/open-in-app/tests/launch-detached.spec.ts) control watch time and deliver late process events through the real launcher's registered callbacks. They check one settlement, one unref, and no child kill. Real-process environment and early-exit cases remain in the [resolver tests](../../../../packages/host/open-in-app/tests/resolver.spec.ts).
+
+The [LSP backpressure test](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts) preserves the real paused-reader fixture and large native pipe write. Before accepting the abort error, it verifies that the pending write callback settled and the captured subprocess completed; `instance.dead` alone can be true as soon as disposal starts.
+
+### Built-client import classification
+
+The [Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts) admits the Dockkit bundle only when Node reports `ERR_UNKNOWN_FILE_EXTENSION` for its exact `dockkit.module.css` path. Other errors and unexpectedly successful exempt imports fail. Scoped resolve/load hooks exercise expected CSS failure, arbitrary failure, another stylesheet, another error code, and stale exemption without modifying shared build artifacts.
+
+## Alternatives considered
+
+**Production timeouts, retries, or suite serialization.** Rejected because none establishes the missing completion observation.
+
+**Completion inferred from acceptance or a preview.** HTTP 202 and an optimistic image can precede the operation being asserted.
+
+**Controlled samples replacing measured worker coverage.** Rejected because they omit verification of Node's actual ELU and transport behavior.
+
+## Consequences
+
+Each fixture owns its clocks, barriers, callbacks, processes, and temporary paths. Controlled observations supplement real worker, subprocess, browser, and persistence paths. Product behavior, production timing, benchmark budgets, CI scheduling, and recorded expectations remain unchanged.

+ 45 - 0
.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md

@@ -0,0 +1,45 @@
+# Agent Note: CI fixture 的完成与隔离
+
+Status: implemented
+
+[English](2026-09-08-ci-completion-observations.md) | 中文
+
+## 问题
+
+[参考 CI 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34206953049)报告:轮询一秒后 webhook 创建的 Session 仍不存在,五秒读取期限内 PowerShell 输出为空。HTTP 接受、UI 投影状态、进程启动和持久化完成是不同的观察。测试需要明确的完成条件,并用对照阻止中间状态满足该条件。[完成等待决策](2026-09-08-ci-readiness-and-completion.zh.md)拥有这些条件与 lane 预算;这些 fixture 通过受控延迟使顺序与清理可观察。
+
+## 决策
+
+[GitHub 评审浏览器测试](../../../../apps/web/tests/github-ready-review.e2e.ts)在 HTTP 202 后阻塞真实 Workspace 创建,验证 Agent 和模型请求均不存在,再释放创建并等待对应 Session 的 `turn/end`。即使测试超时,清理也会释放屏障、恢复方法并移除事件监听器。Workspace 归属、请求数量、提示词内容和浏览器预期保留原有断言。
+
+[PowerShell 执行器测试](../../../../packages/shell/pwsh-local/tests/executor.spec.ts)用私有文件屏障控制启动与消费式读取。测试决定后续输出何时可用;最终 stdin/环境变量输出在 `done` 后读取。轮询使用当前测试预算,每个创建的 Context 都在插件初始化前登记。清理在等待释放前同时取得 Context 与目录,完成释放后才删除目录。
+
+[排队图片测试](../../../../apps/web/tests/queue-image.e2e.ts)分别阻塞接纳和附件读取,再捕获已接纳行中加载完成的缩略图。清理共享一个 Promise,释放保留的请求,并在关闭浏览器前等待其 handler 完成。
+
+[详情 Session 生命周期测试](../../../../apps/web/tests/details-session-lifecycle.e2e.ts)在关闭状态出现后等待框架已捕获的动画 Promise,再检查轨道宽度为零。取消的过渡同样进入该断言;动画结束不能让持续非零的轨道通过。
+
+[整队列 steering 测试](../../../../apps/web/tests/steering.e2e.ts)等待 steering 操作可用以及 composer 显示队列 steering 提示。模型流屏障在测试观察 steering 时阻止后续问题 composer 接管。清理在关闭浏览器前释放该屏障。
+
+[Workspace 管理测试](../../../../apps/web/tests/workspace-management.e2e.ts)在下一次目录对话框操作前等待恢复后的 composer 焦点。归档用例通过 Session controller 为已知 seed id 设置显式用户标题,再用该精确标题跨重载定位行。无关的恢复行无法匹配该定位器;持久化归档断言仍检查 seed id 和保留的日志。
+
+[Worker 预算测试](../../../../packages/code-runtime/code-runtime-worker-thread/tests/budget.spec.ts)保留真实 worker 执行与绑定传输,只控制 Host 定时器和 ELU 样本。测试先确认绑定已进入,再检验 idle、active 和壁钟决策,使启动超时不能冒充绑定期间的预算决策。[真实 worker 测试](../../../../packages/code-runtime/code-runtime-worker-thread/tests/runtime.spec.ts)独立保留实际 ELU、空闲绑定和热循环覆盖。
+
+[分离启动测试](../../../../packages/host/open-in-app/tests/launch-detached.spec.ts)控制观察时间,并通过真实 launcher 登记的回调发送迟到进程事件。测试检查仅完成一次、仅 unref 一次且不终止子进程。[Resolver 测试](../../../../packages/host/open-in-app/tests/resolver.spec.ts)保留真实进程的环境变量和提前退出用例。
+
+[LSP 背压测试](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts)保留真实暂停读取的 fixture 与大型原生管道写入。接受 abort 错误前,测试验证待处理写入回调已完成、捕获的子进程也已结束;`instance.dead` 在释放开始时就可能为真。
+
+### 已构建 Client 的导入分类
+
+[Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts)只有在 Node 针对准确的 `dockkit.module.css` 路径报告 `ERR_UNKNOWN_FILE_EXTENSION` 时才接受 Dockkit bundle。其他错误以及意外成功的豁免导入都会失败。限定范围的 resolve/load hook 覆盖预期 CSS 失败、任意失败、其他 stylesheet、其他错误码和过期豁免,不修改共享构建产物。
+
+## 考虑过的替代方案
+
+**生产超时、重试或套件串行化。** 拒绝,因为均不能建立缺少的完成观察。
+
+**从接受或预览推断完成。** HTTP 202 和乐观图片可能早于被断言的操作。
+
+**用受控样本替换实测 worker 覆盖。** 拒绝,因为会遗漏对 Node 实际 ELU 与传输行为的验证。
+
+## 影响
+
+每个 fixture 拥有自己的时钟、屏障、回调、进程和临时路径。受控观察补充真实 worker、子进程、浏览器和持久化路径。产品行为、生产时序、基准预算、CI 调度和录制预期均保持不变。

+ 2 - 5
.github/review-ownership/check-approval.mjs

@@ -8,7 +8,6 @@ const API_VERSION = '2026-03-10'
 const MAX_PULL_REQUEST_REVIEWS = 3_000
 const PAGE_SIZE = 100
 const STATUS_CONTEXT = 'weighted approval'
-const STATUS_PREFIX = 'This is by automated Angry Turtle Cyborg, not a human'
 const WRITABLE_PERMISSIONS = new Set(['admin', 'write'])
 const REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'])
 const LOGIN = /^[A-Za-z0-9-]+(?:\[bot\])?$/u
@@ -192,12 +191,11 @@ export async function evaluateApproval({ event, policySource, api }) {
  */
 export async function runApprovalCheck({ event, policySource, api, runUrl, write = line => process.stdout.write(`${line}\n`) }) {
   const pull = pullRequestFromEvent(event)
-  write(STATUS_PREFIX)
   let result
   try {
     result = await evaluateApproval({ event, policySource, api })
   } catch (error) {
-    await publishStatus(api, pull, 'error', `${STATUS_PREFIX}: approval evaluation failed.`, runUrl)
+    await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl)
     throw error
   }
   write(`Approval score: ${result.points}/${result.requiredPoints}.`)
@@ -239,7 +237,7 @@ function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReview
   return {
     pull: { repository: pull.repository, number: pull.number, headSha: pull.headSha },
     state,
-    description: `${STATUS_PREFIX}: ${detail}.`,
+    description: `${detail}.`,
     points: approvals.reduce((total, approval) => total + approval.points, 0),
     requiredPoints,
     approvals,
@@ -355,7 +353,6 @@ async function main() {
       api,
     })
     if (resolved === null) {
-      process.stdout.write(`${STATUS_PREFIX}\n`)
       process.stdout.write('Skipped a review event for a superseded pull-request head.\n')
       return
     }

+ 3 - 2
.github/review-ownership/check-approval.test.mjs

@@ -266,12 +266,12 @@ test('publishes the required status and replaces stale success with error on eva
       body: {
         state: 'success',
         context: 'weighted approval',
-        description: 'This is by automated Angry Turtle Cyborg, not a human: 2/2 approval points.',
+        description: '2/2 approval points.',
         target_url: 'https://github.example/actions/runs/1',
       },
     },
   })
-  assert.equal(output[0], 'This is by automated Angry Turtle Cyborg, not a human')
+  assert.equal(output[0], 'Approval score: 2/2.')
 
   const failures = []
   await assert.rejects(runApprovalCheck({
@@ -289,6 +289,7 @@ test('publishes the required status and replaces stale success with error on eva
     write: () => {},
   }), /reviews unavailable/u)
   assert.equal(failures[0].options.body.state, 'error')
+  assert.equal(failures[0].options.body.description, 'Approval evaluation failed.')
 })
 
 test('sends authenticated JSON and escapes an API error body', async () => {

+ 1 - 3
.github/workflows/weighted-approval-review-event.yml

@@ -14,6 +14,4 @@ jobs:
     timeout-minutes: 2
     steps:
       - name: Record review event
-        run: |
-          echo 'This is by automated Angry Turtle Cyborg, not a human'
-          echo 'Recorded a weighted approval review event.'
+        run: echo 'Recorded a weighted approval review event.'

+ 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;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/web/tests/README.md
-README.md: e5b97f0b7527da01ce7c926360145fee49e168f0
-README.zh.md: f360c2b487bbf5b1a1c81492e5b4c2d4eaa8d749
+README.md: a3503e66d780e62562348b02830517381d21df92
+README.zh.md: a9039bbd0444a0786cd419c1e6aba0c3504e26d3

+ 4 - 0
apps/web/tests/README.md

@@ -8,6 +8,10 @@ the deliberate composition divergences from `dsh web` — are documented in
 [`scaffold.ts`](scaffold.ts) and the
 [browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
 
+## Completion observations
+
+State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. See the [CI fixture synchronization decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md).
+
 ## These are Host-face tests
 
 They type-check in the root `tsconfig.host.json`, not in the Client aggregate,

+ 4 - 0
apps/web/tests/README.zh.md

@@ -7,6 +7,10 @@
 [`scaffold.ts`](scaffold.ts) 和
 [浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)中。
 
+## 完成状态观察
+
+依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。参见 [CI fixture 同步决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。
+
 ## 这些是 Host 面的测试
 
 它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取

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

@@ -348,7 +348,7 @@ describe('web e2e: clickable links gallery', () => {
     const mentions = markdown.locator('code button')
     expect(await mentions.count()).toBe(1)
     expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
-    expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
+    expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)
     expect(await page.locator('[class*="centerCol"] button[aria-label^="Open "]').count()).toBeGreaterThanOrEqual(5)
     expect(await page.locator('button[aria-label="Open c/broken.css"]').count()).toBe(0)
 

+ 72 - 0
apps/web/tests/composer-placeholder.e2e.ts

@@ -0,0 +1,72 @@
+// The built shared Web/Electron composer hides guidance as soon as a draft contains whitespace.
+import { fileURLToPath } from 'node:url'
+import { chromium, type Page } from 'playwright'
+import { expect, it } from 'vitest'
+import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+it('hides the placeholder for typed and pasted spaces and restores it after deletion', async () => {
+  const scaffold = await launchWebScaffold({})
+  try {
+    const browser = await chromium.launch()
+    let failurePage: Page | undefined
+    try {
+      const page = await newEnglishPage(browser)
+      failurePage = page
+      const tripwire = watchConsole(page)
+      await page.goto(scaffold.authenticatedUrl)
+      await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-placeholder')
+      const input = page.locator('[data-composer-input][contenteditable="true"]').first()
+      const placeholder = page.locator('[data-composer-placeholder]').first()
+      const observations: string[] = []
+      const observe = async (label: string, visible: boolean) => {
+        await expect.poll(() => placeholder.isVisible()).toBe(visible)
+        observations.push(`- ${label}: placeholder ${visible ? 'visible' : 'hidden'}`)
+      }
+      const clear = async () => {
+        await input.click()
+        await page.keyboard.press('ControlOrMeta+KeyA')
+        await page.keyboard.press('Backspace')
+      }
+      await observe('Empty draft', true)
+      await input.click()
+      await page.keyboard.press('Space')
+      await observe('Single space', false)
+      await page.keyboard.press('Space')
+      await page.keyboard.press('Space')
+      await observe('Consecutive spaces', false)
+      await page.keyboard.press('Tab')
+      await input.click()
+      await observe('Focus restored', false)
+      const draftMarkup = await input.innerHTML()
+      await page.keyboard.press('Enter')
+      expect(await input.innerHTML()).toBe(draftMarkup)
+      await observe('Whitespace submission rejected', false)
+      await clear()
+      await observe('All content deleted', true)
+      await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
+      await page.evaluate(() => navigator.clipboard.writeText('   '))
+      await page.keyboard.press('ControlOrMeta+KeyV')
+      await expect.poll(() => input.textContent()).toBe('   ')
+      await observe('Pasted spaces', false)
+      await clear()
+      await observe('Pasted content deleted', true)
+      await assertFixtureInventory(
+        fileURLToPath(new URL('./expected/composer-placeholder', import.meta.url)), ['visibility.expected.md'],
+      )
+      expect(tripwire.pageErrors).toEqual([])
+      expect(tripwire.warnings).toEqual([])
+      await compareOrRefreshGolden(
+        fileURLToPath(new URL('./expected/composer-placeholder/visibility.expected.md', import.meta.url)),
+        observations.join('\n'), webSnapshotMode(),
+      )
+    } catch (error) {
+      if (failurePage !== undefined) await saveFailureShot(failurePage, 'web-e2e-composer-placeholder')
+      throw error
+    } finally {
+      await browser.close()
+    }
+  } finally {
+    await scaffold.close()
+  }
+})

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

@@ -233,6 +233,10 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     const close = async (): Promise<void> => {
       await column.locator('[data-sidebar-right-toggle]').click()
       await expect.poll(() => column.locator('[data-sidebar-right-open]').count()).toBe(0)
+      // Closing publishes state before the frame's grid transition finishes.
+      await appFrame(page).evaluate(async (frame) => {
+        await Promise.allSettled(frame.getAnimations().map(animation => animation.finished))
+      })
       await expect.poll(() => detailsTrack(page)).toBe(0)
       await panel.waitFor({ state: 'hidden' })
     }
@@ -279,7 +283,8 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     await workspaceDirectory.waitFor({ timeout: 15_000 })
     await workspaceDirectory.click()
     await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('true')
-    await expect.poll(() => column.locator('[data-files-row="loading"]').count()).toBe(0)
+    // The child listing crosses the same Remote as the root listing above.
+    await column.locator('[data-files-row="loading"]').waitFor({ state: 'hidden', timeout: 15_000 })
     expect(await column.locator('[data-files-row="failed"]').count()).toBe(0)
     const retainedB = await paneSnapshot(page)
     expect(retainedB.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files']])

+ 1 - 1
apps/web/tests/expected/clickable-links-gallery/ui.expected.md

@@ -169,7 +169,7 @@
 - list:
   - listitem:
     - paragraph: Footnote references stay inert superscripts. ↩
-- text: Produced
+- text: Files changed
 - button "Open site/report.html": report.html
 - button "Open a/style.css": style.css
 - button "Open b/style.css": style.css

+ 8 - 0
apps/web/tests/expected/composer-placeholder/visibility.expected.md

@@ -0,0 +1,8 @@
+- Empty draft: placeholder visible
+- Single space: placeholder hidden
+- Consecutive spaces: placeholder hidden
+- Focus restored: placeholder hidden
+- Whitespace submission rejected: placeholder hidden
+- All content deleted: placeholder visible
+- Pasted spaces: placeholder hidden
+- Pasted content deleted: placeholder visible

+ 20 - 1
apps/web/tests/github-ready-review.e2e.ts

@@ -6,7 +6,7 @@ import type { AddressInfo } from 'node:net'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest'
 import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
 import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import type {} from '@deepseek-ai/dsh-webhook'
@@ -147,10 +147,29 @@ describe.skipIf(MODE === 'record')('web e2e: GitHub ready-for-review', () => {
         && event.data.source.deliveryId === 'ready' && event.data.source.ruleId === 'review-pr-when-ready') reviewSession = session.id
       if (event.type === 'turn/end' && session.id === reviewSession) completed.resolve(undefined)
     })
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    const createWorkspace = scaffold.ctx.workspaceRegistry.create.bind(scaffold.ctx.workspaceRegistry)
+    const create = vi.spyOn(scaffold.ctx.workspaceRegistry, 'create').mockImplementationOnce(async (...args) => {
+      entered.resolve(undefined)
+      await release.promise
+      return await createWorkspace(...args)
+    })
+    onTestFinished(() => {
+      off()
+      release.resolve(undefined)
+      create.mockRestore()
+    })
     try {
       expect((await send(webhookOrigin, 'ready', payload)).status).toBe(202)
+      await entered.promise
+      expect(scaffold.ctx.agents.list()).toHaveLength(before)
+      expect(adapter.requests).toHaveLength(0)
+      release.resolve(undefined)
       await completed.promise
     } finally {
+      release.resolve(undefined)
+      create.mockRestore()
       off()
     }
     expect(scaffold.ctx.agents.list()).toHaveLength(before + 1)

+ 121 - 0
apps/web/tests/present-svg.e2e.ts

@@ -0,0 +1,121 @@
+/** A file request elicits explicit SVG delivery without naming the present tool. */
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import type {} from '@deepseek-ai/dsh-tool-present/types'
+import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
+import {
+  assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria, compareOrRefreshGolden,
+  fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole,
+  webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
+
+const DIR = fileURLToPath(new URL('../../../snapshots/web/present-svg', import.meta.url))
+const FIXTURE = join(DIR, 'session.v3.jsonl')
+const MODE = webSnapshotMode()
+const PROMPT = '简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg'
+const FILE = 'von-neumann.svg'
+
+describe('web e2e: requested SVG is explicitly delivered', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  let cwd: string
+  let replayRoot: string | undefined
+
+  beforeAll(async () => {
+    let replayOverride: string | undefined
+    if (MODE !== 'record') {
+      replayRoot = await mkdtemp(join(tmpdir(), 'dsh-present-svg-replay-'))
+      replayOverride = join(replayRoot, 'replay.override.json')
+      const script = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
+      // Recorded absolute paths must follow each isolated Session's working directory.
+      const cwdToken = '{{fromRequest:Your working directory is ([^\\n]+)\\.}}'
+      await writeFile(replayOverride, JSON.stringify(script).replaceAll('{{cwd}}', JSON.stringify(cwdToken).slice(1, -1)))
+    }
+    scaffold = await launchWebScaffold({
+      compareReplaySession: true,
+      ...(replayOverride === undefined ? {} : { replayFixture: FIXTURE, replayOverride }),
+    })
+    browser = await chromium.launch()
+    page = await browser.newPage({
+      viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai',
+    })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]')
+    await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
+  })
+
+  afterAll(async () => {
+    try {
+      await browser?.close()
+    } finally {
+      try {
+        await scaffold?.close()
+      } finally {
+        if (replayRoot !== undefined) await rm(replayRoot, { recursive: true, force: true })
+      }
+    }
+  })
+
+  it('writes valid SVG and calls present before the final reply', async () => {
+    if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+    const settled = scaffold.whenTurnSettled()
+    const input = page.locator('[data-composer-input]').first()
+    await input.fill(PROMPT)
+    await input.press('Enter')
+    const sessionId = await settled
+    const session = scaffold.ctx.agents.get(sessionId)?.session
+    if (session?.header.cwd === undefined) throw new Error('SVG Session has no workspace')
+    cwd = session.header.cwd
+    if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
+
+    const svg = await readFile(join(cwd, FILE), 'utf8')
+    const document = await page.evaluate((source) => {
+      const parsed = new DOMParser().parseFromString(source, 'image/svg+xml')
+      return {
+        root: parsed.documentElement.localName,
+        namespace: parsed.documentElement.namespaceURI,
+        errors: parsed.querySelectorAll('parsererror').length,
+      }
+    }, svg)
+    expect(document).toEqual({ root: 'svg', namespace: 'http://www.w3.org/2000/svg', errors: 0 })
+
+    const events = session.snapshotEvents()
+    const declarations = events.filter(event => event.type === 'deliverables/presented')
+    const delivery = declarations.find(event => event.data.files.some(file => resolve(cwd, file.path) === join(cwd, FILE)))
+    expect(delivery, 'the file request must produce a successful present declaration').toBeDefined()
+    if (delivery === undefined) throw new Error('SVG was written but not delivered')
+    expect(events.some(event => (
+      event.type === 'tool/call' && event.data.name === 'present' && event.data.callId === delivery.data.callId
+    ) || (
+      event.type === 'tool/ptc-dispatch' && event.data.name === 'present'
+      && event.data.subCallId === delivery.data.callId && !event.data.isError
+    ))).toBe(true)
+    expect(events.some(event => event.type === 'assistant/message' && event.seq > delivery.seq
+      && event.data.message.content.some(block => block.type === 'text'))).toBe(true)
+
+    const card = page.locator('[data-presented-files-row]').getByRole('button').filter({ hasText: FILE })
+    await card.waitFor({ state: 'visible' })
+    expect(await card.count()).toBe(1)
+    expect(await page.getByText('产物', { exact: true }).count()).toBe(0)
+    if (await page.locator('[data-produced-files-row]').count() > 0) {
+      expect(await page.getByText('本轮文件改动', { exact: true }).count()).toBe(1)
+    }
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+
+  it.skipIf(MODE === 'record')('replays the delivered file and Chinese conversation', async () => {
+    await assertFinalWorkspaceSnapshot(DIR, cwd)
+    // Delivery owns the transcript; navigation and composer chrome have separate scenarios.
+    const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
+  })
+})

+ 1 - 1
apps/web/tests/produced-file-mentions.e2e.ts

@@ -159,7 +159,7 @@ describe('web e2e: inline-code mentions of produced files', () => {
     expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html')
     expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
     // The turn still ends with its produced-files row (all three writes).
-    expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
+    expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)
 
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])

+ 19 - 5
apps/web/tests/produced-files.e2e.ts

@@ -146,16 +146,30 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
     await expect.poll(() => chips.count()).toBe(6)
     await expect.poll(() => row.getByText('+ 4 files', { exact: true }).isVisible()).toBe(true)
 
-    await page.setViewportSize({ width: 780, height: 900 })
-    await expect.poll(() => chips.count()).toBe(5)
+    await page.setViewportSize({ width: 750, height: 900 })
+    await page.evaluate(async () => { await document.fonts.ready })
+    await page.waitForFunction(() => {
+      const frame = document.querySelector('[data-sidebar-collapsed][data-rightbar-collapsed]')
+      if (frame === null) return false
+      const tracks = getComputedStyle(frame).gridTemplateColumns.split(' ').map(Number.parseFloat)
+      // The responsive sidebar's settled collapsed track is 56px.
+      return tracks[0] === 56 && tracks.at(-1) === 0
+        && frame.getAnimations().every(animation =>
+          animation.playState === 'finished' || animation.playState === 'idle')
+    }, undefined, { timeout: 10_000 })
+    await expect.poll(() => chips.count()).toBe(4)
+    const laneWidth = await row.evaluate(element => element.clientWidth)
+    // Keep font-metric differences away from the 479px and 583px container-query edges.
+    expect(laneWidth).toBeGreaterThan(503)
+    expect(laneWidth).toBeLessThan(559)
     expect(await chips.nth(0).innerText()).toBe('关于我.md')
     expect(await chips.nth(1).innerText()).toBe('index.html')
-    expect(await chips.nth(4).innerText()).toBe('app.ts')
-    await expect.poll(() => row.getByText('+ 5 files', { exact: true }).isVisible()).toBe(true)
+    expect(await chips.nth(3).innerText()).toBe('styles.css')
+    await expect.poll(() => row.getByText('+ 6 files', { exact: true }).isVisible()).toBe(true)
     // Chips open in the right Sidebar's text preview, and a directory is not
     // something that preview can show, so the row offers no folder action.
     expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0)
-    expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
+    expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)
 
     const tops = await row.locator(':scope > *:visible').evaluateAll(elements =>
       elements.map(element => element.getBoundingClientRect().top))

+ 46 - 12
apps/web/tests/queue-image.e2e.ts

@@ -46,9 +46,12 @@ describe('web e2e: queued image submission', () => {
   let browser: Browser | undefined
   let page: Page
   let overrideDir: string | undefined
+  let cleanupRoutes: (() => Promise<void>) | undefined
 
   afterEach(async () => {
     const failures: unknown[] = []
+    await cleanupRoutes?.().catch((error: unknown) => failures.push(error))
+    cleanupRoutes = undefined
     await browser?.close().catch((error: unknown) => failures.push(error))
     browser = undefined
     const closing = scaffold
@@ -97,18 +100,49 @@ describe('web e2e: queued image submission', () => {
     await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
     await pasteImage(page, await readFile(PNG))
     await page.getByRole('img', { name: 'queued.png' }).waitFor({ timeout: 10_000 })
-    await input.fill(QUEUED_TEXT)
-    await input.press('Enter')
-
-    // Admission replaces the local preview; the durable row loads its own thumbnail.
-    await page.getByRole('button', { name: 'Remove queued message', disabled: false }).waitFor({ timeout: 15_000 })
-    const dockThumb = page.locator('[data-queue-dock] li:not([data-submission-echo]) img[alt="Queued message image"]')
-    await dockThumb.waitFor({ timeout: 15_000 })
-    await expect.poll(() => dockThumb.getAttribute('src')).toMatch(/^blob:/)
-    await expect.poll(() => dockThumb.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true)
-    await page.getByText(QUEUED_TEXT, { exact: true }).waitFor()
-    const queuedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
-    await compareOrRefreshGolden(QUEUED_EXPECTED, queuedSnapshot, MODE)
+    const releasePrompt = Promise.withResolvers<undefined>()
+    const releaseImage = Promise.withResolvers<undefined>()
+    let cleanupPromise: Promise<void> | undefined
+    const cleanup = (): Promise<void> => cleanupPromise ??= (async () => {
+      releasePrompt.resolve(undefined)
+      releaseImage.resolve(undefined)
+      await page.unrouteAll({ behavior: 'wait' })
+    })()
+    cleanupRoutes = cleanup
+    let imageRequested = false
+    await page.route('**/api/session/prompt', async (route) => {
+      await releasePrompt.promise
+      await route.continue()
+    })
+    await page.route('**/api/session/attachment', async (route) => {
+      imageRequested = true
+      await releaseImage.promise
+      await route.continue()
+    })
+    const dockThumb = page.locator('[data-queue-dock] img[alt="Queued message image"]')
+    try {
+      await input.fill(QUEUED_TEXT)
+      await input.press('Enter')
+      await dockThumb.waitFor({ timeout: 15_000 })
+      await expect.poll(() => dockThumb.getAttribute('src'), { timeout: 15_000 }).toMatch(/^blob:/)
+      expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(1)
+      releasePrompt.resolve(undefined)
+      await expect.poll(() => imageRequested, { timeout: 15_000 }).toBe(true)
+      await page.getByText(QUEUED_TEXT, { exact: true }).waitFor()
+      await page.getByRole('button', { name: 'Remove queued message', disabled: false }).waitFor({ timeout: 15_000 })
+      expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0)
+      expect(await dockThumb.count()).toBe(0)
+      releaseImage.resolve(undefined)
+      // Admission replaces the optimistic image; wait for the durable row's own thumbnail.
+      const durableThumb = page.locator('[data-queue-dock] li:not([data-submission-echo]) img[alt="Queued message image"]')
+      await durableThumb.waitFor({ timeout: 15_000 })
+      await expect.poll(() => durableThumb.getAttribute('src'), { timeout: 15_000 }).toMatch(/^blob:/)
+      await expect.poll(() => durableThumb.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true)
+      const queuedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+      await compareOrRefreshGolden(QUEUED_EXPECTED, queuedSnapshot, MODE)
+    } finally {
+      await cleanup()
+    }
 
     // Stop parks the accepted queue; the next waking send delivers the image
     // message first (FIFO), then its own text as the following turn.

+ 19 - 2
apps/web/tests/steering.e2e.ts

@@ -312,6 +312,8 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => {
 })
 
 describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
+  const releaseReplay = Promise.withResolvers<undefined>()
+  let disposeReplayBarrier: (() => void) | undefined
   let scaffold: WebScaffold
   let browser: Browser
   let page: Page
@@ -327,6 +329,10 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
       replayOverride: STEER_ALL_OVERRIDE,
       paceMs: REPLAY_PACE_MS,
     })
+    disposeReplayBarrier = scaffold.ctx.on('llm/stream', async function* (_options, next) {
+      await releaseReplay.promise
+      yield* next()
+    }, { prepend: true })
     scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
@@ -338,6 +344,8 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
   }, 120_000)
 
   afterAll(async () => {
+    releaseReplay.resolve(undefined)
+    disposeReplayBarrier?.()
     await browser?.close()
     await scaffold?.close()
   })
@@ -348,8 +356,8 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
     await input.waitFor({ timeout: 10_000 })
     const settled = scaffold.whenTurnSettled(30_000)
 
-    // Call 0 streams a question-tool call; the fills must land inside the
-    // first replay window, before the question composer replaces the textarea.
+    // Hold the question-tool stream until both rows have been steered, so
+    // question-composer takeover cannot race queue publication or the shortcut.
     await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
     await input.fill(PROMPT)
     await input.press('Enter')
@@ -369,6 +377,14 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
     await dock.getByText(STEER_TWO, { exact: true }).waitFor({ timeout: 10_000 })
     expect(await page.locator('[data-pending-steering]').count()).toBe(0)
 
+    // Submission echoes carry the same text before the Host queue publishes.
+    await expect.poll(
+      () => dock.getByRole('button', { name: 'Steer queued message', disabled: false }).count(),
+      { timeout: 10_000 },
+    ).toBe(2)
+    await page.getByRole('textbox', { name: 'Cmd/Ctrl+Enter steers all queued messages', exact: true })
+      .waitFor({ timeout: 10_000 })
+
     // Empty draft + Cmd+Enter: both queued rows steer in FIFO order, the dock
     // empties, and the pending steering renders at the conversation tail.
     await input.press('Meta+Enter')
@@ -376,6 +392,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
       () => page.locator('[data-pending-steering]').filter({ hasText: /BANANA|ORANGE/ }).count(),
       { timeout: 10_000 },
     ).toBe(2)
+    releaseReplay.resolve(undefined)
     expect(await page.locator('[data-queue-dock]').count()).toBe(0)
     // The reasoning row streams independently of the steering handoff. Wait
     // for the block to settle so the mid snapshot does not race its transient

+ 25 - 6
apps/web/tests/workspace-management.e2e.ts

@@ -210,6 +210,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     acknowledgeReloadConnectionLoss(tripwire, warningStart)
     await expect.poll(() => page.getByText('gamma-ws', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
+    // Session restoration focuses the composer; the Workspace list can arrive
+    // first. Do not let that focus cancel the next directory dialog's path draft.
+    const composer = page.locator('[data-composer-input][contenteditable="true"]')
+    await expect.poll(() => composer.evaluate(element => document.activeElement === element), { timeout: 10_000 }).toBe(true)
     expect(tripwire.pageErrors).toEqual([])
   }, 90_000)
 
@@ -589,21 +593,28 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
 
   it('archives the seeded session from its row menu, hiding it durably across reload', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-archive'))
-    const sessionRow = await seededSessionRow()
+    const initialRow = await seededSessionRow()
     // Selecting the seed hides any blank stray left by Workspace deletion,
     // so archiving this last visible Ungrouped Session must remove the bucket.
-    await sessionRow.click()
+    await initialRow.click()
+    const { title } = await scaffold.ctx.sessionController.rename({
+      sessionId: SessionId(SEED_ID), title: `Archive target ${SEED_ID}`,
+    })
+    // A user-owned title binds the locator to this seed across restoration.
+    const sessionRow = page.getByRole('treeitem').filter({
+      has: page.getByText(title, { exact: true }),
+    })
+    await expect.poll(() => sessionRow.count(), { timeout: 10_000 }).toBe(1)
     await expect.poll(() => sessionRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
     const ungroupedSection = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..').locator('..')
     await expect.poll(() => ungroupedSection.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(2)
-    const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
     // Row menu: hover reveals the actions button; Archive session commits
     // without a confirmation dialog (non-destructive: log + accounting stay).
-    await clickHoverAction(sessionRow, `Session actions for ${rowTitle}`)
+    await clickHoverAction(sessionRow, `Session actions for ${title}`)
     await page.getByRole('menuitem', { name: 'Archive session' }).click()
     // The row disappears on the archive-set echo; with no other visible
     // stray, the whole Ungrouped bucket withdraws.
-    await expect.poll(() => page.getByText(rowTitle, { exact: true }).count(), { timeout: 10_000 }).toBe(0)
+    await expect.poll(() => sessionRow.count(), { timeout: 10_000 }).toBe(0)
     await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBe(0)
     // Durable on the host: the registry-global set carries the id while the
     // session log itself stays in persistence untouched.
@@ -615,10 +626,18 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     acknowledgeReloadConnectionLoss(tripwire, warningStart)
     await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
+    // Initial Workspace reconnection can focus the composer after the tree renders.
+    // Finish that navigation before the next test opens a path editor.
+    await page.locator('[role="treeitem"][aria-selected="true"]').waitFor({ timeout: 15_000 })
+    await expect.poll(
+      () => page.locator('[data-composer-input][contenteditable="true"]')
+        .evaluate(element => element === document.activeElement),
+      { timeout: 15_000 },
+    ).toBe(true)
     // The archived row must not resurface (the Ungrouped bucket itself may
     // reappear if selection restore lands on another stray — not this test's
     // concern).
-    expect(await page.getByText(rowTitle, { exact: true }).count()).toBe(0)
+    expect(await sessionRow.count()).toBe(0)
     expect(tripwire.pageErrors).toEqual([])
   }, 90_000)
 

+ 2 - 0
apps/web/tsconfig.json

@@ -64,7 +64,9 @@
     "tests/conversation-column-overflow.e2e.ts",
     "tests/ptc-round.e2e.ts",
     "tests/present.e2e.ts",
+    "tests/present-svg.e2e.ts",
     "tests/composer-draft-scroll.e2e.ts",
+    "tests/composer-placeholder.e2e.ts",
     "tests/cordis-tool-round.e2e.ts",
     "tests/web-search-round.e2e.ts",
     "tests/file-upload-round.e2e.ts",

+ 2 - 2
benchmarks/agent-continuation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write benchmarks/agent-continuation/README.md
-README.md: 3d38f008c4ee95c794e4e7fbd3d874d1d668b1ce
-README.zh.md: 189dcae8eea8716dbcb24b1fe2b08f1113caf36d
+README.md: 489939499af8d98922df2cbbdd6be793bf6e3796
+README.zh.md: e99760cfd1aba0ca41e78243f333fe18446474fe

+ 1 - 1
benchmarks/agent-continuation/README.md

@@ -18,7 +18,7 @@ Measure long-history request processing, cold tool-heavy continuation, and repea
 
 From the repository root, build the libraries and workers with `pnpm run build:bench`, then run `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/agent-continuation/agent-continuation.bench.ts`. Do not overlap timing runs with builds or other benchmarks.
 
-The test reports all five fresh-process samples and enforces reviewed median budgets. Catalog and tool continuation each use a 900 ms standard hosted CI expectation with 1.25× headroom (1,125 ms); request history uses a separately reviewed 297 ms hosted limit ([calibration](../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.md)), and SDK continuation uses reference-machine scaling. A failed worker reports its exit, signal, timeout, and stderr; temporary roots are removed even on failure. The required benchmark lane discovers this file automatically.
+The test reports all five fresh-process samples, CPU models, available parallelism, platform/architecture, and Node/V8 versions, and enforces reviewed median budgets. Catalog and tool continuation each use a 900 ms standard hosted CI expectation with 1.25× headroom (1,125 ms); request history uses a separately reviewed 297 ms hosted limit ([calibration](../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.md)), and SDK continuation uses reference-machine scaling. A failed worker reports its exit, signal, timeout, and stderr; temporary roots are removed even on failure. The required benchmark lane discovers this file automatically.
 
 <a id="measurements"></a>
 

+ 1 - 1
benchmarks/agent-continuation/README.zh.md

@@ -18,7 +18,7 @@
 
 在仓库根目录使用 `pnpm run build:bench` 构建库和 worker,然后运行 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/agent-continuation/agent-continuation.bench.ts`。不要让计时运行与构建或其他基准重叠。
 
-测试报告全部五个新进程样本,并约束经审查的中位数预算。目录和工具续聊用例均使用标准托管 CI 的 900 ms 期望值与 1.25× 余量(1,125 ms);请求历史使用单独审查的 297 ms 托管上限([校准依据](../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.zh.md)),SDK 续聊使用参考机器缩放。worker 失败时报告退出状态、信号、超时和 stderr;失败时也会删除临时根目录。必需基准通道自动发现此文件。
+测试报告全部五个新进程样本、CPU 型号、可用并行度、平台/架构和 Node/V8 版本,并约束经审查的中位数预算。目录和工具续聊用例均使用标准托管 CI 的 900 ms 期望值与 1.25× 余量(1,125 ms);请求历史使用单独审查的 297 ms 托管上限([校准依据](../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.zh.md)),SDK 续聊使用参考机器缩放。worker 失败时报告退出状态、信号、超时和 stderr;失败时也会删除临时根目录。必需基准通道自动发现此文件。
 
 <a id="measurements"></a>
 

+ 7 - 1
benchmarks/agent-continuation/agent-continuation.bench.ts

@@ -1,7 +1,7 @@
 /** Baseline budgets for long-history requests, tool continuation, and fork-child discovery. */
 
 import { cp, mkdir, mkdtemp, rm } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
+import { availableParallelism, cpus, tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
 import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
@@ -166,6 +166,12 @@ describe('continuing tool-heavy Sessions with large histories', () => {
       const retainedHeapBudgetMb = EXPECTED_RETAINED_HEAP_MB * PERFORMANCE_BUDGET_HEADROOM
       console.log(JSON.stringify({
         benchmark: 'agent-continuation/' + scenario, workload: WORKLOAD,
+        runtime: {
+          cpuModels: [...new Set(cpus().map(cpu => cpu.model))],
+          availableParallelism: availableParallelism(),
+          platform: process.platform, arch: process.arch,
+          node: process.version, v8: process.versions.v8,
+        },
         samples, totalMs: { min: Math.min(...totalMs), median: median(totalMs), max: Math.max(...totalMs) },
         budgetMs, ...(scenario === 'tool-continuation' ? { retainedHeapBudgetMb } : {}),
       }))

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/tool-catalog.md
-tool-catalog.md: c2ea95ff460b65fbba789738b16b0c67a7c91948
-tool-catalog.zh.md: 41fc1eaa3c6a0acf17bde4b69c97413b64872930
+tool-catalog.md: 928f7537ab212edf6af1dec93077caed2bb003b7
+tool-catalog.zh.md: 5ac34f6188f2b0689e02b72595375f00b02d5f6f

+ 1 - 1
docs/tool-catalog.md

@@ -225,7 +225,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_
 
 ### `present`
 
-Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.
+Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.
 
 ```json
 {

+ 1 - 1
docs/tool-catalog.zh.md

@@ -229,7 +229,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac
 
 ### `present`
 
-声明交付已有的工作区文件。用户打开当前源文件;不复制或保存其内容。调用工具前先创建文件。
+声明交付已有的工作区文件。如果你创建或更新的文件是用户要求接收的成果,则必须在写入完成后、最终回复前调用 present,包括通过 Bash 或代码执行创建的文件。在回复中提到文件路径不能替代这次调用。文件必须已存在。用户打开当前源文件;不复制或保存其内容。
 
 ```json
 {

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 19a210bb200dae359920e85ff944b68242f45b4b
-README.zh.md: c5a56871eb99607c924b2b2e048de72d918e6a8b
+README.md: 7d5f370644f90f57ce1c27c59becf47c4cf2e684
+README.zh.md: e7c5204b763aad92dc057d122537d592952afcd5

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -44,7 +44,7 @@ View selection is deterministic: a registered persisted selection wins, otherwis
 
 The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated.
 
-The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
+The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
 
 Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id.
 

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -44,7 +44,7 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是
 
 Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。
 
-常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
+常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
 
 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。prompt 复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation service 在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
 

+ 1 - 1
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -469,7 +469,7 @@ export const InputBar = memo(function InputBar({
               onKeyDown={workspaceTrigger ? onWorkspaceKeyDown : undefined}
               style={hint === null ? undefined : { '--dsh-composer-hint': JSON.stringify(hint) } as CSSProperties}
             />
-            {empty && !claimActive && (
+            {draft === '' && attachments.length === 0 && !claimActive && (
               <div aria-hidden className={css.placeholder} data-composer-placeholder>
                 {placeholderText}
               </div>

+ 43 - 0
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -270,6 +270,49 @@ function writeDraft(shell: SessionInputShell, text: string): void {
   act(() => { shell.setDraft(text) })
 }
 
+describe('composer placeholder visibility', () => {
+  it.each([' ', '   ', '\t', '\n'])('hides for whitespace %j and returns after deletion', async (draft) => {
+    const { view, shell, textarea, button, sink, props } = bench()
+    const placeholder = () => view.container.querySelector('[data-composer-placeholder]')
+    expect(placeholder()).not.toBeNull()
+    writeDraft(shell, draft)
+    expect(placeholder()).toBeNull()
+    expect(button.disabled).toBe(true)
+    fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 13 })
+    await act(async () => {})
+    expect(sink).not.toHaveBeenCalled()
+    fireEvent.blur(textarea)
+    view.rerender(<InputBar {...props} />)
+    fireEvent.focus(textarea)
+    expect(placeholder()).toBeNull()
+    writeDraft(shell, '')
+    expect(placeholder()).not.toBeNull()
+  })
+
+  it('hides for pasted spaces and restores after clearing', async () => {
+    const { view, shell, textarea } = bench()
+    fireEvent.paste(textarea, {
+      clipboardData: { items: [], getData: () => '   ' },
+    })
+    await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('   ') })
+    expect(view.container.querySelector('[data-composer-placeholder]')).toBeNull()
+    writeDraft(shell, '')
+    expect(view.container.querySelector('[data-composer-placeholder]')).not.toBeNull()
+  })
+
+  it('keeps whitespace hidden through composition and rerender', () => {
+    const { view, shell, textarea, props } = bench()
+    fireEvent.compositionStart(textarea)
+    writeDraft(shell, ' ')
+    expect(view.container.querySelector('[data-composer-placeholder]')).toBeNull()
+    view.rerender(<InputBar {...props} />)
+    fireEvent.compositionEnd(textarea, { data: ' ' })
+    expect(view.container.querySelector('[data-composer-placeholder]')).toBeNull()
+    writeDraft(shell, '')
+    expect(view.container.querySelector('[data-composer-placeholder]')).not.toBeNull()
+  })
+})
+
 describe('image draft rail', () => {
   it('collects clipboard files while preserving text from a mixed paste', async () => {
     const addFiles = vi.fn(() => null)

+ 2 - 2
packages/client/ui-deliverables/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
-README.md: ae42abb5a69d29332bd7cfd6d2d2a1191381e79a
-README.zh.md: 664dfa3cd2e0d526b43464a80290604a8ecf958c
+README.md: 4886a5817e6118ef76d5e7b49ed7fe3ed319650f
+README.zh.md: 43c68d4563f9ec32bb15fdd3df9a2e21225ebc13

+ 1 - 1
packages/client/ui-deliverables/README.md

@@ -36,7 +36,7 @@ The `present` tool row shows running, delivered, failed, or interrupted status;
 
 ### The row
 
-The row uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling.
+The “Files changed” row lists successful file-tool mutations; final file deliveries require `present`. It uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling.
 
 ### Inline-code links
 

+ 1 - 1
packages/client/ui-deliverables/README.zh.md

@@ -36,7 +36,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
 
 ### 该行
 
-该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。
+“本轮文件改动”行列出成功的文件工具修改;最终文件交付需要调用 `present`。该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。
 
 ### 行内代码链接
 

+ 2 - 2
packages/client/ui-deliverables/src/client/locales.ts

@@ -18,7 +18,7 @@ export const zh = {
   'row.stopped': '已中断',
   'row.inspect': '查看调用',
   'presented.open': '在默认程序中打开 {name}',
-  'produced.label': '产物',
+  'produced.label': '本轮文件改动',
   'produced.moreOne': '+ 1 个文件',
   'produced.more': '+ {count} 个文件',
   'produced.open': '打开 {name}',
@@ -39,7 +39,7 @@ export const en: Record<DeliverablesKey, string> = {
   'row.stopped': 'Interrupted',
   'row.inspect': 'Inspect call',
   'presented.open': 'Open {name} in default app',
-  'produced.label': 'Produced',
+  'produced.label': 'Files changed',
   'produced.moreOne': '+ 1 file',
   'produced.more': '+ {count} files',
   'produced.open': 'Open {name}',

+ 3 - 3
packages/client/ui-deliverables/tests/produced-files.client.spec.tsx

@@ -426,7 +426,7 @@ describe('ProducedFiles row', () => {
     const openFile = vi.fn<(path: string) => void>()
 
     const view = render(<ProducedFiles matched={paths} openFile={openFile} t={t} />)
-    expect(view.getByText('产物')).toBeTruthy()
+    expect(view.getByText('本轮文件改动')).toBeTruthy()
     const row = view.container.querySelector('[data-produced-files-row]')
     if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
     expect(within(row).getAllByRole('button')).toHaveLength(6)
@@ -595,7 +595,7 @@ describe('presented files', () => {
     expect(view.queryByRole('link')).toBeNull()
     fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in default app' }))
     expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0)
-    expect(view.queryByText('Produced')).toBeNull()
+    expect(view.queryByText('Files changed')).toBeNull()
   })
 })
 
@@ -617,7 +617,7 @@ it.each([{}, { turn: '1', callId: 'bad', files: [] },
   const owner = tailOwner(deliverablesOf(value), 5)
   const matched = selectDeliverables(owner)!
   const view = render(<Deliverables {...openProps()} matched={matched} openFile={owner.openFile} sessionId={SessionId('session')} t={makeTranslate(en)} />)
-  expect(view.getByText('Produced')).toBeTruthy()
+  expect(view.getByText('Files changed')).toBeTruthy()
   expect(view.queryByText('Deliverables')).toBeNull()
 })
 

+ 95 - 0
packages/code-runtime/code-runtime-worker-thread/tests/budget.spec.ts

@@ -0,0 +1,95 @@
+/** Host budget decisions use controlled clocks and ELU samples; worker execution and binding transport stay real. */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { WorkerThreadCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker-thread'
+import type { CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
+
+const meter = vi.hoisted(() => ({ sample: vi.fn() }))
+
+vi.mock('node:worker_threads', async (importOriginal) => {
+  const original = await importOriginal<typeof import('node:worker_threads')>()
+  return {
+    ...original,
+    Worker: class extends original.Worker {
+      constructor(...args: ConstructorParameters<typeof original.Worker>) {
+        super(...args)
+        this.performance.eventLoopUtilization = meter.sample
+      }
+    },
+  }
+})
+
+describe('worker budgets with controlled ELU samples and real binding transport', () => {
+  let ctx: Context
+  let controller: AbortController
+  let run: Promise<CodeRunResult> | undefined
+  let release: (() => void) | undefined
+
+  beforeEach(() => {
+    ctx = new Context()
+    controller = new AbortController()
+    run = undefined
+    release = undefined
+    meter.sample.mockReset().mockReturnValue({ active: 10, idle: 0, utilization: 1 })
+    // Worker bootstrap and scheduling contribute to ELU active time; only the
+    // measured input and host deadlines are controlled, not worker execution.
+    vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
+  })
+
+  afterEach(async () => {
+    const owned = { ctx, controller, run, release }
+    try {
+      owned.controller.abort('test cleanup')
+      owned.release?.()
+    } finally {
+      vi.useRealTimers()
+    }
+    try {
+      await owned.run
+    } finally {
+      await owned.ctx.fiber.dispose()
+    }
+  })
+
+  async function pendingBinding(): Promise<void> {
+    await ctx.plugin(WorkerThreadCodeRuntime, { computeMs: 1_000, maxWallMs: 30_000 })
+    let entered!: () => void
+    const ready = new Promise<void>((resolve) => { entered = resolve })
+    const binding = new Promise<string>((resolve) => { release = () => { resolve('slow-done') } })
+    run = ctx.codeRuntime.run({
+      program: 'return await tools.slow({})',
+      bindings: [{ global: 'tools', functions: { slow: () => { entered(); return binding } } }],
+      signal: controller.signal,
+    })
+    await Promise.race([
+      ready,
+      run.then((result) => { throw new Error('Worker settled before binding entry: ' + JSON.stringify(result)) }),
+    ])
+  }
+
+  it('does not charge a binding wait longer than the compute budget', async () => {
+    await pendingBinding()
+    const settled = vi.fn()
+    void run!.then(settled, settled)
+    meter.sample.mockReturnValue({ active: 10, idle: 1_500, utilization: 10 / 1_510 })
+    await vi.advanceTimersByTimeAsync(1_500)
+    expect(meter.sample).toHaveBeenCalled()
+    expect(settled).not.toHaveBeenCalled()
+    release!()
+    expect(await run).toEqual({ logs: [], value: 'slow-done' })
+  })
+
+  it('expires active time even while a binding is pending', async () => {
+    await pendingBinding()
+    meter.sample.mockReturnValue({ active: 1_001, idle: 1_500, utilization: 1_001 / 2_501 })
+    await vi.advanceTimersByTimeAsync(25)
+    expect(await run).toEqual({ logs: [], error: { kind: 'timeout', message: 'compute budget exhausted (1000ms busy)' } })
+  })
+
+  it('expires the wall ceiling while active time remains below the compute budget', async () => {
+    await pendingBinding()
+    meter.sample.mockReturnValue({ active: 10, idle: 30_000, utilization: 10 / 30_010 })
+    await vi.advanceTimersByTimeAsync(30_000)
+    expect(await run).toEqual({ logs: [], error: { kind: 'timeout', message: 'wall-clock ceiling reached (30000ms)' } })
+  })
+})

+ 2 - 2
packages/experimental/webworker-runtime/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md
-README.md: b9454e308e92ba78e3d11aa72dfd3723e8c07568
-README.zh.md: 4d39f145d2ce2c826906ea1e60a26488eab9467e
+README.md: 2327851be62bc4e09e8678cd8d0c7b07663d1bdd
+README.zh.md: afd1bef17c8e921b4a6e97bd224deaa0f94e9486

+ 2 - 0
packages/experimental/webworker-runtime/README.md

@@ -32,6 +32,8 @@ Three artifacts from one tsdown pipeline:
 
 Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The fixture generator owns current-generation logs and the projection cache; committed predecessor logs remain byte-identical alongside them. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture.
 
+The [built-bundle import sweep](tests/compile/transform-corpus-check.ts) checks bare Node imports after the library build. Its Dockkit exception accepts only Node’s unknown-extension error for the expected stylesheet; other failures and unexpectedly successful exempt imports remain errors. See the [CI observation decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md).
+
 -----
 
 <a id="model-experience"></a>

+ 2 - 0
packages/experimental/webworker-runtime/README.zh.md

@@ -32,6 +32,8 @@ kind: "package-library"
 
 验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。fixture 生成器负责当前代日志与投影缓存;已提交的前代日志逐字节保持不变,与它们并存。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。
 
+[已构建 bundle 导入检查](tests/compile/transform-corpus-check.ts)在库构建后检查裸 Node 导入。Dockkit 例外仅接受 Node 针对预期样式表报告的未知扩展名错误;其他失败以及意外成功的豁免导入仍然报错。参见 [CI 观察决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。
+
 -----
 
 <a id="model-experience"></a>

+ 7 - 2
packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts

@@ -23,6 +23,8 @@ import { join } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
 
 const repositoryRoot = fileURLToPath(new URL('../../../../../', import.meta.url))
+const DOCKKIT_BUNDLE = 'packages/client/ui-dockkit/lib/index.js'
+const DOCKKIT_CSS = join(repositoryRoot, 'packages/client/ui-dockkit/lib/components/dockkit.module.css')
 
 /**
  * Files Node's ESM loader cannot import in this repository. None is a finding:
@@ -34,7 +36,7 @@ const repositoryRoot = fileURLToPath(new URL('../../../../../', import.meta.url)
  * import re-registers koffi's type names and fails as the second load.
  */
 const BASELINE_EXEMPT: ReadonlyMap<string, string> = new Map([
-  ['packages/client/ui-dockkit/lib/index.js', 'imports .css, which bare Node cannot load'],
+  [DOCKKIT_BUNDLE, 'imports .css, which bare Node cannot load'],
   ['packages/client/ui-primitives/lib/index.js', 'imports .css, which bare Node cannot load'],
   ['packages/client/web/lib/index.js', 'imports .css, which bare Node cannot load'],
   ['packages/subprocess/win32-process/lib/index.js', 'koffi type-name collision on a second load'],
@@ -109,7 +111,10 @@ if (files.length === 0) {
     try {
       await import(pathToFileURL(file).href)
     } catch (reason) {
-      if (exemption === undefined) {
+      const expectedDockkitCss = reason instanceof Error
+        && 'code' in reason && reason.code === 'ERR_UNKNOWN_FILE_EXTENSION'
+        && reason.message === `Unknown file extension ".css" for ${DOCKKIT_CSS}`
+      if (exemption === undefined || (key === DOCKKIT_BUNDLE && !expectedDockkitCss)) {
         // A bundle that stopped being importable is a real finding, so it
         // fails rather than joining a tolerated total.
         fail(`- UNEXPECTED BASELINE FAILURE ${key}: ${(reason as Error).message.split('\n')[0]}`)

+ 50 - 2
packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts

@@ -13,8 +13,8 @@
  * The corpus is the build output, so this skips on a tree that has none.
  */
 import { spawnSync } from 'node:child_process'
-import { fileURLToPath } from 'node:url'
-import { expect, test } from 'vitest'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { expect, test, TestRunner } from 'vitest'
 
 const runner = fileURLToPath(new URL('./transform-corpus-check.ts', import.meta.url))
 
@@ -30,3 +30,51 @@ test('every built bundle imports under Node', (context) => {
   expect(output.split('\n').filter(line => line.startsWith('- ')).join('\n')).toBe('')
   expect(finished.status, output).toBe(0)
 }, 900_000)
+
+// The hook replaces only the chosen bundle; shared build artifacts stay intact.
+test.each([
+  ['expected-css', 0, 'baselineExempt=1 unexpectedBaselineFailure=0'],
+  ['error', 1, '- UNEXPECTED BASELINE FAILURE'],
+  ['other-css', 1, '- UNEXPECTED BASELINE FAILURE'],
+  ['other-code', 1, '- UNEXPECTED BASELINE FAILURE'],
+  ['clean', 1, '- STALE EXEMPTION'],
+] as const)('classifies dockkit import: %s', (mode, status, finding) => {
+  const root = new URL('../../../../../', import.meta.url)
+  const bundle = 'packages/client/ui-dockkit/lib/index.js'
+  const css = fileURLToPath(new URL('packages/client/ui-dockkit/lib/components/dockkit.module.css', root))
+  const message = mode === 'error'
+    ? 'dockkit-negative-control'
+    : `Unknown file extension ".css" for ${mode === 'other-css' ? `${css}.other.css` : css}`
+  const source = mode === 'clean'
+    ? 'export {}'
+    : `throw Object.assign(new Error(${JSON.stringify(message)}), { code: ${JSON.stringify(mode === 'other-code' ? 'ERR_OTHER' : 'ERR_UNKNOWN_FILE_EXTENSION')} })`
+  const script = `
+    import { registerHooks } from 'node:module'
+    const target = ${JSON.stringify(new URL(bundle, root).href)}
+    registerHooks({
+      resolve(specifier, context, nextResolve) {
+        if (specifier === target) return { url: target, shortCircuit: true }
+        return nextResolve(specifier, context)
+      },
+      load(url, context, nextLoad) {
+        if (url === target) {
+          return { format: 'module', shortCircuit: true, source: ${JSON.stringify(source)} }
+        }
+        return nextLoad(url, context)
+      },
+    })
+    process.argv = [process.execPath, 'corpus-classification', ${JSON.stringify(bundle)}]
+    await import(${JSON.stringify(pathToFileURL(runner).href)})
+  `
+  const finished = spawnSync(process.execPath, ['--import', 'tsx/esm', '--input-type=module', '-e', script], {
+    cwd: fileURLToPath(root), encoding: 'utf8', timeout: TestRunner.getCurrentTest()!.timeout,
+  })
+  const output = `${finished.stdout}${finished.stderr}`
+  expect(finished.error).toBeUndefined()
+  expect(finished.signal).toBeNull()
+  expect(finished.status, output).toBe(status)
+  expect(output).toContain(finding)
+  if (mode === 'error' || mode === 'other-css' || mode === 'other-code') {
+    expect(output).toContain(`- UNEXPECTED BASELINE FAILURE ${bundle}: ${message}\n`)
+  }
+})

+ 2 - 2
packages/fs/tool-present/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/fs/tool-present/README.md
-README.md: 6767bb3b8d8839ae776fdf441ef853192c5117a2
-README.zh.md: 8255e7be42de273d58d01ca9b4e108d4c65a585d
+README.md: 77c1a83a30bdbe58b3be35d8bc0d879f1669b5a5
+README.zh.md: cec2d64b4e6f95a4e8210e7e24a9c796ed95d749

+ 1 - 1
packages/fs/tool-present/README.md

@@ -73,7 +73,7 @@ The pure `./types` entry declares `PresentedFile` and the Session event without
 
 #### What the model sees
 
-The [present schema](../../../docs/tool-catalog.md#present) asks for existing workspace files: “Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.” Results report `Presented <path>` for each file; the program result and durable event contain paths and optional descriptions.
+The [present schema](../../../docs/tool-catalog.md#present) asks for existing workspace files: “Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” Results report `Presented <path>` for each file; the program result and durable event contain paths and optional descriptions.
 
 #### Token effect
 

+ 1 - 1
packages/fs/tool-present/README.zh.md

@@ -73,7 +73,7 @@ kind: "package-reference"
 
 #### 模型看到的内容
 
-[present schema](../../../docs/tool-catalog.zh.md#present)要求已有的工作区文件:“Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.” 每个文件的结果为 `Presented <path>`;程序结果和持久事件包含路径及可选说明。
+[present schema](../../../docs/tool-catalog.zh.md#present)要求已有的工作区文件:“Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” 每个文件的结果为 `Presented <path>`;程序结果和持久事件包含路径及可选说明。
 
 #### Token 影响
 

+ 4 - 1
packages/fs/tool-present/src/index.ts

@@ -37,7 +37,10 @@ export function apply(ctx: Context, config: Config): void {
   const pending = new WeakMap<ToolExecution, { session: Session; turn: number; files: PresentedFile[] }>()
   ctx.tools.register(defineTool({
     name: 'present',
-    description: 'Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.',
+    description: 'Declare existing workspace files as final deliverables. '
+      + 'When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. '
+      + 'Mentioning its path in your reply does not replace this call. The files must already exist. '
+      + 'The user opens the current source files; their contents are not copied or preserved.',
     parameters: {
       files: {
         type: 'array', required: true,

+ 2 - 2
packages/host/directory-picker-native/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md
-README.md: a432bdc19175c690287745cc9c2df004d03b7478
-README.zh.md: 98ea9a033dbf6720fa08fbb068e243371998dab6
+README.md: 3f980831848965b976e32b0e5ef25abb77fe4e6e
+README.zh.md: 66f77a0b715aee8b4087dd03d4d4eea69d39bb09

+ 2 - 1
packages/host/directory-picker-native/README.md

@@ -53,7 +53,7 @@ The backend is a thin service over a platform chooser: `NativeDirectoryPicker` r
 
 ### Platform mechanics
 
-Platform tools run without a shell: `osascript` on macOS, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread.
+Platform tools run without a shell: `osascript` on macOS, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread. Immediately before `Show`, the child synthesizes one Alt press through `keybd_event`, which lets the dialog activate as the foreground window even when a background host process spawned the child.
 
 ### Source map
 
@@ -98,6 +98,7 @@ These limits define when the native interaction is unavailable or fragile. They
 
 - **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level).
 - **Windows has no mechanism fallback** — the child-process picker through packaged koffi is the only native tier, so a COM refusal or dialog crash surfaces the failure; the browse backend remains the fallback at the composition level.
+- **Windows foreground grant relies on injected input** — the child synthesizes an Alt press before `Show` so the dialog can take the foreground from a background host; where synthesized input is suppressed (secure desktops, restricted remote sessions, an elevated foreground window), the dialog may still open behind other windows. The technique is validated on Windows 11 only.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 2 - 1
packages/host/directory-picker-native/README.zh.md

@@ -53,7 +53,7 @@ kind: "package-reference"
 
 ### 平台机制
 
-平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`。
+平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`。在 `Show` 之前,子进程立即通过 `keybd_event` 合成一次 Alt 按键,让对话框即使由后台宿主进程 spawn 也能激活为前台窗口。
 
 ### 源码地图
 
@@ -98,6 +98,7 @@ kind: "package-reference"
 
 - **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是浏览后端)。
 - **Windows 没有机制级回退**——通过打包依赖 koffi 运行的子进程选择器是唯一原生层级,因此 COM 拒绝或对话框崩溃会直接上报失败;组合层面的回退仍是浏览后端。
+- **Windows 前台授权依赖注入的输入**——子进程在 `Show` 之前合成一次 Alt 按键,对话框才能从后台宿主取得前台;在合成输入被抑制的环境(安全桌面、受限远程会话、提权前台窗口)中,对话框仍可能在其他窗口后面打开。该技术仅在 Windows 11 上验证过。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 3 - 1
packages/host/directory-picker-native/src/index.ts

@@ -3,7 +3,9 @@
  * with the `native` capability, opening one native OS chooser on the host
  * display per pick (macOS `osascript`, Linux Zenity with a KDialog fallback;
  * Windows opens the modern `IFileOpenDialog` in a spawned child process — a
- * koffi-driven COM conversation on the child's main thread). Only viable when
+ * koffi-driven COM conversation on the child's main thread, preceded by a
+ * synthesized Alt press so the dialog activates as foreground even when a
+ * background host spawned the child). Only viable when
  * the operator sits at the host's screen; remote deployments compose the
  * browse backend instead.
  * @module @deepseek-ai/dsh-host-directory-picker-native

+ 9 - 0
packages/host/directory-picker-native/src/win32-dialog-bindings.ts

@@ -55,6 +55,10 @@ const SIGDN_FILESYSPATH = 0x80058000 | 0
  */
 const DPI_AWARENESS_CONTEXTS = [-4, -3, -2]
 const WM_CLOSE = 0x10
+/** `VK_MENU`: the synthesized Alt press's virtual key. */
+const VK_MENU = 0x12
+/** `KEYEVENTF_KEYUP`: the synthesized Alt press's release flag. */
+const KEYEVENTF_KEYUP = 0x2
 
 /** IFileOpenDialog vtable slots (IUnknown 0-2, IModalWindow 3, IFileDialog 4+). */
 const SLOT_RELEASE = 2
@@ -101,6 +105,7 @@ export async function loadWin32DialogBindings(): Promise<Win32DialogBindings> {
   const coCreateInstance = ole32.func('__stdcall', 'CoCreateInstance', 'int32', ['void *', 'void *', 'uint32', 'void *', 'void *'])
   const coTaskMemFree = ole32.func('__stdcall', 'CoTaskMemFree', 'void', ['void *'])
   const getCurrentThreadId = kernel32.func('__stdcall', 'GetCurrentThreadId', 'uint32', [])
+  const keybdEvent = user32.func('__stdcall', 'keybd_event', 'void', ['uint8', 'uint8', 'uint32', 'uintptr'])
 
   const protoShow = koffi.proto('int32 __stdcall DshDialogShow(void *self, void *owner)')
   const protoSetOptions = koffi.proto('int32 __stdcall DshDialogSetOptions(void *self, uint32 options)')
@@ -140,6 +145,10 @@ export async function loadWin32DialogBindings(): Promise<Win32DialogBindings> {
       coUninitialize()
     },
     currentThreadId: () => getCurrentThreadId() as number,
+    pressAltForForeground: () => {
+      keybdEvent(VK_MENU, 0, 0, 0)
+      keybdEvent(VK_MENU, 0, KEYEVENTF_KEYUP, 0)
+    },
     createFolderDialog: (): Win32FolderDialog => {
       const out = Buffer.alloc(pointerSize)
       const created = coCreateInstance(CLSID_FILE_OPEN_DIALOG, null, CLSCTX_INPROC_SERVER, IID_IFILE_OPEN_DIALOG, out) as number

+ 4 - 3
packages/host/directory-picker-native/src/win32-dialog-host.ts

@@ -14,9 +14,10 @@ import type { Win32DialogWorkerData } from './win32-dialog-worker.ts'
 /**
  * Spawn the dialog child process. Built consumers launch the bundled CJS
  * entry next to this module under plain node; unbuilt (source) consumers
- * bootstrap tsx first, mirroring the dsh CLI's source launch. The dialog is
- * the child's first window, so Windows activates it without a foreground
- * call.
+ * bootstrap tsx first, mirroring the dsh CLI's source launch. The child
+ * opens its dialog as foreground on its own: `runFolderDialog` synthesizes
+ * an Alt press before `Show`, which matters when a background host spawned
+ * the child.
  * @param data - the child payload (dialog title).
  * @returns the spawned child process.
  */

+ 15 - 0
packages/host/directory-picker-native/src/win32-dialog-logic.ts

@@ -79,6 +79,20 @@ export interface Win32DialogBindings {
    * @returns the calling thread's native id.
    */
   currentThreadId(): number
+  /**
+   * Make the dialog that `Show` is about to create able to take the
+   * foreground. Windows grants activation only to the foreground process,
+   * to a process it started, or to a process that received recent input; a
+   * worker spawned by a background host (the web GUI server) qualifies for
+   * none, so the dialog would open behind every other window. Synthesizing
+   * one Alt press (down, then up) through `keybd_event` counts this process
+   * as the most recent input owner — a community-documented technique with
+   * no documented contract. Call immediately before `Show`. When the
+   * process already holds foreground rights (a console-launched CLI), the
+   * press is inert, but the focused window still receives the lone Alt and
+   * may briefly highlight its menu bar before the dialog activates.
+   */
+  pressAltForForeground(): void
 }
 
 /**
@@ -117,6 +131,7 @@ export function runFolderDialog(
       check(dialog.setOptions(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR), 'SetOptions')
       check(dialog.setTitle(title), 'SetTitle')
       onShowing(bindings.currentThreadId())
+      bindings.pressAltForForeground()
       const shown = dialog.show()
       if (shown === HRESULT_CANCELLED) return null
       check(shown, 'Show')

+ 6 - 3
packages/host/directory-picker-native/src/win32-dialog-worker.ts

@@ -1,9 +1,12 @@
 /**
  * Child-process entry for the Win32 folder dialog: blocks THIS process
  * inside the modal `Show` so the host event loop stays live, reporting over
- * the IPC channel. Spawned as a child process (not a worker thread) so the
- * dialog is the process's first window and Windows activates it without a
- * manual foreground call. Protocol: `{kind:'showing',threadId}` right
+ * the IPC channel. Spawned as a child process (not a worker thread) so a
+ * native fault stays contained and the modal call never wedges the host.
+ * A background host (the web GUI server) leaves this process without
+ * foreground rights, so `runFolderDialog` synthesizes an Alt press
+ * immediately before `Show` and the dialog then activates as foreground.
+ * Protocol: `{kind:'showing',threadId}` right
  * before the blocking call (the driver's abort lever needs the native
  * thread id), then exactly one of `{kind:'done',path}` or
  * `{kind:'error',message}`.

+ 20 - 1
packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts

@@ -38,6 +38,10 @@ interface ComWorld {
   registered: number
   unregistered: number
   uninitialized: number
+  /** The synthesized keybd_event calls, in order. */
+  keyEvents: { vk: number; flags: number }[]
+  /** Cross-cutting call trace shared by keybd_event and the dialog Show slot. */
+  nativeOrder: string[]
 }
 
 function comWorld(overrides: Partial<ComWorld> = {}): ComWorld {
@@ -48,6 +52,7 @@ function comWorld(overrides: Partial<ComWorld> = {}): ComWorld {
     titles: [], options: [], dpiContexts: [], freed: [], released: [], posted: [],
     str16PointerSizes: [],
     registered: 0, unregistered: 0, uninitialized: 0,
+    keyEvents: [], nativeOrder: [],
     ...overrides,
   }
 }
@@ -77,7 +82,7 @@ function installFakeKoffi(world: ComWorld, options: {
       switch (slot) {
         case 9: world.options.push(args[0] as number); return 0
         case 17: world.titles.push(args[0] as string); return 0
-        case 3: return world.showHr
+        case 3: world.nativeOrder.push('show'); return world.showHr
         case 20: {
           if (world.getResultHr < 0) return world.getResultHr
           ;(args[0] as unknown[])[0] = itemPtr
@@ -116,6 +121,10 @@ function installFakeKoffi(world: ComWorld, options: {
             }
             case 'CoTaskMemFree': return (ptr: unknown) => { world.freed.push(ptr) }
             case 'GetCurrentThreadId': return () => 31337
+            case 'keybd_event': return (vk: number, _scan: number, flags: number, _extra: unknown) => {
+              world.keyEvents.push({ vk, flags })
+              world.nativeOrder.push(flags === 0 ? 'alt-down' : 'alt-up')
+            }
             case 'SetThreadDpiAwarenessContext': {
               if (!world.hasThreadDpi) throw new Error(`${dll}: SetThreadDpiAwarenessContext not found`)
               return (context: unknown) => {
@@ -188,6 +197,14 @@ describe('loadWin32DialogBindings over the fake COM world', () => {
     expect(world.titles).toEqual(['选择工作区目录'])
     expect(world.options).toHaveLength(1)
     expect(showing).toHaveBeenCalledWith(31337)
+    // One synthesized Alt press (down, then up) immediately precedes Show, so
+    // the dialog's activation attempt finds this process as the recent-input
+    // owner.
+    expect(world.keyEvents).toEqual([
+      { vk: 0x12, flags: 0 },
+      { vk: 0x12, flags: 2 },
+    ])
+    expect(world.nativeOrder.slice(-3)).toEqual(['alt-down', 'alt-up', 'show'])
     expect(world.freed).toHaveLength(1)
     expect(world.str16PointerSizes).toEqual([FAKE_POINTER_SIZE])
     expect(world.released).toEqual(['item', 'dialog'])
@@ -244,6 +261,7 @@ describe('loadWin32DialogBindings over the fake COM world', () => {
     const { loadWin32DialogBindings } = await loadBindingsModule()
     const bindings = await loadWin32DialogBindings()
     expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull()
+    expect(world.keyEvents).toHaveLength(2)
     expect(world.released).toEqual(['dialog'])
     expect(world.uninitialized).toBe(1)
   })
@@ -357,6 +375,7 @@ describe('the worker entry over a mocked process boundary', () => {
         coInitializeSta: () => 0,
         coUninitialize: () => undefined,
         currentThreadId: () => 11,
+        pressAltForForeground: () => undefined,
         createFolderDialog: () => ({
           setOptions: () => 0,
           setTitle: () => 0,

+ 11 - 3
packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts

@@ -15,6 +15,7 @@ const E_FAIL = 0x80004005 | 0
 interface FakeWorld {
   bindings: Win32DialogBindings
   dpi: ReturnType<typeof vi.fn>
+  pressAlt: ReturnType<typeof vi.fn>
   createDialog: ReturnType<typeof vi.fn>
   uninitialize: ReturnType<typeof vi.fn>
   dialog: {
@@ -36,6 +37,7 @@ function world(overrides: Partial<Win32FolderDialog> = {}, coInit = 0): FakeWorl
     ...overrides,
   }
   const dpi = vi.fn()
+  const pressAlt = vi.fn()
   const createDialog = vi.fn(() => dialog)
   const uninitialize = vi.fn()
   const bindings: Win32DialogBindings = {
@@ -44,13 +46,14 @@ function world(overrides: Partial<Win32FolderDialog> = {}, coInit = 0): FakeWorl
     coUninitialize: uninitialize,
     createFolderDialog: createDialog,
     currentThreadId: vi.fn(() => 4242),
+    pressAltForForeground: pressAlt,
   }
-  return { bindings, dpi, createDialog, uninitialize, dialog: dialog as FakeWorld['dialog'] }
+  return { bindings, dpi, pressAlt, createDialog, uninitialize, dialog: dialog as FakeWorld['dialog'] }
 }
 
 describe('runFolderDialog', () => {
   it('sequences DPI, STA, options, title, show, result extraction, and apartment teardown', () => {
-    const { bindings, dpi, dialog, uninitialize } = world()
+    const { bindings, dpi, pressAlt, dialog, uninitialize } = world()
     const showing = vi.fn()
     expect(runFolderDialog(bindings, 'Pick', showing)).toBe('C:\\picked\\目录')
     expect(dpi).toHaveBeenCalledOnce()
@@ -60,12 +63,17 @@ describe('runFolderDialog', () => {
     expect(dialog.setTitle).toHaveBeenCalledWith('Pick')
     expect(showing).toHaveBeenCalledWith(4242)
     expect(showing.mock.invocationCallOrder[0]).toBeLessThan(dialog.show.mock.invocationCallOrder[0] as number)
+    // The foreground press sits between the showing notice and the blocking Show.
+    expect(pressAlt).toHaveBeenCalledOnce()
+    expect(showing.mock.invocationCallOrder[0]).toBeLessThan(pressAlt.mock.invocationCallOrder[0] as number)
+    expect(pressAlt.mock.invocationCallOrder[0]).toBeLessThan(dialog.show.mock.invocationCallOrder[0] as number)
     expect(dialog.release).toHaveBeenCalledOnce()
   })
 
   it('maps the cancelled HRESULT to null and still releases the dialog and apartment', () => {
-    const { bindings, dialog, uninitialize } = world({ show: vi.fn(() => HRESULT_CANCELLED) })
+    const { bindings, pressAlt, dialog, uninitialize } = world({ show: vi.fn(() => HRESULT_CANCELLED) })
     expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull()
+    expect(pressAlt).toHaveBeenCalledOnce()
     expect(dialog.resultPath).not.toHaveBeenCalled()
     expect(dialog.release).toHaveBeenCalledOnce()
     expect(uninitialize).toHaveBeenCalledOnce()

+ 56 - 0
packages/host/open-in-app/tests/launch-detached.spec.ts

@@ -0,0 +1,56 @@
+/** Detached launch settlement with controlled process events and watch time. */
+import { ChildProcess, spawn } from 'node:child_process'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { launchDetachedApp } from '../src/resolver.ts'
+
+vi.mock('node:child_process', async importOriginal => ({
+  ...await importOriginal<typeof import('node:child_process')>(),
+  spawn: vi.fn(),
+}))
+
+afterEach(() => {
+  vi.restoreAllMocks()
+  vi.mocked(spawn).mockReset()
+  vi.useRealTimers()
+})
+
+describe('launchDetachedApp watch window', () => {
+  it.each(['exit 0', 'exit 3', 'error'] as const)('ignores late %s after unref without killing the child', async (event) => {
+    vi.useFakeTimers()
+    const child = new ChildProcess()
+    const unref = vi.spyOn(child, 'unref')
+    const kill = vi.spyOn(child, 'kill')
+    vi.mocked(spawn).mockReturnValueOnce(child)
+    try {
+      const launched = launchDetachedApp('fixture-app', [], { watchMs: 100 })
+      const fulfilled = vi.fn()
+      const rejected = vi.fn()
+      const observed = launched.then(fulfilled, rejected)
+
+      await vi.advanceTimersByTimeAsync(99)
+      expect(fulfilled).not.toHaveBeenCalled()
+      expect(unref).not.toHaveBeenCalled()
+      await vi.advanceTimersByTimeAsync(1)
+      await expect(launched).resolves.toBeUndefined()
+      await observed
+      expect(fulfilled).toHaveBeenCalledExactlyOnceWith(undefined)
+      expect(unref).toHaveBeenCalledTimes(1)
+      expect(kill).not.toHaveBeenCalled()
+
+      // emit() invokes the resolver's registered callback before returning.
+      const handled = event === 'error'
+        ? child.emit('error', new Error('late launcher error'))
+        : child.emit('exit', event === 'exit 0' ? 0 : 3, null)
+      expect(handled).toBe(true)
+      expect(unref).toHaveBeenCalledTimes(1)
+      expect(kill).not.toHaveBeenCalled()
+      expect(rejected).not.toHaveBeenCalled()
+      expect(vi.getTimerCount()).toBe(0)
+    } finally {
+      child.removeAllListeners()
+      vi.restoreAllMocks()
+      vi.mocked(spawn).mockReset()
+      vi.useRealTimers()
+    }
+  })
+})

+ 0 - 12
packages/host/open-in-app/tests/resolver.spec.ts

@@ -664,18 +664,6 @@ describe('launchDetachedApp', () => {
       .rejects.toMatchObject({ code: 'ENOENT' })
   })
 
-  it('counts a child that outlives the watch window as launched without killing it', async () => {
-    // The child exits on its own shortly after; the launch settles at the
-    // window, long before that, and never awaits or kills the process.
-    const started = Date.now()
-    await expect(launchDetachedApp(
-      node, ['-e', 'setTimeout(() => {}, 1500)'], { watchMs: 100 },
-    )).resolves.toBeUndefined()
-    expect(Date.now() - started).toBeLessThan(1_400)
-    // A late exit after the settled window changes nothing.
-    await new Promise(resolve => setTimeout(resolve, 1_600))
-  })
-
   it('hands the child a credential-scrubbed environment with explicit adapter entries', async () => {
     const root = await tempRoot()
     const witness = join(root, 'env.json')

+ 11 - 2
packages/lsp/lsp-stdio/tests/instance.spec.ts

@@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis'
 import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
 import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-stdio'
 import { encodeMessage } from '@deepseek-ai/dsh-lsp-stdio'
-import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-stdio/src/connection.ts'
+import type { ConnectionSpawner, ConnectionWriter } from '@deepseek-ai/dsh-lsp-stdio/src/connection.ts'
 import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-stdio/src/instance.ts'
 import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
 import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
@@ -45,6 +45,7 @@ function makeInstance(
   env: Record<string, string> = {},
   overrides: Partial<InstanceSpec> = {},
   writer?: ConnectionWriter,
+  spawner: ConnectionSpawner = spawnSubprocess,
 ): LspInstance {
   const instance = new LspInstance({
     command: process.execPath,
@@ -59,7 +60,7 @@ function makeInstance(
     shutdownTimeoutMs: 200,
     killGraceMs: 200,
     ...overrides,
-  }, spawnSubprocess, writer)
+  }, spawner, writer)
   live.push(instance)
   return instance
 }
@@ -211,6 +212,7 @@ describe('LspInstance query and abort', () => {
     const marker = join(root, 'initialized.log')
     const didOpenStarted = Promise.withResolvers<undefined>()
     let didOpenFinished = false
+    let processClosed = false
     const instance = makeInstance({
       LSP_FAKE_INITIALIZED_MARKER: marker,
       LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: '1',
@@ -227,6 +229,10 @@ describe('LspInstance query and abort', () => {
         done(error)
       })
       didOpenStarted.resolve(undefined)
+    }, (spec) => {
+      const handle = spawnSubprocess(spec)
+      void Promise.allSettled([handle.done]).then(([result]) => { processClosed = result.status === 'fulfilled' })
+      return handle
     })
     const controller = new AbortController()
     const outcome = run(instance, 'goToDefinition', controller.signal)
@@ -235,9 +241,12 @@ describe('LspInstance query and abort', () => {
     await didOpenStarted.promise
     signal.throwIfAborted()
     expect(didOpenFinished).toBe(false)
+    expect(processClosed).toBe(false)
     controller.abort(new Error('didOpen-abort'))
     const failure = await outcome
     expect(() => { throw failure }).toThrow(/didOpen-abort/)
+    expect(didOpenFinished).toBe(true)
+    expect(processClosed).toBe(true)
     expect(instance.dead).toBe(true)
   })
 

+ 48 - 34
packages/shell/pwsh-local/tests/executor.spec.ts

@@ -13,7 +13,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSyn
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { spawnSync } from 'node:child_process'
-import { afterAll, afterEach, describe, expect, it, onTestFinished } from 'vitest'
+import { afterAll, afterEach, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
@@ -40,6 +40,24 @@ afterEach(async () => {
   if (failures.length > 0) throw new AggregateError(failures, 'PowerShell fixture cleanup failed')
 })
 
+function createContext(): Context {
+  const ctx = new Context()
+  contexts.push(ctx)
+  return ctx
+}
+
+/** A private file barrier keeps the command alive until the test releases it. */
+function commandBarrier() {
+  const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-barrier-'))
+  tempDirs.push(dir)
+  const path = join(dir, 'release')
+  return {
+    command: 'while (-not (Test-Path -LiteralPath $env:DSH_TEST_RELEASE)) { Start-Sleep -Milliseconds 20 }',
+    env: { DSH_TEST_RELEASE: path },
+    release: () => { writeFileSync(path, '') },
+  }
+}
+
 // The probe follows the executor's own resolution (Program Files installs on
 // Windows are found even when bare `pwsh` is not on PATH).
 const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
@@ -56,8 +74,7 @@ function samePath(actual: string, expected: string): boolean {
 }
 
 async function setup(config: ConstructorParameters<typeof PwshLocalExecutor>[1] = {}) {
-  const ctx = new Context()
-  contexts.push(ctx)
+  const ctx = createContext()
   await ctx.plugin(LocalSubprocessRuntime)
   ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
   // A short kill grace via the REAL config path, so escalation tests stay fast.
@@ -67,9 +84,8 @@ async function setup(config: ConstructorParameters<typeof PwshLocalExecutor>[1]
 }
 
 /**
- * Poll a handle's consuming readOutput until the ACCUMULATED delta contains
- * `expected`; returns the accumulation (reads never re-deliver, so the caller
- * gets everything produced up to the match).
+ * Accumulate consuming reads until the marker arrives, using the current test's
+ * budget. Callers keep the child at a barrier when later output must remain unread.
  */
 async function readUntil(proc: ShellProcess, expected: string, timeoutMs: number): Promise<string> {
   let all = ''
@@ -202,7 +218,7 @@ describe('spawn construction (pure, every platform)', () => {
   }
 
   it('runs every command as ONE argv element under the UTF-8 encoding preamble', async () => {
-    const ctx = new Context()
+    const ctx = createContext()
     const subprocess = new CapturingSubprocessRuntime(ctx)
     await ctx.plugin(PwshLocalExecutor)
     await ctx.shell.run(ctx.shell.resolve({ command: 'Write-Output 你好' }))
@@ -215,7 +231,7 @@ describe('spawn construction (pure, every platform)', () => {
   })
 
   it('reports both unread stderr and an asynchronous provider rejection exactly once', async () => {
-    const ctx = new Context()
+    const ctx = createContext()
     const subprocess = new CapturingSubprocessRuntime(ctx)
     await ctx.plugin(PwshLocalExecutor)
     subprocess.stderrText = 'target stderr'
@@ -232,7 +248,7 @@ describe('spawn construction (pure, every platform)', () => {
   })
 
   it('settles an unprintable provider rejection instead of rejecting done', async () => {
-    const ctx = new Context()
+    const ctx = createContext()
     const subprocess = new CapturingSubprocessRuntime(ctx)
     await ctx.plugin(PwshLocalExecutor)
     const providerError = new Error('unprintable provider error')
@@ -249,7 +265,7 @@ describe('spawn construction (pure, every platform)', () => {
   })
 
   it('preserves an explicit kill stamp and maps an aborted direct outcome to killed', async () => {
-    const ctx = new Context()
+    const ctx = createContext()
     const subprocess = new CapturingSubprocessRuntime(ctx)
     await ctx.plugin(PwshLocalExecutor)
 
@@ -406,20 +422,25 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
 })
 
 describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)', () => {
-  it('start returns immediately with a running handle that settles as completed', async () => {
+  it('start returns immediately with a running handle that settles as completed', async ({ task }) => {
     const { bash } = await setup()
-    const before = Date.now()
-    // The sleep outlasts any realistic spawn latency, so returning while the
-    // child still sleeps proves start() does not wait for completion.
-    const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 2000; Write-Output done' }))
-    expect(Date.now() - before).toBeLessThan(1000)
+    const barrier = commandBarrier()
+    const proc = bash.start(bash.resolve({
+      command: `Write-Output ready; [Console]::Out.Flush(); ${barrier.command}; Write-Output done`,
+      env: barrier.env,
+    }))
     expect(proc.status).toBe('running')
+    expect(await readUntil(proc, 'ready\n', task.timeout)).toBe('ready\n')
+    expect(proc.status).toBe('running')
+    barrier.release()
     await proc.done
     expect(proc.status).toBe('completed')
+    expect(proc.signal).toBeNull()
     expect(proc.exitCode).toBe(0)
+    expect(lf(proc.readOutput().delta)).toBe('done\n')
   })
 
-  it('threads stdin and extra env into a background process', async ({ task }) => {
+  it('threads stdin and extra env into a background process', async () => {
     const { bash } = await setup()
     const proc = bash.start(bash.resolve({
       command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:BG_VAR][$env:DSH_BG_VAR]"',
@@ -427,32 +448,25 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
       env: { BG_VAR: 'bg-env' },
       dshEnv: { DSH_BG_VAR: 'bg-dsh-env' },
     }))
-    const partialOutput = await readUntil(proc, '[bg-env][bg-dsh-env]', task.timeout)
     await proc.done
     expect(proc.status).toBe('completed')
-    const output = partialOutput + lf(proc.readOutput().delta)
-    expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n')
+    expect(proc.signal).toBeNull()
     expect(proc.exitCode).toBe(0)
+    expect(lf(proc.readOutput().delta)).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n')
   })
 
   it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async ({ task }) => {
-    const { ctx, bash } = await setup()
-    const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-read-'))
-    onTestFinished(async () => {
-      await ctx.fiber.dispose()
-      rmSync(dir, { recursive: true, force: true })
-    })
-    const releasePath = join(dir, 'release')
-    // The second write cannot race the host's first consuming read.
+    const { bash } = await setup()
+    const barrier = commandBarrier()
     const proc = bash.start(bash.resolve({
-      command: 'Write-Output first; [Console]::Out.Flush(); while (-not [System.IO.File]::Exists($env:DSH_PWSH_RELEASE)) { Start-Sleep -Milliseconds 20 }; Write-Output second',
-      env: { DSH_PWSH_RELEASE: releasePath },
+      command: `Write-Output first; [Console]::Out.Flush(); ${barrier.command}; Write-Output second`,
+      env: barrier.env,
     }))
     const first = await readUntil(proc, 'first\n', task.timeout)
-    expect(lf(first)).toBe('first\n')
+    expect(first).toBe('first\n')
     expect(proc.status).toBe('running')
     expect(proc.readOutput().delta).toBe('')
-    writeFileSync(releasePath, '')
+    barrier.release()
     await proc.done
     expect(proc.status).toBe('completed')
     expect(proc.exitCode).toBe(0)
@@ -552,7 +566,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
 
 describe.skipIf(!hasPwsh)('process lifecycle ownership (the subprocess service, not the executor)', () => {
   it('a background process survives executor-fiber disposal and dies with the subprocess service', async ({ task }) => {
-    const ctx = new Context()
+    const ctx = createContext()
     const managerFiber = await ctx.plugin(LocalSubprocessRuntime)
     ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
     const executorFiber = await ctx.plugin(PwshLocalExecutor, { graceMs: 200 })
@@ -584,7 +598,7 @@ describe.skipIf(!hasPwsh)('process lifecycle ownership (the subprocess service,
   })
 
   it('service disposal settles running handles and leaves settled ones untouched', async () => {
-    const ctx = new Context()
+    const ctx = createContext()
     const managerFiber = await ctx.plugin(LocalSubprocessRuntime)
     ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
     await ctx.plugin(PwshLocalExecutor, { graceMs: 200 })

+ 1 - 1
scripts/ci-workflow.spec.ts

@@ -837,7 +837,7 @@ describe('Weighted approval workflow', () => {
       'timeout-minutes': 2,
     })
     expect(record).toBeDefined()
-    expect(record?.run).toContain('This is by automated Angry Turtle Cyborg, not a human')
+    expect(record?.run).toBe("echo 'Recorded a weighted approval review event.'")
     expect(recordSteps).toHaveLength(1)
     expect(JSON.stringify(publisher)).not.toContain('github.event.pull_request.head')
     expect(JSON.stringify(publisher)).not.toContain('secrets.')

+ 1 - 1
snapshots/web/cordis-tool-round/tool-schemas.expected.json

@@ -522,7 +522,7 @@
     },
     {
       "name": "present",
-      "description": "Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.",
+      "description": "Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.",
       "parameters": {
         "type": "object",
         "properties": {

+ 1 - 1
snapshots/web/fresh-round-trip/tool-schemas.expected.json

@@ -325,7 +325,7 @@
     },
     {
       "name": "present",
-      "description": "Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.",
+      "description": "Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.",
       "parameters": {
         "type": "object",
         "properties": {

File diff suppressed because it is too large
+ 14 - 0
snapshots/web/present-svg/session.v3.jsonl


+ 9 - 0
snapshots/web/present-svg/snapshot.yml

@@ -0,0 +1,9 @@
+version: 1
+scenario: present-svg
+profile: web
+composition: web-default
+recording: live
+header:
+  class: web-default
+workspace:
+  final: true

+ 73 - 0
snapshots/web/present-svg/ui.expected.md

@@ -0,0 +1,73 @@
+- button "系统提示词":
+  - img
+  - img
+  - text: 系统提示词
+- text: 简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg {{clock}}
+- button "复制":
+  - img
+- button "2 次工具调用 · 1 条消息" [expanded]:
+  - text: 2 次工具调用 · 1 条消息
+  - img
+- button "上下文注入 @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: 上下文注入 @deepseek-ai/dsh-system-prompt
+- button "思考 The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses.":
+  - img
+  - img
+  - text: 思考 The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses.
+- paragraph: 我来创建一个冯诺依曼架构的 SVG 示意图。
+- button "写入 von-neumann.svg +83 -0":
+  - img
+  - img
+  - text: 写入
+  - button "von-neumann.svg"
+  - text: +83 -0
+- button "思考 The file is created. Let me present it as a deliverable.":
+  - img
+  - img
+  - text: 思考 The file is created. Let me present it as a deliverable.
+- button "交付文件 已交付 von-neumann.svg":
+  - img
+  - text: 交付文件 已交付 von-neumann.svg
+- paragraph:
+  - text: 已创建
+  - code:
+    - button "在默认程序中打开 von-neumann.svg": von-neumann.svg
+  - text: ,一个简洁的冯诺依曼架构示意图,包含:
+- list:
+  - listitem:
+    - strong: CPU(中央处理器)
+    - text: :内含控制单元 CU、算术逻辑单元 ALU 和寄存器组
+  - listitem:
+    - strong: 内存储器 Memory
+    - text: :标注"指令和数据统一存放"(存储程序思想)
+  - listitem:
+    - strong: 输入设备 / 输出设备
+    - text: :分别用绿色和粉色表示数据流向
+  - listitem:
+    - strong: 总线连接
+    - text: :CPU 与内存间的地址总线,以及 CPU 与 I/O 设备间的数据/控制线,箭头标明数据流向
+- paragraph: 下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。
+- text: 本轮文件改动
+- button "打开 {{cwd}}/workspace/von-neumann.svg": von-neumann.svg
+- text: 交付文件
+- button "在默认程序中打开 von-neumann.svg":
+  - text: von-neumann.svg SVG 冯诺依曼架构示意图 SVG
+  - img
+  - text: 打开
+- button "复制":
+  - img
+- button "好的回答":
+  - img
+- button "有问题的回答":
+  - img
+- button "在新对话中分支":
+  - img
+- button "用量 32K tok":
+  - img
+  - text: 用量 32K tok
+- button "用时 {{duration}}":
+  - img
+  - text: 用时 {{duration}}
+- text: {{clock}}

+ 83 - 0
snapshots/web/present-svg/workspace.expected/von-neumann.svg

@@ -0,0 +1,83 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 560" width="900" height="560" font-family="Helvetica, Arial, sans-serif">
+  <title>冯诺依曼架构</title>
+
+  <defs>
+    <marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
+      <path d="M0,0 L8,3 L0,6 Z" fill="#555"/>
+    </marker>
+  </defs>
+
+  <!-- Background -->
+  <rect x="0" y="0" width="900" height="560" fill="#fafbfc"/>
+  <text x="450" y="34" text-anchor="middle" font-size="22" font-weight="bold" fill="#222">冯诺依曼体系结构 (Von Neumann Architecture)</text>
+
+  <!-- ===== CPU ===== -->
+  <rect x="300" y="70" width="300" height="240" rx="10" fill="#fff" stroke="#1f6feb" stroke-width="2.5"/>
+  <text x="450" y="98" text-anchor="middle" font-size="17" font-weight="bold" fill="#1f6feb">中央处理器 CPU</text>
+
+  <!-- Control Unit -->
+  <rect x="325" y="118" width="120" height="160" rx="8" fill="#ddf4ff" stroke="#54aeff" stroke-width="1.5"/>
+  <text x="385" y="142" text-anchor="middle" font-size="12.5" font-weight="bold" fill="#0969da">控制单元</text>
+  <text x="385" y="158" text-anchor="middle" font-size="11" fill="#0b5394">CU</text>
+  <text x="385" y="262" text-anchor="middle" font-size="10" fill="#555">指令译码 · 时序控制</text>
+
+  <!-- ALU -->
+  <rect x="455" y="118" width="120" height="160" rx="8" fill="#fff8c5" stroke="#d4a72c" stroke-width="1.5"/>
+  <text x="515" y="142" text-anchor="middle" font-size="12.5" font-weight="bold" fill="#9a6700">算术逻辑单元</text>
+  <text x="515" y="158" text-anchor="middle" font-size="11" fill="#7a5200">ALU</text>
+  <text x="515" y="262" text-anchor="middle" font-size="10" fill="#555">算术运算 · 逻辑运算</text>
+
+  <!-- Registers -->
+  <rect x="330" y="186" width="110" height="56" rx="6" fill="#fff" stroke="#8c959f" stroke-width="1.2" stroke-dasharray="4,3"/>
+  <text x="385" y="208" text-anchor="middle" font-size="11" fill="#444">寄存器组</text>
+  <text x="385" y="224" text-anchor="middle" font-size="10" fill="#666">Registers / 缓存</text>
+
+  <!-- ===== Memory ===== -->
+  <rect x="300" y="400" width="300" height="110" rx="10" fill="#fff" stroke="#cf222e" stroke-width="2.5"/>
+  <text x="450" y="428" text-anchor="middle" font-size="17" font-weight="bold" fill="#cf222e">内存储器 Memory</text>
+  <text x="450" y="452" text-anchor="middle" font-size="12" fill="#444">存储单元: 指令和数据</text>
+  <text x="450" y="470" text-anchor="middle" font-size="11" fill="#666">(存放程序与数据,同一总线存取)</text>
+
+  <!-- ===== Input ===== -->
+  <rect x="40" y="150" width="170" height="120" rx="10" fill="#fff" stroke="#1a7f37" stroke-width="2.5"/>
+  <text x="125" y="186" text-anchor="middle" font-size="15" font-weight="bold" fill="#1a7f37">输入设备</text>
+  <text x="125" y="206" text-anchor="middle" font-size="11" fill="#444">Input</text>
+  <text x="125" y="238" text-anchor="middle" font-size="10" fill="#666">键盘 · 鼠标 · 磁盘</text>
+
+  <!-- ===== Output ===== -->
+  <rect x="690" y="150" width="170" height="120" rx="10" fill="#fff" stroke="#bf3989" stroke-width="2.5"/>
+  <text x="775" y="186" text-anchor="middle" font-size="15" font-weight="bold" fill="#bf3989">输出设备</text>
+  <text x="775" y="206" text-anchor="middle" font-size="11" fill="#444">Output</text>
+  <text x="775" y="238" text-anchor="middle" font-size="10" fill="#666">显示器 · 打印机</text>
+
+  <!-- ===== Bus lines ===== -->
+  <!-- Address bus CPU <-> Memory (red) -->
+  <path d="M450,320 L450,398" fill="none" stroke="#cf222e" stroke-width="3"/>
+  <text x="420" y="362" text-anchor="end" font-size="11" fill="#cf222e" transform="rotate(-90 420 362)">地址总线</text>
+
+  <!-- Data/control bus label along the bottom -->
+  <line x1="300" y1="355" x2="600" y2="355" stroke="#555" stroke-width="1" stroke-dasharray="2,3" opacity="0.0"/>
+  <text x="450" y="353" text-anchor="middle" font-size="12" font-weight="bold" fill="#555">← 数据总线 / 控制总线 →</text>
+
+  <!-- CPU <-> Input (bidirectional-ish: input data in + control out) -->
+  <path d="M212,210 L298,210" fill="none" stroke="#1a7f37" stroke-width="3" marker-end="url(#arrow)"/>
+  <path d="M212,196 L298,196" fill="none" stroke="#555" stroke-width="2" marker-end="url(#arrow)"/>
+  <text x="255" y="184" text-anchor="middle" font-size="10.5" fill="#1a7f37">数据(输入)</text>
+  <text x="255" y="236" text-anchor="middle" font-size="10" fill="#555">控制</text>
+
+  <!-- CPU <-> Output -->
+  <path d="M602,210 L688,210" fill="none" stroke="#bf3989" stroke-width="3" marker-end="url(#arrow)"/>
+  <path d="M602,196 L688,196" fill="none" stroke="#555" stroke-width="2" marker-end="url(#arrow)"/>
+  <text x="645" y="184" text-anchor="middle" font-size="10.5" fill="#bf3989">数据(输出)</text>
+  <text x="645" y="236" text-anchor="middle" font-size="10" fill="#555">控制</text>
+
+  <!-- Memory <-> Input/Output hints: 存储器与 I/O 也通过总线交换 -->
+  <path d="M125,272 L125,300 Q125,340 175,340 L298,340" fill="none" stroke="#8c959f" stroke-width="1.8" stroke-dasharray="6,4" marker-end="url(#arrow)"/>
+  <text x="160" y="328" text-anchor="middle" font-size="10" fill="#666" transform="rotate(-8 160 328)">总线</text>
+
+  <path d="M775,272 L775,300 Q775,340 725,340 L602,340" fill="none" stroke="#8c959f" stroke-width="1.8" stroke-dasharray="6,4" marker-end="url(#arrow)"/>
+  <text x="740" y="328" text-anchor="middle" font-size="10" fill="#666" transform="rotate(8 740 328)">总线</text>
+
+  <!-- Stored-program note -->
+  <text x="450" y="530" text-anchor="middle" font-size="11.5" fill="#666">核心思想: 指令与数据以二进制形式统一存放在存储器中 —— “存储程序”</text>
+</svg>

+ 1 - 1
snapshots/web/ptc-round/system-prompt.expected.md

@@ -162,7 +162,7 @@ interface ToolArgsMap {
     /** children (default) lists direct children only; descendants walks the complete tree below you. */
     scope?: "children" | "descendants";
   } & Record<string, JsonValue>;
-  /** Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool. */
+  /** Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved. */
   present: {
     files: {
       /** Path of an existing file inside the workspace. */

+ 2 - 0
tsconfig.host.json

@@ -52,7 +52,9 @@
     "apps/web/tests/conversation-column-overflow.e2e.ts",
     "apps/web/tests/ptc-round.e2e.ts",
     "apps/web/tests/present.e2e.ts",
+    "apps/web/tests/present-svg.e2e.ts",
     "apps/web/tests/composer-draft-scroll.e2e.ts",
+    "apps/web/tests/composer-placeholder.e2e.ts",
     "apps/web/tests/cordis-tool-round.e2e.ts",
     "apps/web/tests/web-search-round.e2e.ts",
     "apps/web/tests/file-upload-round.e2e.ts",

Some files were not shown because too many files changed in this diff