Explorar o código

Merge remote-tracking branch 'origin/master' into worktree/2848-image-token-pressure

creatixchu hai 1 mes
pai
achega
bcdbd85d15
Modificáronse 97 ficheiros con 834 adicións e 585 borrados
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml
  2. 3 3
      .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md
  3. 3 3
      .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml
  8. 2 2
      .agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md
  9. 2 2
      .agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml
  11. 1 1
      .agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md
  12. 1 1
      .agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md
  13. 6 0
      .agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml
  14. 33 0
      .agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md
  15. 33 0
      .agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md
  16. 2 2
      apps/cli/reference/README.i18n.yaml
  17. 1 1
      apps/cli/reference/README.md
  18. 1 1
      apps/cli/reference/README.zh.md
  19. 1 1
      apps/cli/src/bin.ts
  20. 2 3
      apps/cli/src/dump-config.ts
  21. 11 11
      apps/cli/src/profile-boot.ts
  22. 1 0
      apps/cli/tests/built-bin.e2e.ts
  23. 2 13
      apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts
  24. 93 0
      apps/web/tests/trajectory-image-display.expected.e2e.ts
  25. 2 2
      docs/config-catalog.i18n.yaml
  26. 3 14
      docs/config-catalog.md
  27. 3 14
      docs/config-catalog.zh.md
  28. 2 2
      docs/module-graph.i18n.yaml
  29. 5 4
      docs/module-graph.md
  30. 5 4
      docs/module-graph.zh.md
  31. 2 2
      docs/subsystems/slots.i18n.yaml
  32. 2 1
      docs/subsystems/slots.md
  33. 2 1
      docs/subsystems/slots.zh.md
  34. 2 2
      packages/client/ui-attachment/README.i18n.yaml
  35. 1 1
      packages/client/ui-attachment/README.md
  36. 1 1
      packages/client/ui-attachment/README.zh.md
  37. 5 2
      packages/client/ui-attachment/package.json
  38. 5 0
      packages/client/ui-attachment/src/client/index.ts
  39. 7 1
      packages/client/ui-attachment/tests/plugin.client.spec.ts
  40. 3 0
      packages/client/ui-attachment/tsconfig.json
  41. 2 2
      packages/client/ui-chat/README.i18n.yaml
  42. 1 1
      packages/client/ui-chat/README.md
  43. 1 1
      packages/client/ui-chat/README.zh.md
  44. 0 2
      packages/client/ui-chat/package.json
  45. 1 3
      packages/client/ui-chat/src/client/apply.ts
  46. 4 2
      packages/client/ui-chat/src/client/chat/ChatView.tsx
  47. 1 12
      packages/client/ui-chat/src/client/contract/slots.ts
  48. 1 2
      packages/client/ui-chat/src/client/index.ts
  49. 1 1
      packages/client/ui-chat/tests/image-labels.client.spec.tsx
  50. 0 3
      packages/client/ui-chat/tsconfig.json
  51. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  52. 1 1
      packages/client/ui-conversation/README.md
  53. 1 1
      packages/client/ui-conversation/README.zh.md
  54. 14 0
      packages/client/ui-conversation/src/client/contract/slots.ts
  55. 15 0
      packages/client/ui-conversation/src/client/conversation/assembly.ts
  56. 11 12
      packages/client/ui-conversation/src/client/conversation/historical-images.ts
  57. 1 1
      packages/client/ui-conversation/src/client/index.ts
  58. 3 3
      packages/client/ui-conversation/tests/historical-images.client.spec.ts
  59. 2 2
      packages/client/ui-trajectory/README.i18n.yaml
  60. 0 0
      packages/client/ui-trajectory/README.md
  61. 0 0
      packages/client/ui-trajectory/README.zh.md
  62. 4 2
      packages/client/ui-trajectory/package.json
  63. 0 29
      packages/client/ui-trajectory/src/client/TrajectoryTable.module.css
  64. 59 46
      packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
  65. 15 5
      packages/client/ui-trajectory/src/client/TrajectoryView.tsx
  66. 4 0
      packages/client/ui-trajectory/src/client/index.ts
  67. 32 50
      packages/client/ui-trajectory/src/client/layout.ts
  68. 2 2
      packages/client/ui-trajectory/src/client/locales.ts
  69. 12 2
      packages/client/ui-trajectory/src/client/trajectory-contract.ts
  70. 2 2
      packages/client/ui-trajectory/src/client/trajectory-record.ts
  71. 1 1
      packages/client/ui-trajectory/src/client/trajectory-search-index.ts
  72. 100 0
      packages/client/ui-trajectory/tests/layout.client.spec.tsx
  73. 136 2
      packages/client/ui-trajectory/tests/table.client.spec.tsx
  74. 4 0
      packages/client/ui-trajectory/tests/views.client.spec.tsx
  75. 3 0
      packages/client/ui-trajectory/tsconfig.json
  76. 2 2
      packages/core/system-prompt/README.i18n.yaml
  77. 0 1
      packages/core/system-prompt/README.md
  78. 0 1
      packages/core/system-prompt/README.zh.md
  79. 0 4
      packages/core/system-prompt/src/index.ts
  80. 0 15
      packages/core/system-prompt/tests/system-prompt.spec.ts
  81. 2 2
      packages/examples/agent-spine-demo/README.i18n.yaml
  82. 2 2
      packages/examples/agent-spine-demo/README.md
  83. 2 2
      packages/examples/agent-spine-demo/README.zh.md
  84. 3 7
      packages/examples/agent-spine-demo/src/index.ts
  85. 1 5
      packages/examples/agent-spine-demo/tests/agent-core.spec.ts
  86. 64 26
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  87. 2 2
      packages/sdk/server/README.i18n.yaml
  88. 2 2
      packages/sdk/server/README.md
  89. 2 2
      packages/sdk/server/README.zh.md
  90. 0 13
      packages/sdk/server/src/index.ts
  91. 0 7
      packages/sdk/server/src/server.ts
  92. 0 48
      packages/sdk/server/tests/plugin-apply.spec.ts
  93. 0 41
      packages/sdk/server/tests/server.spec.ts
  94. 6 3
      pnpm-lock.yaml
  95. 1 2
      python/sdk/src/deepseek_harness/client.py
  96. 18 23
      python/sdk/tests/test_client.py
  97. 33 78
      scripts/smoke-python-runtime.py

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.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/architecture/2026-08-20-client-session-conversation-ownership.md
-2026-08-20-client-session-conversation-ownership.md: 8e5521ff1981d83ab72db00dea556b4b2acc97fa
-2026-08-20-client-session-conversation-ownership.zh.md: a007a42b3d10ceeced8a2a64696521a96382f4e5
+2026-08-20-client-session-conversation-ownership.md: 12e137209bb43d21c3437d2ce5e9d2f9180bbc77
+2026-08-20-client-session-conversation-ownership.zh.md: f0d9861eeafc07481c536b85f5ba9667573a66ef

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md

@@ -82,7 +82,7 @@ Adding a target does not add a branch to the renderer or Session Controller. The
 | `client/ui-session` | Session scope, standard sources, `SessionProvider`, and pending-interaction aggregation | Session transport, Conversation assembly, Approval/Question results |
 | `client/ui-workspace` | Workspace hook, browser UI, and cross-Controller navigation policy | Workspace transport, copies of Session data |
 | `client/ui-conversation` | Conversation core, registries, bindings, shell, input, composer, queue, and View navigation | Session transport, Chat/Trajectory snapshots |
-| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, locale, and historical images | Session lifecycle, generic View navigation, Trajectory |
+| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, and locale | Session lifecycle, generic View navigation, Trajectory, historical-image cache |
 | `client/ui-trajectory` | Trajectory target, event-record projection, and inspection view | Session snapshots, Chat snapshots |
 | `client/ui-approval` | Pending Approval, Remote listener, composer, and approval UI | Session control, generic composer election |
 | `client/ui-user-questions` | Pending Question, Remote listener, composer, and question UI | Session control, generic composer election |
@@ -296,13 +296,13 @@ Draft and input state belong to Conversation UI and do not enter the Session sna
 
 ### Chat owner
 
-`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, Tool-inspection collaboration, and historical-image cache.
+`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, and Tool-inspection collaboration.
 
 It registers the `chat` target source through `ctx.uiSession.provide()`. `ChatNodeSeat` and internal Chat consumers use `useChat` instead of passing `useConversation(snapshot => snapshot.views.get('chat'))`.
 
 Only visible non-command Chat Nodes activate Chat. Ordinary command-only history keeps the Hero visible; the `/goal` `command-input` Node activates a fresh Conversation.
 
-The historical-image cache's Session key, pending promise, generation guard, blob URL, and disposer all belong to `ui-chat`; draft images remain part of Conversation input.
+The historical-image cache moved to `ui-conversation` (`ctx.uiConversation.imageUrl`), so Chat and Trajectory share one authorized read and one browser URL per session attachment ([Trajectory durable image attachments](../feature/2026-08-24-trajectory-image-attachments.md)); draft images remain part of Conversation input.
 
 ### Trajectory owner
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md

@@ -82,7 +82,7 @@ UI 层可以同时读取多个 Controller 做一次导航决定,但不得把
 | `client/ui-session` | Session scope、标准 source、`SessionProvider`、pending interaction 聚合 | Session transport、Conversation 组装、Approval/Question 结果 |
 | `client/ui-workspace` | Workspace hook、浏览器 UI 和跨 Controller 导航策略 | Workspace transport、Session 数据副本 |
 | `client/ui-conversation` | Conversation core、registry、binding、shell、input、composer、queue 和 View 导航 | Session transport、Chat/Trajectory snapshot |
-| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details、locale 和历史图片 | Session 生命周期、通用 View 导航、Trajectory |
+| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details 和 locale | Session 生命周期、通用 View 导航、Trajectory、历史图片 cache |
 | `client/ui-trajectory` | Trajectory target、事件记录投影和检查视图 | Session snapshot、Chat snapshot |
 | `client/ui-approval` | Pending Approval、Remote listener、composer 和审批 UI | Session control、通用 composer election |
 | `client/ui-user-questions` | Pending Question、Remote listener、composer 和问题 UI | Session control、通用 composer election |
@@ -296,13 +296,13 @@ Draft 与输入状态属于 Conversation UI,不进入 Session snapshot。Queue
 
 ### Chat owner
 
-`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale、tool inspection 协作和历史图片 cache。
+`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale 和 tool inspection 协作。
 
 它通过 `ctx.uiSession.provide()` 注册 `chat` target source。`ChatNodeSeat` 和 Chat 内部消费者使用 `useChat`,不再传递 `useConversation(snapshot => snapshot.views.get('chat'))`。
 
 Chat activity 只由可见且非 command 的 Chat Node 激活。普通 command-only history 保持 Hero,`/goal` 的 `command-input` Node 激活 fresh Conversation。
 
-历史图片 cache 的 Session key、pending promise、generation guard、blob URL 和 disposer 同属 `ui-chat`;Draft 图片仍属于 Conversation input。
+历史图片 cache 已移入 `ui-conversation`(`ctx.uiConversation.imageUrl`),Chat 与 Trajectory 对同一会话附件共享一次授权读取和一个浏览器 URL([Trajectory 持久化图片附件](../feature/2026-08-24-trajectory-image-attachments.zh.md));Draft 图片仍属于 Conversation input。
 
 ### Trajectory owner
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md
-2026-08-23-python-sdk-dsh-profile-runtime.md: dcb4f77048d516f0e187b611dc09c224fba47b58
-2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 505b49506ae4c4be4809d588448b2c593d3ebaf5
+2026-08-23-python-sdk-dsh-profile-runtime.md: 4af7812db6818b65c754a43ec1a7f973d1cbcbf9
+2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 155e7d2ae0b0ba3e4163dd85a31de90bef9d588a

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md

@@ -24,7 +24,7 @@ Every Python launch requires either explicit `dsh_home` or a non-empty `DSH_HOME
 
 Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile <name> ...` manages external dependencies and bundle order, `$DSH_HOME/profiles/<name>/cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A selected profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback; a profile that remains alive without serving JSON-RPC fails the independently bounded initialization handshake with a diagnostic naming that profile.
 
-The [standalone sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.md) lists one repository-owned bundle that inserts its complete explicit tree without `dsh-base`. Its persistent Bash and string-replace editor are present by composition rather than a server filter; dynamic runtime context, workspace instructions, settings, managed credentials, telemetry, compaction, and every other base row are absent. The same runtime still packages the full `sdk` and `web` profiles as separate choices.
+The [standalone sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.md) lists one repository-owned bundle that inserts its complete explicit tree without `dsh-base`. Its persistent Bash and string-replace editor are present by composition; the shared JSON-RPC server exposes no root-agent tool filter. Dynamic runtime context, workspace instructions, settings, managed credentials, telemetry, compaction, and every other base row are absent. The same runtime still packages the full `sdk` and `web` profiles as separate choices.
 
 The runtime wheel installs a `dsh` console command. Ordinary profile and SDK execution remains Node-free; external package management requires a caller-installed `pnpm`.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md

@@ -24,7 +24,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml
 
 持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile <name> ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles/<name>/cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。所选 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退;保持运行却不提供 JSON-RPC 服务的 profile 会在独立有界的初始化握手中失败,诊断会指明该 profile。
 
-[独立 sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.zh.md)只列出一个仓库自有组合包,该组合包会插入不含 `dsh-base` 的完整显式配置树。持久 Bash 与字符串替换 editor 通过组合存在,而不是通过 server 筛选;动态运行时上下文、workspace 指令、settings、托管凭据、遥测、compaction 与其他所有 base 配置项均不存在。同一运行时仍会把完整 `sdk` 与 `web` profile 作为独立选择打包。
+[独立 sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.zh.md)只列出一个仓库自有组合包,该组合包会插入不含 `dsh-base` 的完整显式配置树。持久 Bash 与字符串替换 editor 通过组合存在;共享 JSON-RPC server 不暴露根 agent 工具筛选器。动态运行时上下文、workspace 指令、settings、托管凭据、遥测、compaction 与其他所有 base 配置项均不存在。同一运行时仍会把完整 `sdk` 与 `web` profile 作为独立选择打包。
 
 运行时 wheel 安装 `dsh` 控制台命令。普通 profile 与 SDK 运行仍不需要 Node;外部包管理要求调用方自行安装 `pnpm`。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.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/architecture/2026-08-24-standalone-sdk-minimal-profile.md
-2026-08-24-standalone-sdk-minimal-profile.md: 692bf9763f4ad710ff5cc819a7480b2f3e8d9b5f
-2026-08-24-standalone-sdk-minimal-profile.zh.md: f39baad1b4429b73f13d601716dadd8376c71bfb
+2026-08-24-standalone-sdk-minimal-profile.md: 9c8afbaaf1a8e522af119c1d42ca5ad714eaa879
+2026-08-24-standalone-sdk-minimal-profile.zh.md: b1cd2a348c4b77f197e30a658c13691b2f35d26e

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md

@@ -40,11 +40,11 @@ It also supersedes the minimal-overlay realization in [Python SDK runtime throug
 
 ## Verification
 
-The bundle test pins the exact row and dependency roster. Profile-template and config-dump tests pin the one-bundle manifest, startup-only lifecycle, absence of `dsh-base`, and absence of module HMR. The keyless Python example test boots the real `dsh --profile sdk-minimal` process and asserts the generated manifest, complete system prompt, and two advertised tools. The installed-wheel minimal scenario exercises persistent shell state, editor effects, JSONL persistence, and the committed model-visible snapshot through the packaged executable.
+The bundle test pins the exact row and dependency roster. Profile-template and config-dump tests pin the one-bundle manifest, startup-only lifecycle, absence of `dsh-base`, and absence of module HMR. The keyless source test boots the real `dsh --profile sdk-minimal` process, completes a turn, and asserts the generated manifest. The installed-wheel minimal scenario owns the complete system prompt and two advertised tools in its committed model-visible snapshot while exercising persistent shell state, editor effects, and JSONL persistence through the packaged executable.
 
 ## Alternatives considered
 
-**Keep the minimal mode as an overlay on `sdk`.** Rejected because filtering model-visible tools does not remove base services, prompt contributors, persistence choices, or later runtime behavior. It also makes the minimal application depend on controls in shared SDK server and system-prompt interfaces.
+**Keep the minimal mode as an overlay on `sdk`.** Rejected because filtering model-visible tools does not remove base services, prompt contributors, persistence choices, or later runtime behavior. It also required root-tool filtering in the shared SDK server and a complete-persona shortcut in the system-prompt config; neither shared interface carries those composition controls.
 
 **Restore a Python `cordis` argument or environment-selected complete config.** Rejected because it recreates a Python-owned application composition and bypasses profile plugin management and launcher lifecycle.
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md

@@ -40,11 +40,11 @@ Python 运行时继续打包 `dsh-web-app` 与前端产物。`dsh web` 会从已
 
 ## 验证
 
-组合包测试固定确切配置项与依赖清单。Profile 模板与配置 dump 测试固定单组合包 manifest、仅启动时生命周期、`dsh-base` 缺席与模块 HMR 缺席。Keyless Python 示例测试启动真实 `dsh --profile sdk-minimal` 进程,并断言生成的 manifest、完整系统提示词与两个对外公布的工具。Installed-wheel 极简场景通过打包可执行程序验证持久 shell 状态、editor 文件效果、JSONL 持久化与已提交的模型可见快照。
+组合包测试固定确切配置项与依赖清单。Profile 模板与配置 dump 测试固定单组合包 manifest、仅启动时生命周期、`dsh-base` 缺席与模块 HMR 缺席。Keyless 源码测试启动真实 `dsh --profile sdk-minimal` 进程、完成一个回合并断言生成的 manifest。Installed-wheel 极简场景通过已提交的模型可见快照固定完整系统提示词和两个对外公布的工具,同时经由打包可执行程序验证持久 shell 状态、editor 文件效果与 JSONL 持久化。
 
 ## 考虑过的替代方案
 
-**继续把极简模式作为 `sdk` 上的 overlay。** 否决:筛选面向模型的工具不会移除 base 服务、提示词贡献方、持久化选择或后续运行时行为,还会让极简应用依赖共享 SDK server 与系统提示词接口中的控制项。
+**继续把极简模式作为 `sdk` 上的 overlay。** 否决:筛选面向模型的工具不会移除 base 服务、提示词贡献方、持久化选择或后续运行时行为。该方案还要求共享 SDK server 提供根工具筛选,并要求 system-prompt 配置提供 complete-persona 快捷项;这两个共享接口均不再携带这些组合控制项。
 
 **恢复 Python `cordis` 参数或由环境选择的完整配置。** 否决:这会重新创建 Python 自有应用组合,并绕过 profile 插件管理与 launcher 生命周期。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.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-08-11-minimal-profiles-bare-two-tool-runtime.md
-2026-08-11-minimal-profiles-bare-two-tool-runtime.md: d201a7557136822d82950466e39ae0efd27a5888
-2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: b56f5cea26bb9305ad75a10d153b64f11f841e96
+2026-08-11-minimal-profiles-bare-two-tool-runtime.md: ffe8e799e1ba5a71939e3a17d484afd209b4014f
+2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 587ba696382575244a75f23fe69c14252caf7544

+ 1 - 1
.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md

@@ -22,7 +22,7 @@ The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bund
 
 The Web replay boots the complete Web host, creates the agent through the preset service, and asserts that the scoped filesystem is bare, no scoped compaction service exists, no system-prompt-owned runtime-context message was appended, and the assembled request contains exactly the fixed prompt and two tools. It then executes persistent Bash and the editor against the real scoped services.
 
-The SDK keyless process test boots real `dsh --profile sdk-minimal`, injects an environment-selected prompt, and asserts the generated one-bundle manifest, assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message. Python SDK bundled-runtime coverage initializes the standalone profile through each available packaged carrier with environment-selected model, model capacity, and prompt values, then executes the selected persistent shell and editor. Cordis validation checks that both configurations resolve their declared plugins and configuration fields.
+The SDK keyless source test boots real `dsh --profile sdk-minimal`, completes a turn with an environment-selected prompt, and asserts the generated one-bundle manifest. The Python SDK bundled-runtime snapshot owns the assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message. Packaged-runtime coverage initializes the standalone profile through each available carrier with environment-selected model, model capacity, and prompt values, then executes the selected persistent shell and editor. Cordis validation checks that both configurations resolve their declared plugins and configuration fields.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md

@@ -22,7 +22,7 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash`
 
 Web 回放会启动完整 Web 宿主,通过 preset 服务创建 agent,并断言作用域文件系统为裸后端、不存在作用域压缩服务、没有追加 system-prompt 拥有的 runtime-context 消息,而且组装请求只包含固定提示词与两个工具。随后,它通过真实作用域服务执行持久 Bash 和编辑器。
 
-SDK keyless 进程测试启动真实 `dsh --profile sdk-minimal`,注入由环境选择的提示词,并断言生成的单组合包 manifest、组装提示词、精确双工具目录,以及不存在任何 system-prompt 拥有的 runtime-context 消息。Python SDK 内置运行时覆盖会通过每种可用的打包载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行所选持久 shell 与 editor。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。
+SDK keyless 源码测试启动真实 `dsh --profile sdk-minimal`,使用环境选择的提示词完成一个回合,并断言生成的单组合包 manifest。Python SDK 打包运行时快照固定组装提示词、精确双工具目录,并固定不存在任何 system-prompt 所拥有的 runtime-context 消息。打包运行时覆盖会通过每种可用载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行所选持久 shell 与 editor。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。
 
 ## 考虑过的替代方案
 

+ 6 - 0
.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md
+2026-08-24-trajectory-image-attachments.md: 6f89840a7d4686e45012ea2ae25f4cc939a5ca0c
+2026-08-24-trajectory-image-attachments.zh.md: f1e8634639e42adc094021c03a339b8f8223bfff

+ 33 - 0
.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md

@@ -0,0 +1,33 @@
+# Agent Note: Trajectory durable image attachments
+
+Status: implemented
+
+English | [中文](2026-08-24-trajectory-image-attachments.zh.md)
+
+## Problem
+
+Trajectory did not display session images. A durable `{ type: 'image', attachment: ImageAttachmentRef }` block rendered as pretty-printed JSON in the details panel, and an image-only user message produced an empty ledger row. The only image path Trajectory knew was `imageSrc` sniffing over inline wire fields (`url`, `image_url`, base64 `data`), which no production event carries: every producer commits a durable `ImageAttachmentRef` before its event is appended. Users could not confirm from the execution ledger which image the model saw ([issue #2986](https://github.com/deepseek-harness/deepseek-harness/issues/2986)), while Chat already displayed the same attachments.
+
+## Decision
+
+- `ui-conversation` owns the per-session durable image URL cache. `HistoricalImageCache` moved from `ui-chat` into `packages/client/ui-conversation/src/client/conversation/historical-images.ts` and is served as `ctx.uiConversation.imageUrl(sessionId, attachment)`. Chat and Trajectory resolve through the same instance, so one session attachment costs one `session.attachment` read and one browser URL, revoked when the Session binding is released. This partially supersedes the `ui-chat` cache ownership recorded in [client Session/Conversation ownership](../architecture/2026-08-20-client-session-conversation-ownership.md).
+- The gallery owner contract (`MessageImagesOwnerProps`, `RenderMessageImages`) moved to the `ui-conversation` client contract. `ui-chat` keeps its `conversation.message.images` SlotMap row over the shared owner type; `ui-trajectory` declares its own child slot `conversation.trajectory.images` with the same owner type; `ui-attachment` registers the one `MessageImages` gallery component into both keys, so loading, retry, and lightbox behavior is identical in both views.
+- `TrajectorySourceBlock` carries `attachment?: ImageAttachmentRef` instead of `imageSrc`/`imageAlt`. The inline-source sniffing (`sourceImage`, `safeImageSource`) and the Trajectory-local `PanelImage` renderer are removed: no producer writes inline image bytes or URLs into the session log, so those paths were dead code, and the issue explicitly excludes upload-time transient paths.
+- A record whose content has images but no text labels its ledger row with the locale-owned `layout.imageOnly` count; tool results with only images use the same label for their result summary instead of a JSON dump.
+- Neither the storage nor the BFF changes: `session.attachment` already authorizes by session-log reference (missing, corrupt, and unreferenced attachments fail loud into the gallery's retry state), and sha256 content addressing already stores each image once.
+
+## Alternatives considered
+
+**Keep Trajectory's own `<img>` rendering and feed it resolved URLs.** This duplicates the loading placeholder, retry control, and lightbox that `ui-attachment` already owns, and contradicts [slot-based attachment ownership](../architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.md), which rejected cross-plugin component imports.
+
+**Lift the `conversation.message.images` declaration to a shared parent so both views render one key.** `renderSlot` is typed to the declaring entry's own children table, so a sibling `conversation.view` entry cannot render another entry's child key; the slot registry also rejects a second declaration of the same key. A second key sharing the owner type is the supported composition and lets a theme replace either gallery independently.
+
+**Keep the inline `imageSrc` sniffing beside the durable path.** All producers (host prompt admission, `read_image`, MCP projection, ACP ingress) commit durable refs before their events append, so the sniffing matched nothing; keeping it would preserve a non-durable rendering path the acceptance criteria exclude.
+
+**A Trajectory-owned image cache.** A second cache per view issues duplicate `session.attachment` RPCs and duplicate blob URLs for the same session attachment, violating the "Chat and Trajectory reference the same session attachment" requirement for no benefit.
+
+## Consequences
+
+- Both views present one gallery implementation, so image behavior (sizing, retry, lightbox, labels) cannot drift between Chat and Trajectory, and a session attachment is read once regardless of how many views show it.
+- `TrajectoryTable` threads a required `renderImages` prop through its detail components; `ui-trajectory` gains a type-only dependency on `dsh-attachment`, and `ui-attachment` gains a type-only dependency on `ui-trajectory` for the new SlotMap row.
+- The keyless assembled snapshot `apps/web/tests/trajectory-image-display.snapshot.ts` pins the shared-cache fact directly: the details-panel image URL is string-identical to the Chat gallery's URL for the same fixture attachment.

+ 33 - 0
.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: Trajectory 持久化图片附件
+
+Status: implemented
+
+[English](2026-08-24-trajectory-image-attachments.md) | 中文
+
+## Problem
+
+Trajectory 不展示会话图片。持久化的 `{ type: 'image', attachment: ImageAttachmentRef }` 块在详情面板里渲染成格式化 JSON,纯图片的用户消息在记录表中是一个空行。Trajectory 唯一认识的图片路径是对内联 wire 字段(`url`、`image_url`、base64 `data`)的 `imageSrc` 嗅探,而生产事件从不携带这些字段:每个生产方都在事件追加前提交持久化的 `ImageAttachmentRef`。用户无法从执行记录确认模型看到了哪张图([issue #2986](https://github.com/deepseek-harness/deepseek-harness/issues/2986)),而 Chat 已经能展示同样的附件。
+
+## Decision
+
+- `ui-conversation` 拥有按会话的持久化图片 URL 缓存。`HistoricalImageCache` 从 `ui-chat` 移入 `packages/client/ui-conversation/src/client/conversation/historical-images.ts`,以 `ctx.uiConversation.imageUrl(sessionId, attachment)` 提供。Chat 与 Trajectory 通过同一实例解析,因此一个会话附件只产生一次 `session.attachment` 读取和一个浏览器 URL,并随 Session binding 释放而撤销。这部分取代了 [client Session/Conversation 所有权](../architecture/2026-08-20-client-session-conversation-ownership.zh.md)中记录的 `ui-chat` 缓存归属。
+- 画廊 owner 契约(`MessageImagesOwnerProps`、`RenderMessageImages`)移入 `ui-conversation` 客户端契约。`ui-chat` 的 `conversation.message.images` SlotMap 行沿用共享 owner 类型;`ui-trajectory` 以同一 owner 类型声明自己的子槽位 `conversation.trajectory.images`;`ui-attachment` 把同一个 `MessageImages` 画廊组件注册进两个键,因此加载、重试与灯箱行为在两个视图中完全一致。
+- `TrajectorySourceBlock` 以 `attachment?: ImageAttachmentRef` 取代 `imageSrc`/`imageAlt`。内联来源嗅探(`sourceImage`、`safeImageSource`)与 Trajectory 本地的 `PanelImage` 渲染器一并删除:没有生产方向会话日志写入内联图片字节或 URL,这些路径是死代码,且 issue 明确排除上传来源的临时路径。
+- 内容含图片但没有文本的记录,其记录表行以 locale 持有的 `layout.imageOnly` 计数标注;只含图片的工具结果的摘要也使用同一标签,而不是 JSON 转储。
+- 存储与 BFF 均不改动:`session.attachment` 已按会话日志引用授权(缺失、损坏与未被引用的附件显式失败并进入画廊的重试态),sha256 内容寻址已保证每张图片只存一份。
+
+## Alternatives considered
+
+**保留 Trajectory 自己的 `<img>` 渲染并喂给它解析好的 URL。** 这会重复 `ui-attachment` 已拥有的加载占位、重试控件和灯箱,并与[基于 slot 的附件所有权](../architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md)相抵触,该决定已拒绝跨插件直接 import 组件。
+
+**把 `conversation.message.images` 的声明上提到共享父级,让两个视图渲染同一个键。** `renderSlot` 的类型限定在声明入口自己的 children 表内,同级的 `conversation.view` 入口无法渲染另一个入口的子键;slot registry 也拒绝对同一键的第二次声明。共享 owner 类型的第二个键是受支持的组合方式,且允许主题独立替换任一画廊。
+
+**在持久化路径之外保留内联 `imageSrc` 嗅探。** 所有生产方(宿主 prompt admission、`read_image`、MCP 投影、ACP 入口)都在事件追加前提交持久化引用,嗅探不会命中任何东西;保留它等于保留验收标准明确排除的非持久化渲染路径。
+
+**Trajectory 自有的图片缓存。** 每个视图一份缓存会对同一会话附件发出重复的 `session.attachment` RPC 和重复的 blob URL,违背"Chat 与 Trajectory 引用同一会话附件"的要求,且没有任何收益。
+
+## Consequences
+
+- 两个视图共用一个画廊实现,图片行为(尺寸、重试、灯箱、文案)不会在 Chat 与 Trajectory 之间漂移,且无论多少个视图展示,一个会话附件只读取一次。
+- `TrajectoryTable` 需要把必填的 `renderImages` prop 逐层传入详情组件;`ui-trajectory` 新增对 `dsh-attachment` 的仅类型依赖,`ui-attachment` 为新的 SlotMap 行新增对 `ui-trajectory` 的仅类型依赖。
+- keyless 组装快照 `apps/web/tests/trajectory-image-display.snapshot.ts` 直接钉住共享缓存这一事实:详情面板中的图片 URL 与 Chat 画廊对同一 fixture 附件的 URL 字符串相同。

+ 2 - 2
apps/cli/reference/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/cli/reference/README.md
-README.md: 33de399dc4b2b8e67ed24ed046fcc0da2ff0a7ac
-README.zh.md: 0f20245f318e31159780d29cca955fc85a5b5481
+README.md: eb33816b3ac859e8173e62635f74abbe13fb8462
+README.zh.md: ed84927aa4f211926fd32750385268dc5153b3a9

+ 1 - 1
apps/cli/reference/README.md

@@ -39,7 +39,7 @@ dsh --profile web --dump-default-config
 dsh --profile web --patch ./extra.yml --dump-config
 ```
 
-`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, relative plugin names in inserted rows resolve beside their patch file, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments.
+`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, relative plugin names in inserted rows resolve beside their patch file, and unmatched patch targets are reported on stderr. A dump initializes missing profile files but does not prepare the runtime module fallback under `$DSH_HOME/profiles/node_modules`. It never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments.
 
 ## Plugin management
 

+ 1 - 1
apps/cli/reference/README.zh.md

@@ -39,7 +39,7 @@ dsh --profile web --dump-default-config
 dsh --profile web --patch ./extra.yml --dump-config
 ```
 
-`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,插入行中的相对插件名以各自 patch 文件所在目录解析,找不到目标的 patch 会报告到 stderr。dump 操作不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。
+`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,插入行中的相对插件名以各自 patch 文件所在目录解析,找不到目标的 patch 会报告到 stderr。dump 操作会初始化缺失的 profile 文件,但不会准备 `$DSH_HOME/profiles/node_modules` 下的运行时模块 fallback。它不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。
 
 ## 插件管理
 

+ 1 - 1
apps/cli/src/bin.ts

@@ -41,7 +41,7 @@ switch (invocation.mode) {
   }
   case 'dump-config': {
     const { runDumpConfig } = await import('./dump-config.ts')
-    await runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches)
+    runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches)
     break
   }
   default:

+ 2 - 3
apps/cli/src/dump-config.ts

@@ -26,10 +26,9 @@ const NAME = 'dsh'
  * (the recovery diagnostic for a broken `cordis.patch.yml`, which is then
  * never parsed).
  * @param patches - `--patch` overlay paths, in argv order.
- * @returns settlement after the profile is healed and the dump is written.
  */
-export async function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): Promise<void> {
-  const loaded = await prepareProfile(profile, !defaultOnly)
+export function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): void {
+  const loaded = prepareProfile(profile, !defaultOnly)
   const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({
     label: layer.packageName,
     patches: layer.patches,

+ 11 - 11
apps/cli/src/profile-boot.ts

@@ -103,20 +103,19 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b
 }
 
 /**
- * Load a resolved profile for `name`: heal the shared module fallback, then
- * (re)write the empty root config. The root is always rewritten: the whole
- * composition is patch layers, and the vendored Loader's tree write-back (a
- * plugin self-disposing persists the current tree) can bake composed rows
- * into this file — which would duplicate every bundle insert on the next
- * boot. The file exists on disk only because the Loader needs a real include
- * root to anchor `baseUrl` at the profile directory (the config dump anchors
- * on the same file, so both compose over the identical base).
+ * Load a resolved profile for `name` and (re)write the empty root config. The
+ * root is always rewritten: the whole composition is patch layers, and the
+ * vendored Loader's tree write-back (a plugin self-disposing persists the
+ * current tree) can bake composed rows into this file — which would duplicate
+ * every bundle insert on the next boot. The file exists on disk only because
+ * the Loader needs a real include root to anchor `baseUrl` at the profile
+ * directory (the config dump anchors on the same file, so both compose over
+ * the identical base).
  * @param name - the profile name.
  * @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump).
  * @returns the loaded profile.
  */
-export async function prepareProfile(name: string, userLayer = true): Promise<Profile> {
-  await healProfilesModuleFallback(INSTALL_ANCHOR)
+export function prepareProfile(name: string, userLayer = true): Profile {
   const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
   writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
   return profile
@@ -158,7 +157,8 @@ async function composeProfile(
   name: string,
   patchFiles: readonly string[],
 ): Promise<ComposedProfile> {
-  const profile = await prepareProfile(name)
+  await healProfilesModuleFallback(INSTALL_ANCHOR)
+  const profile = prepareProfile(name)
   const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
   const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
   const bundlePatches = profile.layers.flatMap(layer => layer.patches)

+ 1 - 0
apps/cli/tests/built-bin.e2e.ts

@@ -911,6 +911,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(stdout).toContain('agents: []')
       expect(stdout).toContain('# == @deepseek-ai/dsh-base')
       expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
+      expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(false)
     }, 30_000)
 
     it('prints the headless profile without Host or browser layers', async () => {

+ 2 - 13
apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts

@@ -169,15 +169,11 @@ describe('Python SDK dsh profile keyless smoke', () => {
     }
   }, 40_000)
 
-  it('boots the standalone minimal profile with its exact model-facing roster', async () => {
+  it('boots the standalone minimal profile through its generated manifest', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-'))
-    const modelRequests: Record<string, unknown>[] = []
     const modelServer = createServer((request, response) => {
-      let body = ''
-      request.setEncoding('utf8')
-      request.on('data', (chunk: string) => { body += chunk })
+      request.resume()
       request.on('end', () => {
-        modelRequests.push(JSON.parse(body) as Record<string, unknown>)
         response.writeHead(200, { 'content-type': 'text/event-stream' })
         response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n')
         response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
@@ -237,13 +233,6 @@ describe('Python SDK dsh profile keyless smoke', () => {
         return params?.sessionId === 'minimal' && event?.type === 'turn/end'
       }, () => stderr)
 
-      const request = modelRequests[0] as {
-        messages?: Array<{ role?: string; content?: unknown }>
-        tools?: Array<{ function?: { name?: string } }>
-      }
-      expect(request.messages?.[0]).toMatchObject({ role: 'system', content: 'Minimal allowlist prompt.' })
-      const shellTool = process.platform === 'win32' ? 'pwsh' : 'bash'
-      expect(request.tools?.map(tool => tool.function?.name).sort()).toEqual([shellTool, 'str_replace_editor'].sort())
       const profile = JSON.parse(
         await readFile(join(root, '.dsh', 'profiles', 'sdk-minimal', 'package.json'), 'utf8'),
       ) as { dsh?: { profile?: { bundles?: string[]; patchReload?: string } } }

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

@@ -0,0 +1,93 @@
+// @vitest-environment jsdom
+// Trajectory image surfaces over the BUILT client graph (the code-mode-fixture
+// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
+// Opens the fixture history session whose turn 73 carries an image in BOTH a
+// user message and an assistant message, and pins the Trajectory surfaces:
+// selecting the ledger record renders the shared ui-attachment gallery from
+// the durable session-log reference, and the browser URL is the SAME object
+// URL Chat resolved — one sessions.attachment read per session attachment.
+import { fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { expect, it, vi } from 'vitest'
+import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
+
+installAssembledBootEnv()
+
+/** Open the fixture history session and wait for the Chat gallery to load. */
+async function openFixtureSession(): Promise<void> {
+  const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+  const group = (await within(tree).findAllByText('fixture'))
+    .map(el => el.closest<HTMLElement>('[role="treeitem"]'))
+    .find(el => el?.getAttribute('aria-expanded') !== null)
+  if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
+  if (group.getAttribute('aria-expanded') === 'false') {
+    fireEvent.click(within(group).getByText('fixture'))
+    await waitFor(() => {
+      expect(group.getAttribute('aria-expanded')).toBe('true')
+    })
+  }
+  const session = await within(tree).findByText('Fixture 历史会话')
+  fireEvent.click(session)
+  await waitFor(() => {
+    expect(document.querySelectorAll('[data-align] img').length).toBeGreaterThan(0)
+  }, { timeout: 10_000 })
+}
+
+/** Scroll the virtual ledger until the row whose text contains `needle` mounts. */
+async function scrollRowIntoWindow(needle: string): Promise<HTMLElement> {
+  await waitFor(() => {
+    if (document.querySelectorAll('tr[data-trajectory-row-key]').length === 0) {
+      throw new Error('trajectory rows not mounted')
+    }
+  }, { timeout: 10_000 })
+  const pane = document.querySelector('[data-trajectory-scroll] table')?.parentElement
+  if (!(pane instanceof HTMLElement)) throw new Error('trajectory scroll pane missing')
+  for (let top = 0; top <= 40_000; top += 1_000) {
+    pane.scrollTop = top
+    fireEvent.scroll(pane)
+    // Let the virtualizer publish the new window before probing.
+    await new Promise(resolve => setTimeout(resolve, 25))
+    const hit = [...document.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
+      .find(row => row.textContent?.includes(needle))
+    if (hit !== undefined) return hit
+  }
+  throw new Error(`trajectory row containing ${JSON.stringify(needle)} never mounted`)
+}
+
+it('renders durable record images in the Trajectory details panel from the shared cache', async () => {
+  // The virtual ledger needs a measurable viewport; jsdom reports zero
+  // heights, so pin one and neutralize the imperative tail scroll.
+  vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
+  Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
+    configurable: true,
+    value: () => {},
+  })
+  mountAssembledApp()
+  await openFixtureSession()
+  const chatSrc = document.querySelector('[data-align="end"] img')?.getAttribute('src')
+  if (chatSrc === null || chatSrc === undefined) throw new Error('chat gallery image missing')
+
+  fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
+  const userRow = await scrollRowIntoWindow('历史用户图片')
+  fireEvent.click(userRow)
+
+  // Selecting the record opens the details panel; the ui-attachment gallery
+  // resolves the durable reference through the SAME per-session cache Chat
+  // used, so the object URL is identical — no second attachment read.
+  const panel = await screen.findByRole('tabpanel')
+  await waitFor(() => {
+    expect(within(panel).getAllByRole('img').length).toBeGreaterThan(0)
+  }, { timeout: 10_000 })
+  expect(within(panel).getAllByRole('img').map(img => ({
+    alt: img.getAttribute('alt'),
+    scheme: img.getAttribute('src')?.split(':')[0],
+    sharedWithChat: img.getAttribute('src') === chatSrc,
+  }))).toMatchInlineSnapshot(`
+    [
+      {
+        "alt": "fixture-image.png",
+        "scheme": "blob",
+        "sharedWithChat": true,
+      },
+    ]
+  `)
+})

+ 2 - 2
docs/config-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/config-catalog.md
-config-catalog.md: dd8231c9bc259e99128ee4a9498c99fa4295b884
-config-catalog.zh.md: 080071eca5c1f10c398d833e93d2847a5fbe2426
+config-catalog.md: e4c43c305f22b2ab1713626c0a6a6bde6c4d8e1b
+config-catalog.zh.md: 6f8afcd8aca2e675a3bfa82ea0ec205d6046a1fb

+ 3 - 14
docs/config-catalog.md

@@ -166,9 +166,9 @@ Source: [`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/ag
  * Bundle config: each field forwarded verbatim to the child that owns it —
  * `agents` to the agent loop (an app that pre-creates no agents, like the ACP
  * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`,
- * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin
- * (the fixed opener, dynamic-context policy, deployment persona completeness,
- * and explicit model-facing tool order), the `tools` object to the tool
+ * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener,
+ * dynamic-context policy, deployment persona, and explicit model-facing tool
+ * order), the `tools` object to the tool
  * registry (its presentation `mode`),
  * `dshHome` to bash environment and local skill discovery, `sessionTitle` to
  * the fallback title service, `skills` to the
@@ -197,8 +197,6 @@ export interface Config {
   includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext']
   /** The deployment persona (see dsh-system-prompt's `Config`). */
   persona?: SystemPromptConfig['persona']
-  /** Whether the deployment persona is the complete system prompt. */
-  personaComplete?: SystemPromptConfig['personaComplete']
   /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
   toolOrder?: SystemPromptConfig['toolOrder']
   /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
@@ -1723,13 +1721,6 @@ Requires: `agents`
 export interface JsonRpcConfig {
   /** Report max-token turn/subagent termination as a successful SDK result. */
   maxTokensAsSuccess?: boolean
-  /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */
-  toolFilter?: {
-    /** Global tool names that remain visible. */
-    allow?: string[]
-    /** Global tool names removed from visibility. */
-    deny?: string[]
-  }
   /** Transport input override; production uses `process.stdin`. */
   input?: Readable
   /** Transport output override; production uses `process.stdout`. */
@@ -2466,8 +2457,6 @@ export interface Config {
    * `deployment:persona` shadows it; `{{variable}}` references are strict.
    */
   persona?: string
-  /** Treat the deployment persona as the complete system prompt (default false). */
-  personaComplete?: boolean
   /**
    * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once.
    * Invalid fields fail at load and unknown names fail at assembly; known names

+ 3 - 14
docs/config-catalog.zh.md

@@ -168,9 +168,9 @@ export type PresetTrust = 'system' | 'user'
  * Bundle config: each field forwarded verbatim to the child that owns it —
  * `agents` to the agent loop (an app that pre-creates no agents, like the ACP
  * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`,
- * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin
- * (the fixed opener, dynamic-context policy, deployment persona completeness,
- * and explicit model-facing tool order), the `tools` object to the tool
+ * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener,
+ * dynamic-context policy, deployment persona, and explicit model-facing tool
+ * order), the `tools` object to the tool
  * registry (its presentation `mode`),
  * `dshHome` to bash environment and local skill discovery, `sessionTitle` to
  * the fallback title service, `skills` to the
@@ -199,8 +199,6 @@ export interface Config {
   includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext']
   /** The deployment persona (see dsh-system-prompt's `Config`). */
   persona?: SystemPromptConfig['persona']
-  /** Whether the deployment persona is the complete system prompt. */
-  personaComplete?: SystemPromptConfig['personaComplete']
   /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
   toolOrder?: SystemPromptConfig['toolOrder']
   /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
@@ -1725,13 +1723,6 @@ export interface Config {
 export interface JsonRpcConfig {
   /** Report max-token turn/subagent termination as a successful SDK result. */
   maxTokensAsSuccess?: boolean
-  /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */
-  toolFilter?: {
-    /** Global tool names that remain visible. */
-    allow?: string[]
-    /** Global tool names removed from visibility. */
-    deny?: string[]
-  }
   /** Transport input override; production uses `process.stdin`. */
   input?: Readable
   /** Transport output override; production uses `process.stdout`. */
@@ -2468,8 +2459,6 @@ export interface Config {
    * `deployment:persona` shadows it; `{{variable}}` references are strict.
    */
   persona?: string
-  /** Treat the deployment persona as the complete system prompt (default false). */
-  personaComplete?: boolean
   /**
    * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once.
    * Invalid fields fail at load and unknown names fail at assembly; known names

+ 2 - 2
docs/module-graph.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/module-graph.md
-module-graph.md: 985636d3889394b912dcc1d68cb9e0a4fc1cb11b
-module-graph.zh.md: d352643e21e8d54e523885ea06320610e6951dfe
+module-graph.md: 1cca77ee09ce2a47e732893d814531daac3c07ad
+module-graph.zh.md: 46390d0c897f55c79bb183ea56deb2843fd80b94

+ 5 - 4
docs/module-graph.md

@@ -1454,6 +1454,7 @@ flowchart TD
   pkg_client_ui_settings_general --> pkg_settings
   pkg_client_ui_trajectory --> pkg_agent
   pkg_client_ui_trajectory --> pkg_api_session_controller
+  pkg_client_ui_trajectory --> pkg_attachment
   pkg_client_ui_trajectory --> pkg_client_locale
   pkg_client_ui_trajectory --> pkg_client_ui_conversation
   pkg_client_ui_trajectory --> pkg_client_ui_renderer
@@ -1494,7 +1495,6 @@ flowchart TD
   pkg_client_ui_chat --> pkg_session_stats
   pkg_client_ui_chat --> pkg_token_meter
   pkg_client_ui_chat --> pkg_tools
-  pkg_client_ui_chat --> pkg_util_crypto
   pkg_client_ui_chat --> pkg_util_workspace_path
   pkg_client_ui_commands --> pkg_api_remotes
   pkg_client_ui_commands --> pkg_api_session_controller
@@ -1541,6 +1541,7 @@ flowchart TD
   pkg_client_ui_attachment --> pkg_client_ui_chat
   pkg_client_ui_attachment --> pkg_client_ui_conversation
   pkg_client_ui_attachment --> pkg_client_ui_renderer
+  pkg_client_ui_attachment --> pkg_client_ui_trajectory
   pkg_client_ui_attachment --> pkg_invariants
   pkg_client_ui_deliverables --> pkg_client_connection
   pkg_client_ui_deliverables --> pkg_client_locale
@@ -1876,15 +1877,15 @@ flowchart TD
 | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) |
 | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
-| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
+| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
 | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) |
-| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path) |
+| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
 | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) |
-| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
 | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
 | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |

+ 5 - 4
docs/module-graph.zh.md

@@ -1456,6 +1456,7 @@ flowchart TD
   pkg_client_ui_settings_general --> pkg_settings
   pkg_client_ui_trajectory --> pkg_agent
   pkg_client_ui_trajectory --> pkg_api_session_controller
+  pkg_client_ui_trajectory --> pkg_attachment
   pkg_client_ui_trajectory --> pkg_client_locale
   pkg_client_ui_trajectory --> pkg_client_ui_conversation
   pkg_client_ui_trajectory --> pkg_client_ui_renderer
@@ -1496,7 +1497,6 @@ flowchart TD
   pkg_client_ui_chat --> pkg_session_stats
   pkg_client_ui_chat --> pkg_token_meter
   pkg_client_ui_chat --> pkg_tools
-  pkg_client_ui_chat --> pkg_util_crypto
   pkg_client_ui_chat --> pkg_util_workspace_path
   pkg_client_ui_commands --> pkg_api_remotes
   pkg_client_ui_commands --> pkg_api_session_controller
@@ -1543,6 +1543,7 @@ flowchart TD
   pkg_client_ui_attachment --> pkg_client_ui_chat
   pkg_client_ui_attachment --> pkg_client_ui_conversation
   pkg_client_ui_attachment --> pkg_client_ui_renderer
+  pkg_client_ui_attachment --> pkg_client_ui_trajectory
   pkg_client_ui_attachment --> pkg_invariants
   pkg_client_ui_deliverables --> pkg_client_connection
   pkg_client_ui_deliverables --> pkg_client_locale
@@ -1878,15 +1879,15 @@ flowchart TD
 | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) |
 | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
-| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
+| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
 | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) |
-| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path) |
+| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
 | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) |
-| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
 | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
 | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |

+ 2 - 2
docs/subsystems/slots.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/subsystems/slots.md
-slots.md: d201223c9e630f16f310d8ff90318ab8c43211e5
-slots.zh.md: 23277e94e2a9e85be7f1745a172dfd9cb8a5be37
+slots.md: 6eb61780ca2f06ebc38a0fcf2638a7fafcd5ceee
+slots.zh.md: e5a25763d382c228525f3da24694e41dd09737e0

+ 2 - 1
docs/subsystems/slots.md

@@ -134,7 +134,8 @@ root
 │  │     │  ├─ conversation.chat.turnTail
 │  │     │  └─ tool.call.toolview
 │  │     │     └─ tool.view.cordis
-│  │     └─ conversation.message.images
+│  │     ├─ conversation.message.images
+│  │     └─ conversation.trajectory.images
 │  ├─ conversation.session.header
 │  │  ├─ conversation.session.header.lineage
 │  │  ├─ conversation.session.header.actions

+ 2 - 1
docs/subsystems/slots.zh.md

@@ -134,7 +134,8 @@ root
 │  │     │  ├─ conversation.chat.turnTail
 │  │     │  └─ tool.call.toolview
 │  │     │     └─ tool.view.cordis
-│  │     └─ conversation.message.images
+│  │     ├─ conversation.message.images
+│  │     └─ conversation.trajectory.images
 │  ├─ conversation.session.header
 │  │  ├─ conversation.session.header.lineage
 │  │  ├─ conversation.session.header.actions

+ 2 - 2
packages/client/ui-attachment/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-attachment/README.md
-README.md: 925187da23e9acec9a69959bfc29ce7ec62f0096
-README.zh.md: 2872dac19e36143923ba39e4b3efbd6e8bb27cac
+README.md: ee9b604d0227b5ae52f833f97f80429e4ab93bea
+README.zh.md: 8d8cd43c32587f258de3e6a26fa7e0d0127dbf57

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

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-Dynamic attachment presentation plugin for the conversation UI. It waits for the conversation package's `conversation.input.attachments` and `conversation.message.images` declarations through `ctx.slots.inject`, then registers the composer draft-image rail, document drop target, chat-history image gallery, and original-image lightbox. The conversation slot owner supplies attachment data, image loading, callbacks, and its namespace translator; presentation components remain pure props and are not exported from the package entry.
+Dynamic attachment presentation plugin for the conversation UI. It waits for the `conversation.input.attachments`, `conversation.message.images`, and `conversation.trajectory.images` declarations through `ctx.slots.inject`, then registers the composer draft-image rail, document drop target, the history image gallery serving both the Chat transcript and the Trajectory inspector, and the original-image lightbox. The conversation slot owner supplies attachment data, image loading, callbacks, and its namespace translator; presentation components remain pure props and are not exported from the package entry.
 
 ## Attachment rail
 

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

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-对话 UI 的动态附件呈现插件。它通过 `ctx.slots.inject` 等待 conversation 包声明 `conversation.input.attachments` 与 `conversation.message.images`,随后注册输入框草稿图片栏、文档拖放目标、聊天历史图片画廊和原图灯箱。conversation slot 持有方提供附件数据、图片加载、回调及其命名空间翻译器;呈现组件保持纯 props,且不从包入口导出。
+对话 UI 的动态附件呈现插件。它通过 `ctx.slots.inject` 等待 `conversation.input.attachments`、`conversation.message.images` 与 `conversation.trajectory.images` 声明,随后注册输入框草稿图片栏、文档拖放目标、同时服务 Chat 会话记录与 Trajectory 检查器的历史图片画廊,以及原图灯箱。conversation slot 持有方提供附件数据、图片加载、回调及其命名空间翻译器;呈现组件保持纯 props,且不从包入口导出。
 
 ## 附件栏
 

+ 5 - 2
packages/client/ui-attachment/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-attachment",
-  "description": "Dynamic attachment presentation plugin for conversation input and message-image slots",
+  "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots",
   "version": "0.1.1-rc.2",
   "publishConfig": {
     "access": "public"
@@ -34,7 +34,8 @@
       "inject": [
         "@deepseek-ai/dsh-client-ui-chat",
         "@deepseek-ai/dsh-client-ui-conversation",
-        "@deepseek-ai/dsh-client-ui-renderer"
+        "@deepseek-ai/dsh-client-ui-renderer",
+        "@deepseek-ai/dsh-client-ui-trajectory"
       ],
       "platform": "web"
     }
@@ -54,6 +55,7 @@
     "@types/react-dom": "~18.3.0",
     "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
@@ -72,6 +74,7 @@
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^"
   }

+ 5 - 0
packages/client/ui-attachment/src/client/index.ts

@@ -3,6 +3,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-client-ui-trajectory/client'
 import { ComposerAttachments } from './ComposerAttachments.tsx'
 import { MessageImages } from './MessageImages.tsx'
 
@@ -19,4 +20,8 @@ export function apply(ctx: ClientContext): void {
     name: 'conversation.message.images',
     locale: 'conversation',
   }, MessageImages))
+  ctx.slots.inject('conversation.trajectory.images', () => ctx.slots.register({
+    name: 'conversation.trajectory.images',
+    locale: 'conversation',
+  }, MessageImages))
 }

+ 7 - 1
packages/client/ui-attachment/tests/plugin.client.spec.ts

@@ -14,6 +14,7 @@ async function bench() {
     children: {
       'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' },
       'conversation.message.images': { kind: 'single', scope: 'session' },
+      'conversation.trajectory.images': { kind: 'single', scope: 'session' },
     },
   } as never, () => null)
   const fiber = ctx.plugin({ inject: [...inject], apply })
@@ -26,7 +27,7 @@ describe('attachment plugin', () => {
     expect(() => { applyHost() }).not.toThrow()
   })
 
-  it('registers both entries and removes them with the plugin fiber', async () => {
+  it('registers all entries and removes them with the plugin fiber', async () => {
     const { ctx, fiber } = await bench()
     expect(inject).toEqual(['slots'])
     expect(ctx.slots.entries('conversation.input.attachments')).toMatchObject([{
@@ -37,10 +38,15 @@ describe('attachment plugin', () => {
       locale: 'conversation',
       component: MessageImages,
     }])
+    expect(ctx.slots.entries('conversation.trajectory.images')).toMatchObject([{
+      locale: 'conversation',
+      component: MessageImages,
+    }])
 
     await fiber.dispose()
 
     expect(ctx.slots.entries('conversation.input.attachments')).toHaveLength(0)
     expect(ctx.slots.entries('conversation.message.images')).toHaveLength(0)
+    expect(ctx.slots.entries('conversation.trajectory.images')).toHaveLength(0)
   })
 })

+ 3 - 0
packages/client/ui-attachment/tsconfig.json

@@ -23,6 +23,9 @@
     {
       "path": "../ui-conversation"
     },
+    {
+      "path": "../ui-trajectory"
+    },
     {
       "path": "../ui-slots"
     },

+ 2 - 2
packages/client/ui-chat/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-chat/README.md
-README.md: 56bb20ab7b4d9b5c0c95142b311a07ad9b8a1fd3
-README.zh.md: 40ee1ee710e2f86e802ffaac4dc0bb10852f128f
+README.md: 5253cb95b0e5c0b89c32646e2ae2915936d35288
+README.zh.md: 8cd2d0d581a0493892aed23f42ebc0c229a0bc17

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

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, historical images, and scroll restoration.
+The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`).
 
 ## Model Experience
 

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

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化、历史图片与滚动位置恢复。
+Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。
 
 ## 模型体验
 

+ 0 - 2
packages/client/ui-chat/package.json

@@ -72,7 +72,6 @@
     "@deepseek-ai/dsh-session-stats": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
-    "@deepseek-ai/dsh-util-crypto": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^"
   },
   "devDependencies": {
@@ -101,7 +100,6 @@
     "@deepseek-ai/dsh-session-stats": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
-    "@deepseek-ai/dsh-util-crypto": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@types/react": "~18.3.1",
     "react": "^18.2.0"

+ 1 - 3
packages/client/ui-chat/src/client/apply.ts

@@ -23,7 +23,6 @@ import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
 import { StatsLine } from './chat/StatsLine.tsx'
 import { registerConversationNodes } from './conversation-nodes/register.ts'
 import { DetailsPanel } from './details/DetailsPanel.tsx'
-import { HistoricalImageCache } from './historical-images.ts'
 import { en, NS, zh } from './locale.ts'
 import { createChatStore } from './stores.ts'
 
@@ -74,7 +73,6 @@ export function apply(ctx: Context): void {
   const t = ctx.locale.bind(NS)
   const chatStore = createChatStore()
   const chatScrollPositions = new Map<SessionId, ChatScrollPosition>()
-  const images = new HistoricalImageCache(ctx)
 
   ctx.slots.inject('conversation.view', () => {
     const disposeView = ctx.slots.register({
@@ -102,7 +100,7 @@ export function apply(ctx: Context): void {
             return ctx.uiWorkspace.openPath(resolveWorkspacePath(cwd, path))
           },
           loadOlder: () => { void session.loadOlder() },
-          loadImage: attachment => images.resolve(sessionId, attachment),
+          loadImage: attachment => ctx.uiConversation.imageUrl(sessionId, attachment),
           chatScroll: {
             save: (position) => {
               if (position === null) chatScrollPositions.delete(sessionId)

+ 4 - 2
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -2,9 +2,11 @@
 // otherwise this view owns it. Each row subscribes to one stable node key.
 
 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
-import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type {
+  ConversationTimelineSnapshot, RenderMessageImages,
+} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { ChatViewSlotProps, RenderMessageImages } from '../contract/slots.ts'
+import type { ChatViewSlotProps } from '../contract/slots.ts'
 import { PendingSteeringBubble } from './MessageItem.tsx'
 import { ChatNodeSeat } from './ChatNodeSeat.tsx'
 import { formatRunDuration } from './message-chrome.ts'

+ 1 - 12
packages/client/ui-chat/src/client/contract/slots.ts

@@ -1,9 +1,8 @@
 /** Chat-owned Slot declarations and composed component props. */
-import type { ReactNode } from 'react'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type {
-  ConversationTurnDataMap, TurnLocation,
+  ConversationTurnDataMap, MessageImagesOwnerProps, RenderMessageImages, TurnLocation,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {
   InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory,
@@ -19,16 +18,6 @@ import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock }
 /** Selector hook over the current Conversation binding's Chat target. */
 export type UseChat = SnapshotSelectorHook<ChatSnapshot>
 
-/** Historical image group handed to the optional attachment presentation plugin. */
-export interface MessageImagesOwnerProps {
-  images: readonly { readonly attachment: ImageAttachmentRef }[]
-  loadImage: (attachment: ImageAttachmentRef) => Promise<string>
-  align: 'start' | 'end'
-}
-
-/** Slot-backed renderer used by Chat nodes without importing an attachment implementation. */
-export type RenderMessageImages = (owner: Omit<MessageImagesOwnerProps, 'loadImage'>) => ReactNode
-
 /** Owner currency of the completed-Turn extension chain. */
 export interface TurnTailOwnerProps {
   turn: TurnLocation

+ 1 - 2
packages/client/ui-chat/src/client/index.ts

@@ -28,8 +28,7 @@ export type {
   AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected,
   ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps,
   CommandRowOwnerProps, CommandRowProps, DetailsInjected, DetailsSlotProps,
-  DetailsToolOwnerProps, MessageImagesOwnerProps, MessageImagesProps, RenderMessageImages,
-  TurnTailOwnerProps, UseChat, UseChatNodeTurnData,
+  DetailsToolOwnerProps, MessageImagesProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData,
 } from './contract/slots.ts'
 export type { ChatKey } from './locale.ts'
 export type { ConversationContext, ConversationContextOriginKind } from './model/conversation-context.ts'

+ 1 - 1
packages/client/ui-chat/tests/image-labels.client.spec.tsx

@@ -5,8 +5,8 @@ import { cleanup, render } from '@testing-library/react'
 import { AttachmentId } from '@deepseek-ai/dsh-attachment'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
-import type { RenderMessageImages } from '../src/client/contract/slots.ts'
 import { zh } from '../src/client/locale.ts'
 
 afterEach(cleanup)

+ 0 - 3
packages/client/ui-chat/tsconfig.json

@@ -50,9 +50,6 @@
     {
       "path": "../../runtime-diagnostics/invariants"
     },
-    {
-      "path": "../../util/crypto"
-    },
     {
       "path": "../../util/workspace-path"
     },

+ 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: 08d1408bea404a80da491078aa013989835fccaf
-README.zh.md: 874aea8865e6f74a64da2c249087bdad032d98d5
+README.md: 8506cc9e2d2935151003ad11e34d056dda70e0ff
+README.zh.md: bf6643112878f6537d3db1d1686db046202fada5

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

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller event feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers.
+`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller event feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. It also owns the per-session durable image URL cache: `ctx.uiConversation.imageUrl(sessionId, attachment)` resolves one session-authorized browser URL per attachment and revokes it with the Session binding, so every Conversation target shares one `session.attachment` read. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers.
 
 ## Conversation assembly
 

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

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller event feed,通过 `ctx.uiConversation` 暴露不依赖 React 的 registry 与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。Chat 等具体 target 位于独立 package,由各自 package 注册 Definition、snapshot builder、View 和 renderer。
+`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller event feed,通过 `ctx.uiConversation` 暴露不依赖 React 的 registry 与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。它还拥有按会话的持久化图片 URL 缓存:`ctx.uiConversation.imageUrl(sessionId, attachment)` 为每个附件解析一个经会话授权的浏览器 URL,并随 Session binding 释放而撤销,因此所有 Conversation target 共享一次 `session.attachment` 读取。Chat 等具体 target 位于独立 package,由各自 package 注册 Definition、snapshot builder、View 和 renderer。
 
 ## Conversation 组装
 

+ 14 - 0
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -1,5 +1,6 @@
 /** Target-neutral Conversation slot declarations and composed component props. */
 import type { ReactNode, RefObject } from 'react'
+import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type {
@@ -43,6 +44,19 @@ export interface ComposerAttachmentsOwnerProps {
   dropLimits?: { readonly count: number; readonly size: string } | undefined
 }
 
+/** Durable image group handed to the optional attachment presentation plugin. */
+export interface MessageImagesOwnerProps {
+  /** Durable image references in source order. */
+  images: readonly { readonly attachment: ImageAttachmentRef }[]
+  /** Session-authorized image URL loader. */
+  loadImage: (attachment: ImageAttachmentRef) => Promise<string>
+  /** Horizontal placement inside the owning record. */
+  align: 'start' | 'end'
+}
+
+/** Slot-backed renderer used by Conversation targets without importing an attachment implementation. */
+export type RenderMessageImages = (owner: Omit<MessageImagesOwnerProps, 'loadImage'>) => ReactNode
+
 /** Selector hook over the current Session's assembled Conversation. */
 export type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
 /** Selector hook over the registered Conversation View roster. */

+ 15 - 0
packages/client/ui-conversation/src/client/conversation/assembly.ts

@@ -1,5 +1,6 @@
 /** Per-Session target-neutral Conversation assembly. */
 import { Service, type Context } from '@deepseek-ai/cordis'
+import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type {
   ISessions, SessionBinding, SessionEventSource, SessionEventWindow,
 } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -15,6 +16,7 @@ import type {
 import type { ConversationSnapshot } from '../contract/snapshot.ts'
 import { ConversationNodeAssembler } from './assembler.ts'
 import { ConversationEventRegistry } from './event-registry.ts'
+import { HistoricalImageCache } from './historical-images.ts'
 import { ConversationViewRegistry } from './view-registry.ts'
 
 /** Observable faces published for one Session's Conversation assembly. */
@@ -144,6 +146,7 @@ export class UiConversation extends Service {
   /** Registry of target View definitions. */
   readonly views: ConversationViewRegistry
   private readonly bindings = new Map<SessionId, BindingRecord>()
+  private readonly images: HistoricalImageCache
 
   /**
    * @param ctx - owning Client context.
@@ -153,6 +156,7 @@ export class UiConversation extends Service {
     super(ctx, 'uiConversation')
     this.events = new ConversationEventRegistry(ctx)
     this.views = new ConversationViewRegistry(ctx)
+    this.images = new HistoricalImageCache(ctx, sessions)
     const rebuild = (): void => {
       for (const record of this.bindings.values()) record.binding.rebuild()
     }
@@ -202,6 +206,17 @@ export class UiConversation extends Service {
     return binding
   }
 
+  /**
+   * Resolve one session-authorized durable image URL, cached per Session so
+   * every Conversation target shares one read and one browser URL.
+   * @param sessionId - Session authorization and lifetime scope.
+   * @param attachment - Durable image reference from a session event.
+   * @returns browser URL valid until the Session binding is released.
+   */
+  imageUrl(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string> {
+    return this.images.resolve(sessionId, attachment)
+  }
+
   private drop(record: BindingRecord, releaseScope: boolean): void {
     if (this.bindings.get(record.source.sessionId) !== record) return
     this.bindings.delete(record.source.sessionId)

+ 11 - 12
packages/client/ui-chat/src/client/historical-images.ts → packages/client/ui-conversation/src/client/conversation/historical-images.ts

@@ -1,4 +1,4 @@
-/** Session-scoped historical image URL cache owned by the Chat plugin. */
+/** Session-scoped durable image URL cache shared by Conversation targets. */
 import type { Context } from '@deepseek-ai/cordis'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -11,9 +11,8 @@ interface ImageUrlEntry {
   readonly pending: Promise<string>
 }
 
-/** Resolve durable Chat images and release their browser URLs with Session scope. */
+/** Resolve durable Conversation images and release their browser URLs with Session scope. */
 export class HistoricalImageCache {
-  private readonly sessions: ISessions
   private readonly entries = new Map<string, ImageUrlEntry>()
   private readonly generations = new Map<SessionId, number>()
   private readonly scopeDisposers = new Map<SessionId, () => void>()
@@ -21,11 +20,11 @@ export class HistoricalImageCache {
   private disposed = false
 
   /**
-   * @param ctx - Owning ui-chat fiber.
+   * @param ctx - Owning ui-conversation fiber.
+   * @param sessions - Session Controller object layer.
    */
-  constructor(ctx: Context) {
-    this.sessions = ctx.sessions
-    ctx.effect(() => () => { this.dispose() }, 'ui-chat historical image cache')
+  constructor(ctx: Context, private readonly sessions: ISessions) {
+    ctx.effect(() => () => { this.dispose() }, 'ui-conversation historical image cache')
   }
 
   /**
@@ -35,22 +34,22 @@ export class HistoricalImageCache {
    * @returns browser URL valid until the Session binding is released.
    */
   resolve(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string> {
-    if (this.disposed) return Promise.reject(new Error('ui-chat image cache is disposed'))
+    if (this.disposed) return Promise.reject(new Error('ui-conversation image cache is disposed'))
     const key = `${sessionId}:${attachment.attachmentId}`
     const cached = this.entries.get(key)
     if (cached !== undefined) return cached.pending
     const binding = this.sessions.binding(sessionId)
     if (binding === undefined) {
-      return Promise.reject(new Error(`ui-chat: unknown session "${sessionId}"`))
+      return Promise.reject(new Error(`ui-conversation: unknown session "${sessionId}"`))
     }
     this.bindScope(sessionId, binding.ctx)
     const generation = this.generations.get(sessionId) ?? 0
     const pending = binding.session.readAttachment(attachment.attachmentId)
       .then((result) => {
         if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
-        if (this.disposed) throw new Error('ui-chat image cache was disposed before loading completed')
+        if (this.disposed) throw new Error('ui-conversation image cache was disposed before loading completed')
         if ((this.generations.get(sessionId) ?? 0) !== generation) {
-          throw new Error('ui-chat image scope was released before loading completed')
+          throw new Error('ui-conversation image scope was released before loading completed')
         }
         if (typeof URL.createObjectURL !== 'function') {
           return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}`
@@ -73,7 +72,7 @@ export class HistoricalImageCache {
     const dispose = scope.effect(() => () => {
       this.scopeDisposers.delete(sessionId)
       this.release(sessionId)
-    }, 'ui-chat historical image scope')
+    }, 'ui-conversation historical image scope')
     this.scopeDisposers.set(sessionId, () => { void dispose() })
   }
 

+ 1 - 1
packages/client/ui-conversation/src/client/index.ts

@@ -51,7 +51,7 @@ export type {
   ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps,
   ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps,
   HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone,
-  UseConversation, UseConversationViews,
+  MessageImagesOwnerProps, RenderMessageImages, UseConversation, UseConversationViews,
 } from './contract/slots.ts'
 export type {
   ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest,

+ 3 - 3
packages/client/ui-chat/tests/historical-images.client.spec.ts → packages/client/ui-conversation/tests/historical-images.client.spec.ts

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
 import { AttachmentId } from '@deepseek-ai/dsh-attachment'
 import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client'
 import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
-import { HistoricalImageCache } from '../src/client/historical-images.ts'
+import { HistoricalImageCache } from '../src/client/conversation/historical-images.ts'
 
 describe('HistoricalImageCache', () => {
   it('invalidates a pending image load when its Session binding is released', async () => {
@@ -13,7 +13,7 @@ describe('HistoricalImageCache', () => {
       id: 's1',
       session: { readAttachment: () => read.promise },
     })
-    const cache = new HistoricalImageCache(runtime.ctx)
+    const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions)
     const attachment = {
       attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
     } as const
@@ -22,7 +22,7 @@ describe('HistoricalImageCache', () => {
     await runtime.sessions.remove(sessionId)
     read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } })
 
-    await expect(pending).rejects.toThrow('ui-chat image scope was released before loading completed')
+    await expect(pending).rejects.toThrow('ui-conversation image scope was released before loading completed')
     await runtime.dispose()
   })
 })

+ 2 - 2
packages/client/ui-trajectory/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-trajectory/README.md
-README.md: 97badd562bbf132c9766c763ff604306d1a6c08d
-README.zh.md: 5a17ee811047e8ffd15be849595d87adfe4ddf00
+README.md: c623105b9bb84edbd8ff6a91244271f6fc92d943
+README.zh.md: 007f4451be9ac2f19c7fdd8e6eefe410813c7eb7

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
packages/client/ui-trajectory/README.md


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
packages/client/ui-trajectory/README.zh.md


+ 4 - 2
packages/client/ui-trajectory/package.json

@@ -62,7 +62,8 @@
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
-    "@deepseek-ai/dsh-llm": "workspace:^"
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-attachment": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
@@ -85,7 +86,8 @@
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
-    "@deepseek-ai/dsh-llm": "workspace:^"
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-attachment": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 0 - 29
packages/client/ui-trajectory/src/client/TrajectoryTable.module.css

@@ -1545,35 +1545,6 @@
   white-space: pre-wrap;
 }
 
-.panelImageLink {
-  display: block;
-  width: auto;
-  max-width: 100%;
-  overflow: hidden;
-  border-radius: 4px;
-  cursor: zoom-in;
-}
-
-.panelImageLinkPreview {
-  max-height: 140px;
-}
-
-.panelImage {
-  display: block;
-  width: auto;
-  max-width: 100%;
-  height: auto;
-  max-height: 320px;
-  margin: 0;
-  border-radius: inherit;
-  background: var(--dsw-alias-bg-base);
-  object-fit: contain;
-}
-
-.panelImageLinkPreview .panelImage {
-  max-height: 140px;
-}
-
 .messageImages {
   display: flex;
   flex-direction: column;

+ 59 - 46
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -14,8 +14,9 @@ import {
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { JsonTreeLabels, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
 import { structuredPatch } from 'diff'
+import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type {
-  AssistantRequestConfig, ConversationPromptSnapshot,
+  AssistantRequestConfig, ConversationPromptSnapshot, RenderMessageImages,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {
   AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
@@ -377,6 +378,8 @@ function AssistantTimingPanel({
 export interface TrajectoryTableProps {
   /** Trajectory locale seat. */
   t: TrajectoryTranslate
+  /** Slot-backed durable image renderer shared with the Chat gallery. */
+  renderImages: RenderMessageImages
   /** Session-global request numbers for the request groups visible in this context. */
   requestNumbers?: readonly TrajectoryRequestNumber[]
   /** Grouped records in display order. */
@@ -1121,10 +1124,12 @@ function MarkdownFragment({
 function SourceBlocks({
   blocks,
   onOpenCall,
+  renderImages,
   t,
 }: {
   blocks: readonly TrajectorySourceBlock[]
   onOpenCall: (callId: string) => void
+  renderImages: RenderMessageImages
   t: TrajectoryTranslate
 }) {
   return (
@@ -1155,8 +1160,10 @@ function SourceBlocks({
                 </span>
               </div>
             )}
-          {block.imageSrc !== undefined
-            ? <PanelImage block={block} t={t} />
+          {/* The Raw view keeps model block order and granularity: one
+              gallery per image block, unlike the aggregated record gallery. */}
+          {block.attachment !== undefined
+            ? renderImages({ images: [{ attachment: block.attachment }], align: 'start' })
             : <pre className={css.sourceBlockContent}>{block.content}</pre>}
         </section>
       ))}
@@ -1164,47 +1171,27 @@ function SourceBlocks({
   )
 }
 
-function PanelImage({
-  block,
-  preview = false,
-  t,
-}: {
-  block: TrajectorySourceBlock
-  preview?: boolean
-  t: TrajectoryTranslate
-}) {
-  if (block.imageSrc === undefined) return null
-  return (
-    <a
-      className={preview ? `${css.panelImageLink} ${css.panelImageLinkPreview}` : css.panelImageLink}
-      href={block.imageSrc}
-      target="_blank"
-      rel="noopener noreferrer"
-      title={t('block.openImage')}
-    >
-      <img
-        className={css.panelImage}
-        src={block.imageSrc}
-        alt={block.imageAlt ?? ''}
-      />
-    </a>
-  )
+function recordImages(
+  blocks: readonly TrajectorySourceBlock[] | undefined,
+): { readonly attachment: ImageAttachmentRef }[] {
+  return (blocks ?? []).flatMap(block =>
+    block.attachment !== undefined ? [{ attachment: block.attachment }] : [])
 }
 
 function MessageImages({
   blocks,
   preview,
-  t,
+  renderImages,
 }: {
   blocks: readonly TrajectorySourceBlock[] | undefined
   preview: boolean
-  t: TrajectoryTranslate
+  renderImages: RenderMessageImages
 }) {
-  const images = blocks?.filter(block => block.imageSrc !== undefined) ?? []
+  const images = recordImages(blocks)
   if (images.length === 0) return null
   return (
     <div className={preview ? `${css.messageImages} ${css.messageImagesPreview}` : css.messageImages}>
-      {images.map((block, index) => <PanelImage block={block} preview={preview} t={t} key={index} />)}
+      {renderImages({ images, align: 'start' })}
     </div>
   )
 }
@@ -1403,13 +1390,16 @@ function SystemPromptDiff({
 function ToolOutputBlocks({
   blocks,
   error,
+  errorDetail,
   preview,
-  t,
+  renderImages,
 }: {
   blocks: readonly TrajectorySourceBlock[]
   error: boolean
+  /** Failure name and code preserved beside image-only error content. */
+  errorDetail?: string | undefined
   preview: boolean
-  t: TrajectoryTranslate
+  renderImages: RenderMessageImages
 }) {
   return (
     <div className={[
@@ -1418,9 +1408,15 @@ function ToolOutputBlocks({
       error ? css.errorPayload : undefined,
     ].filter((value): value is string => value !== undefined).join(' ')}
     >
+      {error && errorDetail !== undefined && errorDetail !== ''
+        && <pre className={css.resultBlockText}>{errorDetail}</pre>}
       {blocks.map((block, index) => (
-        block.imageSrc !== undefined
-          ? <PanelImage block={block} preview={preview} t={t} key={index} />
+        block.attachment !== undefined
+          ? (
+            <div className={css.messageImages} key={index}>
+              {renderImages({ images: [{ attachment: block.attachment }], align: 'start' })}
+            </div>
+          )
           : block.content !== ''
             ? <pre className={css.resultBlockText} key={index}>{block.content}</pre>
             : null
@@ -1436,6 +1432,7 @@ function MarkdownRecordContent({
   thinkingExpanded,
   onThinkingExpandedChange,
   onOpenCall,
+  renderImages,
   t,
 }: {
   record: TableRecord
@@ -1444,10 +1441,18 @@ function MarkdownRecordContent({
   thinkingExpanded: boolean
   onThinkingExpandedChange: (expanded: boolean) => void
   onOpenCall: (callId: string) => void
+  renderImages: RenderMessageImages
   t: TrajectoryTranslate
 }) {
   if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) {
-    return <SourceBlocks blocks={record.cell.sourceBlocks} onOpenCall={onOpenCall} t={t} />
+    return (
+      <SourceBlocks
+        blocks={record.cell.sourceBlocks}
+        onOpenCall={onOpenCall}
+        renderImages={renderImages}
+        t={t}
+      />
+    )
   }
   if (record.cell.thinkingDetail) {
     if (!rendered) {
@@ -1502,13 +1507,13 @@ function MarkdownRecordContent({
         <MessageImages
           blocks={record.cell.sourceBlocks}
           preview={preview}
-          t={t}
+          renderImages={renderImages}
         />
       </div>
     )
   }
   const source = markdownSource(record)
-  const hasImages = record.cell.sourceBlocks?.some(block => block.imageSrc !== undefined) === true
+  const hasImages = record.cell.sourceBlocks?.some(block => block.attachment !== undefined) === true
   const hasToolCalls = record.cell.kind === 'message'
     && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true
   if (!source && !hasImages && !hasToolCalls) {
@@ -1531,7 +1536,7 @@ function MarkdownRecordContent({
           t={t}
         />
       )}
-      <MessageImages blocks={record.cell.sourceBlocks} preview={preview} t={t} />
+      <MessageImages blocks={record.cell.sourceBlocks} preview={preview} renderImages={renderImages} />
     </div>
   )
 }
@@ -1590,11 +1595,13 @@ function RecordPayload({
   record,
   direction,
   preview = false,
+  renderImages,
   t,
 }: {
   record: TableRecord
   direction: 'input' | 'output'
   preview?: boolean
+  renderImages: RenderMessageImages
   t: TrajectoryTranslate
 }) {
   const value = direction === 'input' ? record.cell.inputDetail : record.cell.outputDetail
@@ -1624,14 +1631,15 @@ function RecordPayload({
   if (
     direction === 'output'
     && record.cell.outputBlocks?.some(block =>
-      block.imageSrc !== undefined || block.content !== '') === true
+      block.attachment !== undefined || block.content !== '') === true
   ) {
     return (
       <ToolOutputBlocks
         blocks={record.cell.outputBlocks}
         error={error}
+        errorDetail={error ? value : undefined}
         preview={preview}
-        t={t}
+        renderImages={renderImages}
       />
     )
   }
@@ -1791,6 +1799,7 @@ function OverviewSection({
  */
 export function TrajectoryTable({
   t,
+  renderImages,
   requestNumbers: sessionRequestNumbers,
   turns,
   streamingCells = [],
@@ -2990,6 +2999,7 @@ export function TrajectoryTable({
                   >
                     <MarkdownRecordContent
                       record={selected}
+                      renderImages={renderImages}
                       rendered
                       thinkingExpanded={thinkingExpanded}
                       onThinkingExpandedChange={setThinkingExpanded}
@@ -3103,6 +3113,7 @@ export function TrajectoryTable({
                         <OverviewSection label={t('tab.preview')} onOpen={() => { activateTab('rendered') }}>
                           <MarkdownRecordContent
                             record={selected}
+                            renderImages={renderImages}
                             rendered
                             preview
                             thinkingExpanded={thinkingExpanded}
@@ -3117,12 +3128,12 @@ export function TrajectoryTable({
                       <>
                         {selected.cell.inputDetail && (
                           <OverviewSection label={t('tab.payload')} onOpen={() => { activateTab('input') }}>
-                            <RecordPayload record={selected} direction="input" preview t={t} />
+                            <RecordPayload record={selected} direction="input" preview renderImages={renderImages} t={t} />
                           </OverviewSection>
                         )}
                         {selected.cell.outputDetail && (
                           <OverviewSection label={t('tab.result')} onOpen={() => { activateTab('output') }}>
-                            <RecordPayload record={selected} direction="output" preview t={t} />
+                            <RecordPayload record={selected} direction="output" preview renderImages={renderImages} t={t} />
                           </OverviewSection>
                         )}
                         <OverviewSection label={t('tab.schema')} onOpen={() => { activateTab('schema') }}>
@@ -3151,6 +3162,7 @@ export function TrajectoryTable({
             {!promptSelected && selected !== undefined && activeTab === 'rendered' && (
               <MarkdownRecordContent
                 record={selected}
+                renderImages={renderImages}
                 rendered
                 thinkingExpanded={thinkingExpanded}
                 onThinkingExpandedChange={setThinkingExpanded}
@@ -3161,6 +3173,7 @@ export function TrajectoryTable({
             {!promptSelected && selected !== undefined && activeTab === 'raw' && (
               <MarkdownRecordContent
                 record={selected}
+                renderImages={renderImages}
                 rendered={false}
                 thinkingExpanded={thinkingExpanded}
                 onThinkingExpandedChange={setThinkingExpanded}
@@ -3172,10 +3185,10 @@ export function TrajectoryTable({
               <MessageSource record={selected} t={t} />
             )}
             {!promptSelected && selected !== undefined && activeTab === 'input' && (
-              <RecordPayload record={selected} direction="input" t={t} />
+              <RecordPayload record={selected} direction="input" renderImages={renderImages} t={t} />
             )}
             {!promptSelected && selected !== undefined && activeTab === 'output' && (
-              <RecordPayload record={selected} direction="output" t={t} />
+              <RecordPayload record={selected} direction="output" renderImages={renderImages} t={t} />
             )}
             {!promptSelected && selected !== undefined && activeTab === 'schema' && (
               <RecordSchema record={selected} t={t} />

+ 15 - 5
packages/client/ui-trajectory/src/client/TrajectoryView.tsx

@@ -1,10 +1,11 @@
 /** Trajectory view: compact summary over a turn-aware event ledger. */
 
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type {
-  AssistantBlock, AssistantMessageNode, ConvViewProps,
+  AssistantBlock, AssistantMessageNode, ConvViewProps, RenderMessageImages,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
+import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import {
   TrajectoryTable,
@@ -69,6 +70,7 @@ export interface TrajectoryViewInjected {
     duration: SnapshotStore<boolean>
   }
   loadOlder: () => Promise<boolean>
+  loadImage: (attachment: ImageAttachmentRef) => Promise<string>
   setActualDuration: (actualDuration: boolean) => void
 }
 
@@ -117,10 +119,17 @@ function addUsage(
 }
 
 export function TrajectoryView({
-  useSession, useTrajectory, useDuration, loadOlder, setActualDuration,
-  viewRequest, completeViewRequest, t,
-}: ConvViewProps & InjectFace<TrajectoryViewInjected> & PropsLocale<'trajectory'>) {
+  useSession, useTrajectory, useDuration, loadOlder, loadImage, setActualDuration,
+  viewRequest, completeViewRequest, renderSlot, t,
+}: ConvViewProps
+  & PropsRenderSlots<'conversation.trajectory.images'>
+  & InjectFace<TrajectoryViewInjected>
+  & PropsLocale<'trajectory'>) {
   const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
+  const renderImages = useCallback<RenderMessageImages>(
+    owner => renderSlot('conversation.trajectory.images', { ...owner, loadImage }),
+    [loadImage, renderSlot],
+  )
   const [collapsedAssistants, setCollapsedAssistants] =
     useState<ReadonlySet<string>>(EMPTY_RECORD_IDS)
   const [timelineSelection, setTimelineSelection] = useState<TrajectoryTimeRange | null>(null)
@@ -481,6 +490,7 @@ export function TrajectoryView({
       <div className={css.ledger}>
         <TrajectoryTable
           t={t}
+          renderImages={renderImages}
           requestNumbers={requestNumbers}
           turns={timelineTurns}
           streamingCells={streamingCells}

+ 4 - 0
packages/client/ui-trajectory/src/client/index.ts

@@ -79,6 +79,9 @@ export function apply(ctx: Context): void {
     order: 10,
     locale: NS,
     label: () => t('view.trajectory'),
+    children: {
+      'conversation.trajectory.images': { kind: 'single', scope: 'session' },
+    },
     inject: (sessionId: SessionId): TrajectoryViewInjected => {
       const session = ctx.sessions.binding(sessionId)?.session
       if (session === undefined) {
@@ -92,6 +95,7 @@ export function apply(ctx: Context): void {
           await session.loadOlder()
           return trajectory.getSnapshot() !== before
         },
+        loadImage: attachment => ctx.uiConversation.imageUrl(sessionId, attachment),
         setActualDuration: (value) => { duration.set(value) },
       }
     },

+ 32 - 50
packages/client/ui-trajectory/src/client/layout.ts

@@ -12,6 +12,7 @@ import type {
   ToolCallBlock,
   ToolResultNode,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type {
   TrajectoryCellProps,
   TrajectorySourceBlock,
@@ -108,7 +109,7 @@ function layoutEntryOrder(entry: OrderedLayoutEntry): number {
     : entry.seq
 }
 
-function inputCellDetail(node: InputNode): Pick<
+function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick<
   TrajectoryCellProps,
   | 'text'
   | 'previewMarkdown'
@@ -119,9 +120,15 @@ function inputCellDetail(node: InputNode): Pick<
   | 'timeSeconds'
   | 'startedAt'
 > {
-  const previewMarkdown = previewContent(node.content)
+  // An empty text block yields an empty preview; treat it as absent so an
+  // image-bearing record still labels its row instead of rendering blank.
+  const preview = previewContent(node.content)
+  const previewMarkdown = preview === '' ? undefined : preview
+  const images = imageBlockCount(node.content)
   return {
-    text: '',
+    text: previewMarkdown === undefined && images > 0
+      ? t('layout.imageOnly', { count: images })
+      : '',
     ...(previewMarkdown === undefined ? {} : { previewMarkdown }),
     sourceSeq: node.seq,
     messageSource: node.source,
@@ -368,7 +375,7 @@ export function deriveTrajectoryLayout(
         cell: {
           index: ++index,
           kind: 'user',
-          ...inputCellDetail(node),
+          ...inputCellDetail(node, t),
           opensTurn: true,
         },
       })
@@ -387,7 +394,7 @@ export function deriveTrajectoryLayout(
         cell: {
           index: ++index,
           kind: 'user' as const,
-          ...inputCellDetail(node),
+          ...inputCellDetail(node, t),
         },
       }
       if (placement.step === undefined) pushMessage(placement.turn, laid)
@@ -415,7 +422,7 @@ export function deriveTrajectoryLayout(
         cell: {
           index: ++index,
           kind: 'context',
-          ...inputCellDetail(node),
+          ...inputCellDetail(node, t),
         },
       })
       prevAbsTime = finiteTime(node.time) ?? prevAbsTime
@@ -788,6 +795,8 @@ function summarizeAssistantActivity(
   if (tools.size > 0) {
     return t('layout.toolCallOnly')
   }
+  const images = blocks.filter(block => block.kind === 'image').length
+  if (images > 0) return t('layout.imageOnly', { count: images })
   return ''
 }
 
@@ -808,12 +817,7 @@ function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock {
       callId: block.callId,
       toolName: block.name,
     }
-    // Attachment refs carry no fetchable bytes, so the record shows the
-    // durable metadata instead of an inline preview.
-    case 'image': return {
-      type: 'image',
-      content: stringifySourceValue(block.attachment),
-    }
+    case 'image': return { type: 'image', content: '', attachment: block.attachment }
     case 'other': return sourceBlock(block.block)
   }
 }
@@ -827,47 +831,21 @@ function sourceBlock(value: unknown): TrajectorySourceBlock {
   if (typeof block.text === 'string') {
     return { type: type === 'reasoning' ? 'thinking' : type, content: block.text }
   }
-  const imageSrc = sourceImage(block)
-  const imageAlt = typeof block.alt === 'string' ? block.alt : undefined
-  return {
-    type,
-    content: imageSrc === undefined ? stringifySourceValue(value) : '',
-    ...(imageSrc !== undefined ? { imageSrc } : {}),
-    ...(imageAlt !== undefined ? { imageAlt } : {}),
-  }
-}
-
-function sourceImage(block: Record<string, unknown>): string | undefined {
-  if (typeof block.type !== 'string' || !block.type.toLowerCase().includes('image')) return undefined
-  for (const candidate of [block.url, block.image_url]) {
-    if (typeof candidate === 'string') return safeImageSource(candidate)
-  }
-  if (typeof block.data === 'string') {
-    const mediaType = [block.mimeType, block.mediaType, block.media_type]
-      .find((candidate): candidate is string => typeof candidate === 'string')
-      ?? 'image/png'
-    return safeImageSource(
-      block.data.startsWith('data:')
-        ? block.data
-        : `data:${mediaType};base64,${block.data}`,
-    )
+  if (
+    type === 'image'
+    && typeof block.attachment === 'object' && block.attachment !== null
+    && typeof (block.attachment as Record<string, unknown>).attachmentId === 'string'
+  ) {
+    // Session-log content is validated into core ContentBlocks by the
+    // Conversation node assembly; the `attachmentId` guard only keeps
+    // wire-shaped 'other' blocks with an unrelated `attachment` member out.
+    return { type, content: '', attachment: block.attachment as ImageAttachmentRef }
   }
-  if (typeof block.source !== 'object' || block.source === null) return undefined
-  const source = block.source as Record<string, unknown>
-  if (typeof source.url === 'string') return safeImageSource(source.url)
-  if (typeof source.data !== 'string') return undefined
-  const mediaType = typeof source.media_type === 'string' ? source.media_type : 'image/png'
-  return safeImageSource(`data:${mediaType};base64,${source.data}`)
+  return { type, content: stringifySourceValue(value) }
 }
 
-function safeImageSource(value: string): string | undefined {
-  if (value.startsWith('data:image/') || value.startsWith('blob:')) return value
-  try {
-    const protocol = new URL(value).protocol
-    return protocol === 'http:' || protocol === 'https:' ? value : undefined
-  } catch {
-    return undefined
-  }
+function imageBlockCount(content: readonly { type: string }[]): number {
+  return content.filter(block => block.type === 'image').length
 }
 
 function stringifySourceValue(value: unknown): string {
@@ -1087,6 +1065,8 @@ function summarizeResult(
       return { result: '', resultPreviewMarkdown: block.text }
     }
   }
+  const images = imageBlockCount(node.content)
+  if (images > 0) return { result: t('layout.imageOnly', { count: images }) }
   return { result: t('record.noOutput') }
 }
 
@@ -1112,6 +1092,8 @@ function detailResult(node: ToolResultNode, t: TrajectoryTranslate): string {
     .map(block => block.type === 'text' ? block.text : '')
     .join('\n')
   if (text !== '') return text
+  const images = imageBlockCount(node.content)
+  if (images > 0) return t('layout.imageOnly', { count: images })
   if (
     node.content.length === 0
     || node.content.every(block =>

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

@@ -121,7 +121,6 @@ export const zh = {
   'block.openSummary': '打开第 {index} 个块的工具调用概述',
   'block.openSummaryTitle': '打开工具调用概述',
   'block.label': '块 #{index} {type}',
-  'block.openImage': '打开图片',
   'history.loadingTrajectory': '正在加载轨迹…',
   'history.loadingEarlier': '正在加载更早的历史…',
   'history.loadingEarlierAria': '正在加载更早的历史…',
@@ -174,6 +173,7 @@ export const zh = {
   'layout.compactionFailed': '上下文压缩失败',
   'layout.compacted': '上下文已压缩',
   'layout.toolCallOnly': '仅工具调用',
+  'layout.imageOnly': '图片 ×{count}',
   'layout.initialSystemPrompt': '初始系统提示词',
   'layout.systemPromptUpdated': '系统提示词已更新',
   'layout.toolsUpdated': '工具已更新',
@@ -313,7 +313,6 @@ export const en: Record<TrajectoryKey, string> = {
   'block.openSummary': 'Open Block #{index} tool call summary',
   'block.openSummaryTitle': 'Open tool call summary',
   'block.label': 'Block #{index} {type}',
-  'block.openImage': 'Open image',
   'history.loadingTrajectory': 'Loading trajectory…',
   'history.loadingEarlier': 'Loading earlier history…',
   'history.loadingEarlierAria': 'Loading earlier history…',
@@ -366,6 +365,7 @@ export const en: Record<TrajectoryKey, string> = {
   'layout.compactionFailed': 'Compaction failed',
   'layout.compacted': 'Context compacted',
   'layout.toolCallOnly': 'Tool call only',
+  'layout.imageOnly': 'Images ×{count}',
   'layout.initialSystemPrompt': 'Initial System Prompt',
   'layout.systemPromptUpdated': 'System Prompt Updated',
   'layout.toolsUpdated': 'Tools Updated',

+ 12 - 2
packages/client/ui-trajectory/src/client/trajectory-contract.ts

@@ -1,7 +1,7 @@
 import type {
   AssistantMessageNode, ConversationLocation, ConversationNode, ConversationPromptSnapshot,
-  ConversationViewNode, PartialAssistant, RequestPromptChange, RequestView, RunningToolCall,
-  ToolCallBlock,
+  ConversationViewNode, MessageImagesOwnerProps, PartialAssistant, RequestPromptChange,
+  RequestView, RunningToolCall, ToolCallBlock,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 
@@ -84,4 +84,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
     /** Selector hook over the current Conversation binding's Trajectory target. */
     useTrajectory: UseTrajectory
   }
+
+  interface SlotMap {
+    /**
+     * Renderer for one group of durable record images in the Trajectory
+     * ledger. The owner supplies image references, an authorized loader, and
+     * alignment. A registration replaces the shipped gallery; without one,
+     * images are omitted.
+     */
+    'conversation.trajectory.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps }
+  }
 }

+ 2 - 2
packages/client/ui-trajectory/src/client/trajectory-record.ts

@@ -1,6 +1,7 @@
 /** Shared trajectory record data and formatting contracts. */
 
 import type { HTMLAttributes } from 'react'
+import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { TrajectoryTranslate } from './locales.ts'
 
@@ -28,8 +29,7 @@ export interface AssistantMetricDetail {
 export interface TrajectorySourceBlock {
   type: string
   content: string
-  imageSrc?: string
-  imageAlt?: string
+  attachment?: ImageAttachmentRef
   callId?: string
   toolName?: string
 }

+ 1 - 1
packages/client/ui-trajectory/src/client/trajectory-search-index.ts

@@ -64,7 +64,7 @@ function recordSources(
       block.content,
       block.callId ?? '',
       block.toolName ?? '',
-      block.imageAlt ?? '',
+      block.attachment?.name ?? '',
     ]),
     searchableJson(cell.messageSource),
     searchableJson(cell.promptDetail),

+ 100 - 0
packages/client/ui-trajectory/tests/layout.client.spec.tsx

@@ -566,3 +566,103 @@ describe('run_code sub-dispatch cells', () => {
     expect(cells.map(cell => cell.index)).toEqual([1, 2, 3, 4])
   })
 })
+
+describe('durable image attachments', () => {
+  const attachment = {
+    attachmentId: `sha256:${'a'.repeat(64)}`,
+    mediaType: 'image/png',
+    bytes: 68,
+    width: 640,
+    height: 320,
+    name: 'screenshot.png',
+  }
+
+  it('carries user image refs into sourceBlocks and labels an image-only record', () => {
+    const nodes = [
+      {
+        kind: 'user', seq: 1, time: 1_000, source: null,
+        content: [{ type: 'image', attachment }, { type: 'image', attachment }],
+      },
+    ] as unknown as LegacyConversationSlice['nodes']
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+    const user = turns[0]?.groups[0]?.cells[0]
+    expect(user?.text).toBe('Images ×2')
+    expect(user?.previewMarkdown).toBeUndefined()
+    expect(user?.sourceBlocks).toEqual([
+      { type: 'image', content: '', attachment },
+      { type: 'image', content: '', attachment },
+    ])
+  })
+
+  it('labels a record whose only text block is empty as image-only', () => {
+    const nodes = [
+      {
+        kind: 'user', seq: 1, time: 1_000, source: null,
+        content: [{ type: 'text', text: '' }, { type: 'image', attachment }],
+      },
+    ] as unknown as LegacyConversationSlice['nodes']
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+    const user = turns[0]?.groups[0]?.cells[0]
+    expect(user?.text).toBe('Images ×1')
+    expect(user?.previewMarkdown).toBeUndefined()
+  })
+
+  it('keeps the text preview when a user message mixes text and images', () => {
+    const nodes = [
+      {
+        kind: 'user', seq: 1, time: 1_000, source: null,
+        content: [{ type: 'text', text: 'look at this' }, { type: 'image', attachment }],
+      },
+    ] as unknown as LegacyConversationSlice['nodes']
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+    const user = turns[0]?.groups[0]?.cells[0]
+    expect(user?.text).toBe('')
+    expect(user?.previewMarkdown).toBe('look at this')
+    expect(user?.sourceBlocks?.[1]).toEqual({ type: 'image', content: '', attachment })
+  })
+
+  it('maps assistant image blocks to attachment source blocks and labels image-only output', () => {
+    const nodes = [
+      {
+        kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 0,
+        blocks: [{ kind: 'image', attachment }],
+      },
+    ] as unknown as LegacyConversationSlice['nodes']
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+    const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
+    expect(message?.text).toBe('Images ×1')
+    expect(message?.sourceBlocks).toEqual([{ type: 'image', content: '', attachment }])
+  })
+
+  it('carries tool-result image refs into outputBlocks and labels the result', () => {
+    const nodes = [
+      {
+        kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1,
+        blocks: [{ kind: 'tool-call', callId: 'c1', name: 'read_image', argsRaw: '{}' }],
+      },
+      {
+        kind: 'tool-result', seq: 2, time: 2_000, callId: 'c1',
+        call: { name: 'read_image', argsRaw: '{}' }, callTime: 1_200,
+        content: [{ type: 'image', attachment }], isError: false,
+      },
+    ] as unknown as LegacyConversationSlice['nodes']
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+    const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool')
+    expect(tool?.result).toBe('Images ×1')
+    expect(tool?.outputDetail).toBe('Images ×1')
+    expect(tool?.outputBlocks).toEqual([{ type: 'image', content: '', attachment }])
+  })
+
+  it('shows wire-shaped blocks without an attachment as JSON, not as an image', () => {
+    const nodes = [
+      {
+        kind: 'user', seq: 1, time: 1_000, source: null,
+        content: [{ type: 'image', url: 'https://example.com/a.png' }],
+      },
+    ] as unknown as LegacyConversationSlice['nodes']
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+    const block = turns[0]?.groups[0]?.cells[0]?.sourceBlocks?.[0]
+    expect(block?.attachment).toBeUndefined()
+    expect(block?.content).toContain('https://example.com/a.png')
+  })
+})

+ 136 - 2
packages/client/ui-trajectory/tests/table.client.spec.tsx

@@ -4,12 +4,24 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import type { ComponentProps } from 'react'
+import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx'
 import type { TrajectoryTurnModel } from '../src/client/layout.ts'
 import { trajectoryRecordId } from '../src/client/trajectory-record.ts'
 import { t, tZh } from './locale.client.ts'
 
-function TrajectoryTable(props: Omit<ComponentProps<typeof LocalizedTrajectoryTable>, 't'>) {
+const renderImagesStub: RenderMessageImages = ({ images }) => (
+  <div data-testid="record-images" data-count={images.length}>
+    {images.map((image, index) => (
+      <span key={index} data-attachment-id={image.attachment.attachmentId} />
+    ))}
+  </div>
+)
+
+function TrajectoryTable(
+  props: Omit<ComponentProps<typeof LocalizedTrajectoryTable>, 't' | 'renderImages'>
+    & { renderImages?: RenderMessageImages },
+) {
   const inferred: Array<NonNullable<typeof props.requestNumbers>[number] & { firstIndex: number }> = []
   for (const turn of props.turns) {
     for (const group of turn.groups) {
@@ -40,7 +52,14 @@ function TrajectoryTable(props: Omit<ComponentProps<typeof LocalizedTrajectoryTa
   const requestNumbers = props.requestNumbers ?? inferred
     .sort((left, right) => left.firstIndex - right.firstIndex)
     .map(({ firstIndex: _firstIndex, ...request }, index) => ({ ...request, number: index + 1 }))
-  return <LocalizedTrajectoryTable {...props} requestNumbers={requestNumbers} t={t} />
+  return (
+    <LocalizedTrajectoryTable
+      renderImages={renderImagesStub}
+      {...props}
+      requestNumbers={requestNumbers}
+      t={t}
+    />
+  )
 }
 
 afterEach(() => {
@@ -129,6 +148,7 @@ describe('TrajectoryTable', () => {
     render(
       <LocalizedTrajectoryTable
         t={tZh}
+        renderImages={renderImagesStub}
         turns={TURNS}
         collapsedTurns={new Set<number>()}
         onToggleTurn={() => {}}
@@ -937,6 +957,120 @@ describe('TrajectoryTable', () => {
     expect(screen.getByText('value:')).toBeTruthy()
   })
 
+  it('renders user image attachments through the shared gallery in the details panel', () => {
+    const attachment = {
+      attachmentId: `sha256:${'a'.repeat(64)}`,
+      mediaType: 'image/png',
+      bytes: 68,
+      width: 640,
+      height: 320,
+      name: 'screenshot.png',
+    } as unknown as NonNullable<
+      NonNullable<TrajectoryTurnModel['groups'][number]['cells'][number]['sourceBlocks']>[number]['attachment']
+    >
+    const turns: readonly TrajectoryTurnModel[] = [{
+      turn: 1,
+      groups: [{
+        title: 'Message',
+        cells: [{
+          index: 1,
+          kind: 'user',
+          text: 'Images ×2',
+          sourceBlocks: [
+            { type: 'image', content: '', attachment },
+            { type: 'image', content: '', attachment },
+          ],
+          timeSeconds: 0,
+        }],
+      }],
+    }]
+
+    render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
+    fireEvent.click(screen.getByRole('row', { name: /USER/ }))
+
+    const preview = screen.getAllByTestId('record-images')
+    expect(preview.length).toBeGreaterThan(0)
+    expect(preview[0]?.getAttribute('data-count')).toBe('2')
+
+    fireEvent.click(screen.getByRole('tab', { name: 'Raw' }))
+    const rawGalleries = screen.getAllByTestId('record-images')
+    expect(rawGalleries).toHaveLength(2)
+    expect(rawGalleries[0]?.querySelector('[data-attachment-id]')?.getAttribute('data-attachment-id'))
+      .toBe(String(attachment.attachmentId))
+  })
+
+  it('renders a tool-result image through the shared gallery in the Result tab', () => {
+    const attachment = {
+      attachmentId: `sha256:${'b'.repeat(64)}`,
+      mediaType: 'image/png',
+      bytes: 68,
+      width: 320,
+      height: 640,
+      name: 'capture.png',
+    } as unknown as NonNullable<
+      NonNullable<TrajectoryTurnModel['groups'][number]['cells'][number]['outputBlocks']>[number]['attachment']
+    >
+    const turns: readonly TrajectoryTurnModel[] = [{
+      turn: 1,
+      groups: [{
+        title: 'Step 1',
+        cells: [{
+          index: 1,
+          kind: 'tool',
+          text: 'read_image {"path":"a.png"}',
+          outputDetail: 'Images ×1',
+          outputBlocks: [{ type: 'image', content: '', attachment }],
+          timeSeconds: 0.1,
+        }],
+      }],
+    }]
+
+    render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
+    fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
+    fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
+
+    const gallery = screen.getAllByTestId('record-images').at(-1)
+    expect(gallery?.getAttribute('data-count')).toBe('1')
+    expect(gallery?.querySelector('[data-attachment-id]')?.getAttribute('data-attachment-id'))
+      .toBe(String(attachment.attachmentId))
+  })
+
+  it('keeps the failure name beside an image-only error result', () => {
+    const attachment = {
+      attachmentId: `sha256:${'c'.repeat(64)}`,
+      mediaType: 'image/png',
+      bytes: 68,
+      width: 320,
+      height: 320,
+      name: 'failed.png',
+    } as unknown as NonNullable<
+      NonNullable<TrajectoryTurnModel['groups'][number]['cells'][number]['outputBlocks']>[number]['attachment']
+    >
+    const turns: readonly TrajectoryTurnModel[] = [{
+      turn: 1,
+      groups: [{
+        title: 'Step 1',
+        cells: [{
+          index: 1,
+          kind: 'tool',
+          text: 'render {"target":"chart"}',
+          outputDetail: 'ToolError: RENDER_TRUNCATED',
+          outputBlocks: [{ type: 'image', content: '', attachment }],
+          isError: true,
+          timeSeconds: 0.1,
+        }],
+      }],
+    }]
+
+    render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
+    fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
+    fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
+
+    expect(screen.getByText('ToolError: RENDER_TRUNCATED')).toBeTruthy()
+    const gallery = screen.getAllByTestId('record-images').at(-1)
+    expect(gallery?.getAttribute('data-count')).toBe('1')
+  })
+
   it('keeps the first row and a compact summary when a turn is collapsed', () => {
     render(
       <TrajectoryTable

+ 4 - 0
packages/client/ui-trajectory/tests/views.client.spec.tsx

@@ -228,6 +228,10 @@ function standaloneProps(
     viewRequest: null,
     openView: () => {},
     completeViewRequest: () => {},
+    // Image seats the outlet would bake: standalone renders omit the gallery.
+    renderSlot: () => null,
+    SessionProvider: ({ children }) => <>{children}</>,
+    loadImage: () => Promise.reject(new Error('standalone views load no images')),
     // The locale seat the outlet would inject for the declared namespace.
     t: tZh,
   }

+ 3 - 0
packages/client/ui-trajectory/tsconfig.json

@@ -49,6 +49,9 @@
     },
     {
       "path": "../../llm/llm"
+    },
+    {
+      "path": "../../attachment/attachment"
     }
   ]
 }

+ 2 - 2
packages/core/system-prompt/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/core/system-prompt/README.md
-README.md: a52aa3e4c2782993fed5a525cc827aba4e3eaeb0
-README.zh.md: cf0ba43aa2f2f47ea61ef13c74fbf182fbd9f2ee
+README.md: d750a507e628e7609af542227e4528d4d4934ce8
+README.zh.md: ec5b32d742b96c8044a3707c35f15e30ba642b4f

+ 0 - 1
packages/core/system-prompt/README.md

@@ -11,7 +11,6 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem
 | `includeHarnessIdentity` | `true` | Include the fixed `You are an AI agent powered by DeepSeek Harness.` order-−100 opener. Set false only when a compatibility deployment owns the complete system prompt. |
 | `includeRuntimeContext` | `true` | Include ordered dynamic contexts in assembly. When false, context providers are not evaluated and contexts added by `system-prompt/assemble` listeners are discarded after the waterfall; other services and their enforcement remain active. |
 | `persona` | `''` | The global deployment-persona default: the ONE config-authored prompt fragment, rendered as the order-0 `deployment:persona` section unless an agent-scoped contribution shadows it. A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. |
-| `personaComplete` | `false` | Treat `persona` as the complete system prompt after assembly. Other sections remain registered but are omitted from model requests; tool schemas and variables remain available. |
 | `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `'<unlisted-tools>'` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md). |
 
 ## Service: `SystemPrompt` (ctx key: `systemPrompt`)

+ 0 - 1
packages/core/system-prompt/README.zh.md

@@ -11,7 +11,6 @@
 | `includeHarnessIdentity` | `true` | 是否包含顺序为 −100 的固定开场白 `You are an AI agent powered by DeepSeek Harness.`。仅当兼容性部署拥有完整系统提示词时设为 false。 |
 | `includeRuntimeContext` | `true` | 是否在组装中包含有序动态上下文。设为 false 时不会求值上下文提供方,并会在 waterfall 后丢弃 `system-prompt/assemble` 监听器添加的上下文;其他服务及其强制机制仍然生效。 |
 | `persona` | `''` | 全局部署 persona 默认值:唯一由配置提供的提示词片段,渲染为顺序为 0 的 `deployment:persona` 段,除非 agent 作用域的贡献将其遮蔽。它是模板,完整的 `{{…}}` 组会严格按已注册变量解释(随附循环注册 `{{model}}`/`{{cwd}}`),目前没有表达字面量花括号的转义语法。为空 ⇒ 渲染时删除该段。 |
-| `personaComplete` | `false` | 在组装后将 `persona` 作为完整系统提示词。其他段仍保持注册,但不会进入模型请求;工具 schema 与变量仍然可用。 |
 | `toolOrder` | 无 | 显式指定面向模型的工具顺序。该列表由 `ToolSchema.name` 组成,并且必须恰好包含一个 `'<unlisted-tools>'` 其余项标记(`TOOL_ORDER_REST`):已列工具按列表位置排列,未列工具则按名称字典序插入该标记所在的位置。缺席 ⇒ 直接按名称字典序排列。该顺序会在 `system-prompt/assemble` waterfall(瀑布式事件)之前应用于已收集的工具。与段的 `order` 排序一样,它会规范化注册表贡献的内容;注册顺序只是插件加载时序的产物。修改列表的 waterfall 监听器对其输出的确定性负责。配置错误会明确失败:列表没有恰好一个其余项或存在重复项,会在加载时抛出;已列名称没有对应已注册工具,会使每次 `assemble()` 被拒绝;工具提供方返回保留的其余项名称也会被拒绝。在随附循环下,轮次会在任何模型请求前失败。为何采用中心列表而非每插件权重,见[显式面向模型工具顺序](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md)。 |
 
 ## 服务:`SystemPrompt`(ctx 键:`systemPrompt`)

+ 0 - 4
packages/core/system-prompt/src/index.ts

@@ -193,8 +193,6 @@ export interface Config {
    * `deployment:persona` shadows it; `{{variable}}` references are strict.
    */
   persona?: string
-  /** Treat the deployment persona as the complete system prompt (default false). */
-  personaComplete?: boolean
   /**
    * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once.
    * Invalid fields fail at load and unknown names fail at assembly; known names
@@ -342,7 +340,6 @@ export class SystemPrompt extends Service {
     includeHarnessIdentity: z.boolean().default(true),
     includeRuntimeContext: z.boolean().default(true),
     persona: z.string().default(''),
-    personaComplete: z.boolean().default(false),
     // Preserve omission because an explicit empty order lacks the rest marker.
     toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
   })
@@ -369,7 +366,6 @@ export class SystemPrompt extends Service {
       order: PERSONA_ORDER,
       // The fallback narrows the optional input type; the schema already defaults it.
       text: config.persona ?? '',
-      complete: config.personaComplete ?? false,
     })
     if (!(config.includeRuntimeContext ?? true)) this.suppressRuntimeContext()
   }

+ 0 - 15
packages/core/system-prompt/tests/system-prompt.spec.ts

@@ -49,21 +49,6 @@ describe('SystemPrompt', () => {
       expect(renderPrompt(assembly)).toBe('You are a helpful software engineer assistant.')
     })
 
-    it('can make the deployment persona the complete system prompt', async () => {
-      const ctx = new Context()
-      await ctx.plugin(SystemPrompt, {
-        persona: 'You are a focused SDK agent.',
-        personaComplete: true,
-      })
-      ctx.systemPrompt.section({ name: 'tool:future', order: 100, text: 'Future tool guidance.' })
-
-      const assembly = await ctx.systemPrompt.assemble()
-      expect(assembly.sections).toEqual([
-        { name: 'deployment:persona', text: 'You are a focused SDK agent.' },
-      ])
-      expect(renderPrompt(assembly)).toBe('You are a focused SDK agent.')
-    })
-
     it('can suppress runtime context without evaluating providers or accepting waterfall additions', async () => {
       const ctx = new Context()
       await ctx.plugin(SystemPrompt, { includeRuntimeContext: false })

+ 2 - 2
packages/examples/agent-spine-demo/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/examples/agent-spine-demo/README.md
-README.md: 28e1496a8c941f6524ec4b4dfbfad52d17df5d71
-README.zh.md: c2838bb4cf78d6ac863bac2eba4d2e4df335fa55
+README.md: ef82aa4b413be41049dbbd247deee4f1969cdb48
+README.zh.md: 7701da0d965736efd905a3331401752119b160d9

+ 2 - 2
packages/examples/agent-spine-demo/README.md

@@ -55,11 +55,11 @@ This applies the [Service Definition / Service Provider / Consumer separation](.
 
 ```ts
 import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
-// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, personaComplete?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? }
+// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? }
 // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
 ```
 
-The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. `includeRuntimeContext: false` suppresses all dynamic context snapshots for fresh sessions without disabling their policy services; `personaComplete: true` makes the deployment persona the sole system-prompt section. Prompt, tool, title, skill, agent-instructions, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `jobs.maxConcurrentJobsPerOwner` configures the local provider independently of the model-facing `toolJobs` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition.
+The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. `includeRuntimeContext: false` suppresses all dynamic context snapshots for fresh sessions without disabling their policy services. Prompt, tool, title, skill, agent-instructions, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `jobs.maxConcurrentJobsPerOwner` configures the local provider independently of the model-facing `toolJobs` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition.
 
 For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../runtime-diagnostics/invariants/README.md) for regex and lifecycle rules.
 

+ 2 - 2
packages/examples/agent-spine-demo/README.zh.md

@@ -55,11 +55,11 @@
 
 ```ts
 import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
-// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, personaComplete?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? }
+// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? }
 // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
 ```
 
-组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。`includeRuntimeContext: false` 会为新建会话抑制所有动态上下文快照,但不禁用其策略服务;`personaComplete: true` 会让部署 persona 成为唯一系统提示词段。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值;`jobs.maxConcurrentJobsPerOwner` 配置本地 Service Provider,并与面向模型的 `toolJobs` 控制工具相互独立。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。
+组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。`includeRuntimeContext: false` 会为新建会话抑制所有动态上下文快照,但不禁用其策略服务。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值;`jobs.maxConcurrentJobsPerOwner` 配置本地 Service Provider,并与面向模型的 `toolJobs` 控制工具相互独立。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。
 
 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../runtime-diagnostics/invariants/README.zh.md)。
 

+ 3 - 7
packages/examples/agent-spine-demo/src/index.ts

@@ -70,9 +70,9 @@ export interface GoalConfig {
  * Bundle config: each field forwarded verbatim to the child that owns it —
  * `agents` to the agent loop (an app that pre-creates no agents, like the ACP
  * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`,
- * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin
- * (the fixed opener, dynamic-context policy, deployment persona completeness,
- * and explicit model-facing tool order), the `tools` object to the tool
+ * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener,
+ * dynamic-context policy, deployment persona, and explicit model-facing tool
+ * order), the `tools` object to the tool
  * registry (its presentation `mode`),
  * `dshHome` to bash environment and local skill discovery, `sessionTitle` to
  * the fallback title service, `skills` to the
@@ -101,8 +101,6 @@ export interface Config {
   includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext']
   /** The deployment persona (see dsh-system-prompt's `Config`). */
   persona?: SystemPromptConfig['persona']
-  /** Whether the deployment persona is the complete system prompt. */
-  personaComplete?: SystemPromptConfig['personaComplete']
   /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
   toolOrder?: SystemPromptConfig['toolOrder']
   /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
@@ -188,7 +186,6 @@ export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'a
     ...config.includeHarnessIdentity !== undefined ? { includeHarnessIdentity: config.includeHarnessIdentity } : {},
     ...config.includeRuntimeContext !== undefined ? { includeRuntimeContext: config.includeRuntimeContext } : {},
     ...config.persona !== undefined ? { persona: config.persona } : {},
-    ...config.personaComplete !== undefined ? { personaComplete: config.personaComplete } : {},
     ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
     ...config.tools !== undefined ? { tools: config.tools } : {},
     ...config.dshHome !== undefined ? { dshHome: config.dshHome } : {},
@@ -230,7 +227,6 @@ export function apply(ctx: Context, config: Config): void {
     includeHarnessIdentity: config.includeHarnessIdentity ?? true,
     includeRuntimeContext: config.includeRuntimeContext ?? true,
     persona: config.persona ?? '',
-    personaComplete: config.personaComplete ?? false,
     ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
   })
   ctx.plugin(ToolRuntime, config.tools ?? {})

+ 1 - 5
packages/examples/agent-spine-demo/tests/agent-core.spec.ts

@@ -709,12 +709,11 @@ describe('dsh-agent-spine-demo bundle', () => {
     await ctx.fiber.dispose()
   })
 
-  it('can omit the bundled bash tool and Harness identity for a compatibility deployment', async () => {
+  it('can omit the bundled bash tool, Harness identity, and runtime context', async () => {
     const ctx = await mount({
       includeHarnessIdentity: false,
       includeRuntimeContext: false,
       persona: 'You are a helpful software engineer assistant.',
-      personaComplete: true,
       workspaceContext: false,
       skills: { enabled: false },
       toolBash: false,
@@ -724,7 +723,6 @@ describe('dsh-agent-spine-demo bundle', () => {
     expect(ctx.tools.schemas()).toEqual([])
     ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'hidden policy' })
     expect((await ctx.systemPrompt.assemble()).contexts).toEqual([])
-    ctx.systemPrompt.section({ name: 'hidden', order: 100, text: 'hidden guidance' })
     expect(renderPrompt(await ctx.systemPrompt.assemble()))
       .toBe('You are a helpful software engineer assistant.')
 
@@ -738,7 +736,6 @@ describe('dsh-agent-spine-demo bundle', () => {
       includeHarnessIdentity: false,
       includeRuntimeContext: false,
       persona: 'You are merged.',
-      personaComplete: true,
       toolOrder: ['zulu'],
       tools: { mode: 'native' as const },
       dshHome: '/tmp/dsh-home',
@@ -757,7 +754,6 @@ describe('dsh-agent-spine-demo bundle', () => {
       includeHarnessIdentity: appConfig.includeHarnessIdentity,
       includeRuntimeContext: appConfig.includeRuntimeContext,
       persona: appConfig.persona,
-      personaComplete: appConfig.personaComplete,
       toolOrder: appConfig.toolOrder,
       tools: appConfig.tools,
       dshHome: appConfig.dshHome,

+ 64 - 26
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n      { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:197',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:186',
   },
   {
     key: 'conversation.chat.commandview',
@@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n      { name: \'conversation.chat.commandview\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:185',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:174',
   },
   {
     key: 'conversation.chat.node',
@@ -310,7 +310,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n      { name: \'conversation.chat.node\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:166',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:155',
   },
   {
     key: 'conversation.chat.turnTail',
@@ -355,7 +355,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n      { name: \'conversation.chat.turnTail\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:191',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:180',
   },
   {
     key: 'conversation.composer',
@@ -404,7 +404,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n      { name: \'conversation.composer\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:78',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:92',
   },
   {
     key: 'conversation.composer.bar',
@@ -440,7 +440,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n      { name: \'conversation.composer.bar\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:96',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:110',
   },
   {
     key: 'conversation.composer.dock',
@@ -498,7 +498,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n      { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:90',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:104',
   },
   {
     key: 'conversation.details.tool',
@@ -534,7 +534,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n      { name: \'conversation.details.tool\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:203',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:192',
   },
   {
     key: 'conversation.hero.agentPreset',
@@ -562,7 +562,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n      { name: \'conversation.hero.agentPreset\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:84',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:98',
   },
   {
     key: 'conversation.hero.brand.mark',
@@ -590,7 +590,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n      { name: \'conversation.hero.brand.mark\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:82',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:96',
   },
   {
     key: 'conversation.hero.workspace',
@@ -620,7 +620,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n      { name: \'conversation.hero.workspace\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:80',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:94',
   },
   {
     key: 'conversation.hero.workspace.directoryFlow',
@@ -686,7 +686,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n      { name: \'conversation.input.attachments\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:98',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:112',
   },
   {
     key: 'conversation.input.dock',
@@ -746,7 +746,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n      { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:86',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:100',
   },
   {
     key: 'conversation.input.left',
@@ -802,7 +802,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n      { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:92',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:106',
   },
   {
     key: 'conversation.input.model',
@@ -838,7 +838,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n      { name: \'conversation.input.model\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:106',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:120',
   },
   {
     key: 'conversation.input.overlay',
@@ -892,7 +892,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n      { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:88',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:102',
   },
   {
     key: 'conversation.input.plan',
@@ -928,7 +928,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n      { name: \'conversation.input.plan\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:104',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:118',
   },
   {
     key: 'conversation.input.right',
@@ -984,7 +984,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n      { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:94',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:108',
   },
   {
     key: 'conversation.message.images',
@@ -994,7 +994,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.',
     registerOptions: [],
     ownerProps: [
-      '/** Historical image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n  images: readonly { readonly attachment: ImageAttachmentRef }[]\n  loadImage: (attachment: ImageAttachmentRef) => Promise<string>\n  align: \'start\' | \'end\'\n}',
+      '/** Durable image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n  /** Durable image references in source order. */\n  images: readonly { readonly attachment: ImageAttachmentRef }[]\n  /** Session-authorized image URL loader. */\n  loadImage: (attachment: ImageAttachmentRef) => Promise<string>\n  /** Horizontal placement inside the owning record. */\n  align: \'start\' | \'end\'\n}',
     ],
     ownerPropsReferences: [
       'ImageAttachmentRef',
@@ -1022,7 +1022,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n      { name: \'conversation.message.images\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:179',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:168',
   },
   {
     key: 'conversation.session',
@@ -1056,7 +1056,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n      { name: \'conversation.session\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:54',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:68',
   },
   {
     key: 'conversation.session.header',
@@ -1090,7 +1090,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n      { name: \'conversation.session.header\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:56',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:70',
   },
   {
     key: 'conversation.session.header.actions',
@@ -1146,7 +1146,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n      { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:64',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:78',
   },
   {
     key: 'conversation.session.header.lineage',
@@ -1184,7 +1184,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n      { name: \'conversation.session.header.lineage\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:58',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:72',
   },
   {
     key: 'conversation.session.header.utilities',
@@ -1239,7 +1239,45 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n      { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:70',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:84',
+  },
+  {
+    key: 'conversation.trajectory.images',
+    kind: 'single',
+    scope: 'session',
+    summary: 'Renderer for one group of durable record images in the Trajectory ledger.',
+    doc: 'Renderer for one group of durable record images in the Trajectory\nledger. The owner supplies image references, an authorized loader, and\nalignment. A registration replaces the shipped gallery; without one,\nimages are omitted.',
+    registerOptions: [],
+    ownerProps: [
+      '/** Durable image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n  /** Durable image references in source order. */\n  images: readonly { readonly attachment: ImageAttachmentRef }[]\n  /** Session-authorized image URL loader. */\n  loadImage: (attachment: ImageAttachmentRef) => Promise<string>\n  /** Horizontal placement inside the owning record. */\n  align: \'start\' | \'end\'\n}',
+    ],
+    ownerPropsReferences: [
+      'ImageAttachmentRef',
+    ],
+    standardProps: [
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+      'useSessions: UseSessions',
+      'useSessionPendingInteraction: UseSessionPendingInteraction',
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+      'useChat: UseChat',
+      'useConversation: UseConversation',
+      'useInput: SnapshotSelectorHook<InputState>',
+      'inputActions: InputActions',
+      'useSession: SessionSnapshotSelector',
+      'sessionId: SessionId',
+      'useProjection: UseProjection',
+      'useTrajectory: UseTrajectory',
+    ],
+    keyDomain: '',
+    hookContext: '',
+    slotInject: '',
+    declaredBy: 'an entry in \'conversation.view\' (client-ui-trajectory), so it exists while that entry is mounted',
+    occupants: [
+      'client-ui-attachment MessageImages',
+    ],
+    replaceRisk: 'shadows-shipped-ui',
+    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.trajectory.images\', () => ctx.slots.register(\n      { name: \'conversation.trajectory.images\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
+    source: 'packages/client/ui-trajectory/src/client/trajectory-contract.ts:95',
   },
   {
     key: 'conversation.view',
@@ -1297,7 +1335,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n      { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:76',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:90',
   },
   {
     key: 'details',

+ 2 - 2
packages/sdk/server/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/sdk/server/README.md
-README.md: e6d36df7e387e895941c082226ac955c69f16ca4
-README.zh.md: 13c026edc491ebdbec298b8c9cd330b03d675db7
+README.md: f10c45e2df06383d6726f249bb6db2015a7fb418
+README.zh.md: 7bf3bcdab2a318fbfcba7b5a6c3a60ff883f47b6

+ 2 - 2
packages/sdk/server/README.md

@@ -10,7 +10,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc
 
 ## Config
 
-`maxTokensAsSuccess` defaults to `false` and affects only the deployment-mapped status on `subagent.finished`; root-session prompts have no prompt-level status. Optional `toolFilter.allow` and `toolFilter.deny` restrict each SDK-created root agent through `ctx.tools.restrict()`. An allow list excludes later global tool registrations that it does not name, so a fixed SDK deployment cannot silently gain model-facing tools when its base bundle expands. Unknown names and an empty filter fail when the first session is created. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport hooks; production uses process stdio and `process.exit`.
+`maxTokensAsSuccess` defaults to `false` and affects only the deployment-mapped status on `subagent.finished`; root-session prompts have no prompt-level status. The profile composition owns each root agent's tools. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport hooks; production uses process stdio and `process.exit`.
 
 ## stdout is the protocol
 
@@ -30,7 +30,7 @@ The plugin answers `shutdown`, flushes the response, disposes the root context s
 
 #### What the model sees
 
-For each accepted `session/prompt`, text and durable content references enter one user message verbatim. Inline `SdkEncodedImageBlock` values are validated and committed through the composition's attachment store first, so the session log retains content-addressed image references rather than base64 bytes. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. A configured `toolFilter` projects that composition's global tool registry before the request is assembled and executed.
+For each accepted `session/prompt`, text and durable content references enter one user message verbatim. Inline `SdkEncodedImageBlock` values are validated and committed through the composition's attachment store first, so the session log retains content-addressed image references rather than base64 bytes. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition.
 
 #### Token effect
 

+ 2 - 2
packages/sdk/server/README.zh.md

@@ -10,7 +10,7 @@
 
 ## 配置
 
-`maxTokensAsSuccess` 默认为 `false`,且只影响 `subagent.finished` 上由部署映射的状态;根会话提示词没有提示词级状态。可选的 `toolFilter.allow` 与 `toolFilter.deny` 通过 `ctx.tools.restrict()` 限制每个由 SDK 创建的根 agent。Allow 列表会排除之后出现但未指名的全局工具,因此固定的 SDK 部署不会在基础 bundle 扩展时静默获得面向模型的新工具。未知名称与空筛选器会在创建首个会话时明确失败。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输钩子;生产环境使用进程 stdio 和 `process.exit`。
+`maxTokensAsSuccess` 默认为 `false`,且只影响 `subagent.finished` 上由部署映射的状态;根会话提示词没有提示词级状态。Profile 组合负责每个根 agent 的工具。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输钩子;生产环境使用进程 stdio 和 `process.exit`。
 
 ## stdout 即协议
 
@@ -30,7 +30,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写
 
 #### 模型看到的内容
 
-对于每个已接受的 `session/prompt`,文本和持久内容引用会原样进入一条用户消息。内联 `SdkEncodedImageBlock` 会先通过组合中的附件存储完成校验与提交,因此会话日志保留内容寻址的图片引用而不是 base64 字节。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。配置的 `toolFilter` 会在请求组装与执行前投影该组合的全局工具注册表。
+对于每个已接受的 `session/prompt`,文本和持久内容引用会原样进入一条用户消息。内联 `SdkEncodedImageBlock` 会先通过组合中的附件存储完成校验与提交,因此会话日志保留内容寻址的图片引用而不是 base64 字节。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。
 
 #### Token 影响
 

+ 0 - 13
packages/sdk/server/src/index.ts

@@ -25,13 +25,6 @@ export const inject = ['agents']
 export interface JsonRpcConfig {
   /** Report max-token turn/subagent termination as a successful SDK result. */
   maxTokensAsSuccess?: boolean
-  /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */
-  toolFilter?: {
-    /** Global tool names that remain visible. */
-    allow?: string[]
-    /** Global tool names removed from visibility. */
-    deny?: string[]
-  }
   /** Transport input override; production uses `process.stdin`. */
   input?: Readable
   /** Transport output override; production uses `process.stdout`. */
@@ -42,11 +35,6 @@ export interface JsonRpcConfig {
 
 export const Config: Schema<JsonRpcConfig> = Schema.object({
   maxTokensAsSuccess: Schema.boolean().default(false),
-  // Preserve omission; Schemastery's materialized empty object is not a valid restriction.
-  toolFilter: Schema.object({
-    allow: Schema.array(Schema.string()).default(undefined as unknown as string[]),
-    deny: Schema.array(Schema.string()).default(undefined as unknown as string[]),
-  }).default(undefined as unknown as { allow: string[]; deny: string[] }),
 })
 
 /**
@@ -71,7 +59,6 @@ export function apply(ctx: Context, config: JsonRpcConfig): void {
   const transport = new JsonRpcLineTransport(input, output)
   const server = new HarnessSdkJsonRpcServer(ctx, transport, {
     maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess,
-    ...resolvedConfig.toolFilter === undefined ? {} : { toolFilter: resolvedConfig.toolFilter },
   })
 
   // Share one exit task so racing shutdown requests cannot dispose the root or

+ 0 - 7
packages/sdk/server/src/server.ts

@@ -15,7 +15,6 @@ import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
 import { SessionId } from '@deepseek-ai/dsh-session'
 import type SubagentRuntime from '@deepseek-ai/dsh-subagent'
 import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
-import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
 import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
 import type {
   InitializeParams,
@@ -61,8 +60,6 @@ function subagentParentOf(carrier: Scoped<SubagentRuntime>): Agent {
 export interface HarnessSdkJsonRpcServerOptions {
   /** Report max-token termination as an accepted result instead of an infrastructure error. */
   maxTokensAsSuccess?: boolean
-  /** Restrict each SDK-created root agent to an explicit subset of global tools. */
-  toolFilter?: ToolRestriction
 }
 
 function successStatus(reason: string, options: HarnessSdkJsonRpcServerOptions): 'ok' | 'error' {
@@ -256,7 +253,6 @@ export class HarnessSdkJsonRpcServer {
     // rows in the host plane, so this agent reads them from the global layer. A
     // deployment that configures a roster has to join one here first
     // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
-    const toolFilter = this.options.toolFilter
     const handle = await this.ctx.agents.create({
       sessionId: SessionId(sessionId),
       meta: { cwd: this.cwd },
@@ -265,9 +261,6 @@ export class HarnessSdkJsonRpcServer {
         model: this.model,
         ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
       },
-      ...toolFilter === undefined
-        ? {}
-        : { setup: (agentCtx: Context) => { agentCtx.tools.restrict(toolFilter) } },
     })
     const rec: SessionRecord = { handle }
     this.sessions.set(sessionId, rec)

+ 0 - 48
packages/sdk/server/tests/plugin-apply.spec.ts

@@ -11,7 +11,6 @@ import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
 import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
 import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
-import { defineTool } from '@deepseek-ai/dsh-tools'
 import * as jsonrpc from '../src/index.ts'
 
 /**
@@ -72,7 +71,6 @@ async function mountPlugin(
     writeDelayMs?: number
     failFlush?: boolean
     beforeServer?: (ctx: Context) => Promise<void> | void
-    toolFilter?: jsonrpc.JsonRpcConfig['toolFilter']
   } = {},
 ): Promise<ApplyHarness> {
   const ctx = new Context()
@@ -122,7 +120,6 @@ async function mountPlugin(
     input,
     output,
     exit,
-    ...options.toolFilter === undefined ? {} : { toolFilter: options.toolFilter },
   })
 
   const frames = (): Record<string, unknown>[] =>
@@ -289,51 +286,6 @@ describe('dsh-sdk-jsonrpc-server plugin apply', () => {
     }
   })
 
-  it('applies the configured root-agent tool filter through the Loader plugin', async () => {
-    const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-tool-filter-'))
-    const llmServer = await mockCompletionServer()
-    vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
-    vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
-    const harness = await mountPlugin(storageDir, {
-      toolFilter: { allow: ['kept'] },
-      beforeServer: (ctx) => {
-        for (const name of ['kept', 'excluded']) {
-          ctx.tools.register(defineTool({
-            name,
-            description: name,
-            parameters: {},
-            output: {
-              schema: { type: 'string' },
-              render: (_args, value) => [{ type: 'text', text: value }],
-            },
-            execute: async () => name,
-          }))
-        }
-      },
-    })
-    try {
-      harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'filtered-model' } })
-      await harness.waitForFrame(frame => frame.id === 1, 'initialize response')
-      harness.send({
-        jsonrpc: '2.0',
-        id: 2,
-        method: 'session/prompt',
-        params: { sessionId: 'filtered', contentBlocks: [{ type: 'text', text: 'inspect tools' }] },
-      })
-      await harness.waitForFrame(
-        frame => frame.method === 'session.status'
-          && (frame.params as { status?: string } | undefined)?.status === 'idle',
-        'filtered session idle status',
-      )
-
-      const request = llmServer.requests[0] as { tools?: Array<{ function?: { name?: string } }> }
-      expect(request.tools?.map(entry => entry.function?.name)).toEqual(['kept'])
-    } finally {
-      await harness.dispose()
-      await rm(storageDir, { recursive: true, force: true })
-    }
-  })
-
   it('answers shutdown before exiting 0 exactly once, even against a racing second shutdown', async () => {
     const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-shutdown-'))
     const harness = await mountPlugin(storageDir, { writeDelayMs: 10 })

+ 0 - 41
packages/sdk/server/tests/server.spec.ts

@@ -14,7 +14,6 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
 import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
 import SubagentRuntime, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
 import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol'
-import { defineTool } from '@deepseek-ai/dsh-tools'
 import { HarnessSdkJsonRpcServer } from '../src/index.ts'
 
 class FakeTransport implements JsonRpcTransportPeer {
@@ -172,46 +171,6 @@ describe('HarnessSdkJsonRpcServer', () => {
     }
   })
 
-  it('allowlists each root session against current and later global tools', { timeout: 15_000 }, async () => {
-    const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-tool-filter-'))
-    const llmServer = await mockCompletionServer()
-    vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
-    vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
-    const ctx = await makeHarness(storageDir)
-    const tool = (name: string) => defineTool({
-      name,
-      description: name,
-      parameters: {},
-      output: {
-        schema: { type: 'string' as const },
-        render: (_args, value) => [{ type: 'text' as const, text: value }],
-      },
-      execute: async () => name,
-    })
-    ctx.tools.register(tool('kept'))
-    ctx.tools.register(tool('excluded'))
-    const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport(), {
-      toolFilter: { allow: ['kept'] },
-    })
-    try {
-      await server.initialize({ cwd: storageDir, provider: 'deepseek-official', model: 'filtered-model' })
-      await server.prompt({ sessionId: 'first', contentBlocks: [{ type: 'text', text: 'first' }] })
-      await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) })
-      ctx.tools.register(tool('future'))
-      await server.prompt({ sessionId: 'second', contentBlocks: [{ type: 'text', text: 'second' }] })
-      await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(2) })
-
-      expect(llmServer.requests.map((request) => {
-        const tools = (request as { tools?: Array<{ function?: { name?: string } }> }).tools ?? []
-        return tools.map(entry => entry.function?.name)
-      })).toEqual([['kept'], ['kept']])
-      await server.shutdown()
-    } finally {
-      await ctx.fiber.dispose()
-      await rm(storageDir, { recursive: true, force: true })
-    }
-  })
-
   it('queues overlapping prompts for one session without blocking other sessions', async () => {
     const mainFollowup = vi.fn<Agent['followup']>()
     const mainAgent = ({

+ 6 - 3
pnpm-lock.yaml

@@ -1835,6 +1835,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-client-ui-trajectory':
+        specifier: workspace:^
+        version: link:../ui-trajectory
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../runtime-diagnostics/invariants
@@ -1961,9 +1964,6 @@ importers:
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         version: link:../../core/tools
-      '@deepseek-ai/dsh-util-crypto':
-        specifier: workspace:^
-        version: link:../../util/crypto
       '@deepseek-ai/dsh-util-workspace-path':
         specifier: workspace:^
         version: link:../../util/workspace-path
@@ -3379,6 +3379,9 @@ importers:
       '@deepseek-ai/dsh-api-session-controller':
         specifier: workspace:^
         version: link:../../api/session-controller
+      '@deepseek-ai/dsh-attachment':
+        specifier: workspace:^
+        version: link:../../attachment/attachment
       '@deepseek-ai/dsh-client-locale':
         specifier: workspace:^
         version: link:../locale

+ 1 - 2
python/sdk/src/deepseek_harness/client.py

@@ -34,7 +34,6 @@ class HarnessConfig:
     initialize_timeout_seconds: float = 30.0
     request_timeout_seconds: float | None = None
     shutdown_timeout_seconds: float | None = 1.0
-    _launch_args: tuple[str, ...] | None = None
 
 
 class HarnessClient:
@@ -47,7 +46,7 @@ class HarnessClient:
         _launch_args: tuple[str, ...] | None = None,
     ) -> None:
         self.config = config or HarnessConfig()
-        self._launch_args = _launch_args or self.config._launch_args
+        self._launch_args = _launch_args
         self._proc: subprocess.Popen[str] | None = None
         self._lock = threading.Lock()
         self._write_lock = threading.Lock()

+ 18 - 23
python/sdk/tests/test_client.py

@@ -471,9 +471,7 @@ for line in sys.stdin:
 """.strip()
     )
 
-    with HarnessClient(
-        HarnessConfig(_launch_args=(sys.executable, str(script)))
-    ) as client:
+    with HarnessClient(_launch_args=(sys.executable, str(script))) as client:
         init = client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
         assert init.serverInfo.name == "fake-dsh"
 
@@ -611,7 +609,7 @@ for line in sys.stdin:
     def broken_filter(_notification: object) -> bool:
         raise RuntimeError("bad notification filter")
 
-    with HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) as client:
+    with HarnessClient(_launch_args=(sys.executable, str(script))) as client:
         client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
         with (
             client.subscribe_notifications(broken_filter) as broken,
@@ -648,7 +646,7 @@ for line in sys.stdin:
 """.strip()
     )
 
-    with HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) as client:
+    with HarnessClient(_launch_args=(sys.executable, str(script))) as client:
         client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
         with pytest.raises(ValueError):
             client.session_prompt("main", [{"type": "text", "text": "fix it"}])
@@ -675,9 +673,7 @@ for line in sys.stdin:
 """.strip()
     )
 
-    with HarnessClient(
-        HarnessConfig(_launch_args=(sys.executable, str(script)))
-    ) as client:
+    with HarnessClient(_launch_args=(sys.executable, str(script))) as client:
         client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
 
         request = client.next_request()
@@ -709,9 +705,7 @@ for line in sys.stdin:
 """.strip()
     )
 
-    with HarnessClient(
-        HarnessConfig(_launch_args=(sys.executable, str(script)))
-    ) as client:
+    with HarnessClient(_launch_args=(sys.executable, str(script))) as client:
         init = client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
         assert init.serverInfo.name == "fake-dsh"
 
@@ -730,10 +724,10 @@ time.sleep(60)
 
     with HarnessClient(
         HarnessConfig(
-            _launch_args=(sys.executable, str(script)),
             profile="web",
             initialize_timeout_seconds=0.1,
-        )
+        ),
+        _launch_args=(sys.executable, str(script)),
     ) as client:
         start = time.monotonic()
         try:
@@ -768,9 +762,9 @@ for line in sys.stdin:
 
     client = HarnessClient(
         HarnessConfig(
-            _launch_args=(sys.executable, str(script)),
             shutdown_timeout_seconds=0.1,
-        )
+        ),
+        _launch_args=(sys.executable, str(script)),
     )
     client.start()
     proc = client._proc
@@ -808,10 +802,10 @@ Path(os.environ["QUIESCED_MARKER"]).write_text("quiesced")
 
     client = HarnessClient(
         HarnessConfig(
-            _launch_args=(sys.executable, str(script)),
             env={"QUIESCED_MARKER": str(marker)},
             shutdown_timeout_seconds=1,
-        )
+        ),
+        _launch_args=(sys.executable, str(script)),
     )
     client.start()
     client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
@@ -838,7 +832,7 @@ for line in sys.stdin:
 """.strip()
     )
 
-    client = HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script))))
+    client = HarnessClient(_launch_args=(sys.executable, str(script)))
     client.start()
     proc = client._proc
     assert proc is not None
@@ -878,6 +872,7 @@ def test_public_signatures_omit_unsupported_wire_parameters() -> None:
     for removed in ("cordis", "session_root", "runtime_bin", "bridge_bin", "launch_args_override"):
         assert removed not in DeepSeekHarnessConfig.__dataclass_fields__
         assert removed not in HarnessConfig.__dataclass_fields__
+    assert "_launch_args" not in HarnessConfig.__dataclass_fields__
     assert "session_root" not in RunResult.__dataclass_fields__
 
 
@@ -900,7 +895,7 @@ for line in sys.stdin:
 """.strip()
     )
 
-    client = HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script))))
+    client = HarnessClient(_launch_args=(sys.executable, str(script)))
     client.start()
     client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
     client.close()
@@ -920,9 +915,9 @@ sys.exit(42)
 
     with HarnessClient(
         HarnessConfig(
-            _launch_args=(sys.executable, str(script)),
             request_timeout_seconds=2,
-        )
+        ),
+        _launch_args=(sys.executable, str(script)),
     ) as client:
         with pytest.raises(Exception, match="fatal bridge exploded"):
             client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
@@ -952,9 +947,9 @@ with open(os.environ["SEEN"], "w") as seen:
 
     with HarnessClient(
         HarnessConfig(
-            _launch_args=(sys.executable, str(script)),
             env={"SEEN": str(output)},
-        )
+        ),
+        _launch_args=(sys.executable, str(script)),
     ) as client:
         client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
         threads = [

+ 33 - 78
scripts/smoke-python-runtime.py

@@ -221,6 +221,36 @@ def write_profile_patch(
     return path
 
 
+def write_advanced_profile_patch(root: Path, name: str, sessions: Path) -> Path:
+    """Write the shared custom, snapshot, and restart profile patch."""
+    return write_profile_patch(root, name, sessions, [
+        {"id": "tools", "config": {"mode": "both"}},
+        {
+            "id": "system-prompt",
+            "config": {
+                "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.",
+            },
+        },
+        {"id": "session-log-deepseek", "config": {"enabled": True}},
+        *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS),
+        {"id": "tool-bash", "disabled": True},
+        {"id": "tool-pwsh", "disabled": True},
+        {
+            "id": "tool-subagent",
+            "config": {
+                "provider": "spawn",
+                "toolName": "subagent",
+                "backgroundMode": "one-shot",
+            },
+        },
+        {"insert": [
+            {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"},
+            {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"},
+            {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"},
+        ]},
+    ])
+
+
 def write_mcp_patch(root: Path, sessions: Path, server_script: Path) -> Path:
     """Write a profile patch that mounts the packaged MCP client."""
     return write_profile_patch(root, "mcp.patch.yml", sessions, [{
@@ -923,32 +953,7 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None:
         root = Path(temporary).resolve()
         dsh_home = root / "home"
         sessions = dsh_home / "sessions"
-        patch = write_profile_patch(root, "custom.patch.yml", sessions, [
-            {"id": "tools", "config": {"mode": "both"}},
-            {
-                "id": "system-prompt",
-                "config": {
-                    "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.",
-                },
-            },
-            {"id": "session-log-deepseek", "config": {"enabled": True}},
-            *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS),
-            {"id": "tool-bash", "disabled": True},
-            {"id": "tool-pwsh", "disabled": True},
-            {
-                "id": "tool-subagent",
-                "config": {
-                    "provider": "spawn",
-                    "toolName": "subagent",
-                    "backgroundMode": "one-shot",
-                },
-            },
-            {"insert": [
-                {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"},
-                {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"},
-                {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"},
-            ]},
-        ])
+        patch = write_advanced_profile_patch(root, "custom.patch.yml", sessions)
         with DeepSeekHarness(
             provider="deepseek-official",
             model="smoke-model",
@@ -1173,32 +1178,7 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
         root = Path(temporary).resolve()
         dsh_home = root / "home"
         sessions = dsh_home / "sessions"
-        patch = write_profile_patch(root, "snapshot.patch.yml", sessions, [
-            {"id": "tools", "config": {"mode": "both"}},
-            {
-                "id": "system-prompt",
-                "config": {
-                    "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.",
-                },
-            },
-            {"id": "session-log-deepseek", "config": {"enabled": True}},
-            *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS),
-            {"id": "tool-bash", "disabled": True},
-            {"id": "tool-pwsh", "disabled": True},
-            {
-                "id": "tool-subagent",
-                "config": {
-                    "provider": "spawn",
-                    "toolName": "subagent",
-                    "backgroundMode": "one-shot",
-                },
-            },
-            {"insert": [
-                {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"},
-                {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"},
-                {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"},
-            ]},
-        ])
+        patch = write_advanced_profile_patch(root, "snapshot.patch.yml", sessions)
         with DeepSeekHarness(
             provider="deepseek-official",
             model="smoke-model",
@@ -1247,32 +1227,7 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots
         root = Path(temporary).resolve()
         dsh_home = root / "home"
         sessions = dsh_home / "sessions"
-        patch = write_profile_patch(root, "restart.patch.yml", sessions, [
-            {"id": "tools", "config": {"mode": "both"}},
-            {
-                "id": "system-prompt",
-                "config": {
-                    "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.",
-                },
-            },
-            {"id": "session-log-deepseek", "config": {"enabled": True}},
-            *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS),
-            {"id": "tool-bash", "disabled": True},
-            {"id": "tool-pwsh", "disabled": True},
-            {
-                "id": "tool-subagent",
-                "config": {
-                    "provider": "spawn",
-                    "toolName": "subagent",
-                    "backgroundMode": "one-shot",
-                },
-            },
-            {"insert": [
-                {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"},
-                {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"},
-                {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"},
-            ]},
-        ])
+        patch = write_advanced_profile_patch(root, "restart.patch.yml", sessions)
         first_request = len(MockModelHandler.requests)
 
         def run(prompt: str, session_id: str) -> "RunResult":

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