ソースを参照

feat(ui-tool): render read_image results as the image

A settled top-level `read_image` call printed its raw attachment object as
literal text in the tool card — `{"type":"image","attachment":{…}}` —
instead of the image, because no presentation metadata told a client card
how to present the reference and the tool-card layer had no image concept.

Host: `read_image` declares an `output.presentationMeta` persisting
`{ path }` only. The attachment reference deliberately lives in the
settled result content — the single record a `tools/post-execute`
replacement rewrites — not in `meta`; the id is opaque and
provider-owned, checked for existence only.

Client: `imageCardModel` derives the card from the call head, the meta
path, the result's own image block, and a shape-matched envelope. ToolRow
gains an `image` card slot; the `read_image` toolview declares the
Tool-owned `tool.call.images` slot as its child and dispatches the
gallery through it. ui-chat down-threads the session-authorized loader
(`ChatNodeOwnerProps.loadImage`), so the tool layer supplies only derived
references plus the loader and never imports an attachment
implementation; ui-attachment fills the slot with its message gallery
renderer. The card keeps the envelope text below the gallery for the
no-attachment-plugin deployment. An image-bearing tool registers a keyed
toolview; the generic fallback keeps its flattened text. `read_image`
joins the read variant with its own locale title key; both rows share
`read-family-row.tsx`.

Verification: `read-image.spec.ts` (metadata projection, envelope by
shape, reference narrowing, real-execution round trip, rejection
branches incl. non-digest ids), `image-card.client.spec.tsx` (derivation,
row render site dispatching the slot, keyed registration with the
child-slot declaration, empty-slot fallbacks, media-type enum), keyless
snapshots (`read-image-gif` added; read-image/-dimension/-reencode
updated to the `{path}` meta), five injected-defect negative controls,
and a demo GIF recorded from this PR's head through the official
image-capable model.
Chinesezjc 3 週間 前
コミット
a4d4404708
62 ファイル変更1354 行追加125 行削除
  1. 2 2
      .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml
  2. 1 1
      .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md
  3. 1 1
      .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-08-20-tool-card-image-results.i18n.yaml
  5. 57 0
      .agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md
  6. 57 0
      .agents/notes/implemented/feature/2026-08-20-tool-card-image-results.zh.md
  7. 6 0
      .agents/notes/proposed/process/2026-08-27-port-tool-owned-render.i18n.yaml
  8. 35 0
      .agents/notes/proposed/process/2026-08-27-port-tool-owned-render.md
  9. 35 0
      .agents/notes/proposed/process/2026-08-27-port-tool-owned-render.zh.md
  10. 2 2
      docs/subsystems/slots.i18n.yaml
  11. 1 0
      docs/subsystems/slots.md
  12. 1 0
      docs/subsystems/slots.zh.md
  13. 2 2
      packages/client/ui-attachment/README.i18n.yaml
  14. 3 3
      packages/client/ui-attachment/README.md
  15. 3 3
      packages/client/ui-attachment/README.zh.md
  16. 4 2
      packages/client/ui-attachment/package.json
  17. 8 1
      packages/client/ui-attachment/src/client/index.ts
  18. 6 0
      packages/client/ui-attachment/tests/plugin.client.spec.ts
  19. 3 0
      packages/client/ui-attachment/tsconfig.json
  20. 3 2
      packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx
  21. 1 0
      packages/client/ui-chat/src/client/chat/ChatView.tsx
  22. 7 0
      packages/client/ui-chat/src/client/contract/slots.ts
  23. 2 0
      packages/client/ui-conversation/src/client/locales.ts
  24. 2 2
      packages/client/ui-tool/README.i18n.yaml
  25. 3 3
      packages/client/ui-tool/README.md
  26. 3 3
      packages/client/ui-tool/README.zh.md
  27. 2 1
      packages/client/ui-tool/package.json
  28. 2 0
      packages/client/ui-tool/src/client/apply.ts
  29. 30 1
      packages/client/ui-tool/src/client/contract/slots.ts
  30. 10 6
      packages/client/ui-tool/src/client/tool/ToolCallTree.tsx
  31. 23 0
      packages/client/ui-tool/src/client/tool/components/ToolRow.module.css
  32. 88 45
      packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
  33. 232 0
      packages/client/ui-tool/src/client/tool/models/image-card-model.ts
  34. 6 0
      packages/client/ui-tool/src/client/tool/models/tool-call-model.ts
  35. 59 0
      packages/client/ui-tool/src/client/tool/toolviews/read-family-row.tsx
  36. 61 0
      packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx
  37. 4 24
      packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx
  38. 1 0
      packages/client/ui-tool/tests/coverage-tails.client.spec.tsx
  39. 3 1
      packages/client/ui-tool/tests/diff-card.client.spec.tsx
  40. 371 0
      packages/client/ui-tool/tests/image-card.client.spec.tsx
  41. 2 1
      packages/client/ui-tool/tests/read-card.client.spec.tsx
  42. 1 0
      packages/client/ui-tool/tests/search-card.client.spec.tsx
  43. 1 0
      packages/client/ui-tool/tests/terminal-card.client.spec.tsx
  44. 1 0
      packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx
  45. 1 0
      packages/client/ui-tool/tests/tool-row.client.spec.tsx
  46. 1 1
      packages/client/ui-tool/tests/web-card.client.spec.tsx
  47. 3 0
      packages/client/ui-tool/tsconfig.json
  48. 1 0
      packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx
  49. 17 1
      packages/experimental/webworker-packer/tests/image-loadable.spec.ts
  50. 54 10
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  51. 2 2
      packages/fs/tool-fs/README.i18n.yaml
  52. 1 1
      packages/fs/tool-fs/README.md
  53. 1 1
      packages/fs/tool-fs/README.zh.md
  54. 9 0
      packages/fs/tool-fs/src/read-image.ts
  55. 68 0
      packages/fs/tool-fs/tests/read-image.spec.ts
  56. 6 0
      pnpm-lock.yaml
  57. 1 1
      snapshots/session/read-image-dimension/session.jsonl
  58. 29 0
      snapshots/session/read-image-gif/session.jsonl
  59. 7 0
      snapshots/session/read-image-gif/snapshot.yml
  60. BIN
      snapshots/session/read-image-gif/workspace/red.gif
  61. 1 1
      snapshots/session/read-image-reencode/session.jsonl
  62. 1 1
      snapshots/session/read-image/session.jsonl

+ 2 - 2
.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.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-10-minimal-read-image-tool.md
-2026-08-10-minimal-read-image-tool.md: 8880032b2648846df679ea8fa3301d182a95c06b
-2026-08-10-minimal-read-image-tool.zh.md: aec34e19fc58037b031f7d4116d2fa664b2b45b5
+2026-08-10-minimal-read-image-tool.md: 67a738e71901fb251b8e9e167d52bac4f2f3720a
+2026-08-10-minimal-read-image-tool.zh.md: 7a468372542a45088946bdaac7f1869c7a1909ee

+ 1 - 1
.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md

@@ -29,4 +29,4 @@ Both image-reading operations live in `dsh-tool-fs` and publish ordinary logged
 
 - The tools refuse execution on a text-only route, while existing images in session history are represented by request-local placeholders.
 - Repeated image results accumulate request cost until request projection or compaction removes them; content addressing deduplicates durable bytes.
-- The tool-result card renders the durable reference, not pixels; inline preview is deferred to the UI packages.
+- The tool-result card now renders the image itself through the browser's `tool.call.images` slot (see [the tool-card image results note](2026-08-20-tool-card-image-results.md)); a UI without the attachment presentation plugin shows the result's envelope text.

+ 1 - 1
.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md

@@ -29,4 +29,4 @@ Status: implemented
 
 - 工具在纯文本路由上拒绝执行,而会话历史中已经存在的图片会由请求期占位符表示。
 - 重复的图片结果会累积请求成本,直到请求投影或压缩将其移除;内容寻址只去重持久字节。
-- 工具结果卡片渲染持久引用而非像素;内嵌预览延后到 UI 包处理
+- 工具结果卡片现在经由浏览器的 `tool.call.images` 槽位渲染图像本身(见 [tool-card image results 笔记](2026-08-20-tool-card-image-results.zh.md));未组合附件呈现插件的 UI 显示结果的信封文本

+ 6 - 0
.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.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-20-tool-card-image-results.md
+2026-08-20-tool-card-image-results.md: c2f069a6c8eb6d7be48c80fca846bcb2c2be1dd3
+2026-08-20-tool-card-image-results.zh.md: 018a4f0e20f254285189965102fbb4390c160e1c

+ 57 - 0
.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md

@@ -0,0 +1,57 @@
+# Agent Note: Tool-card image results
+
+Status: implemented
+
+English | [中文](2026-08-20-tool-card-image-results.zh.md)
+
+## Problem
+
+A settled `read_image` call rendered its raw attachment object as literal text in the tool card — `{"type":"image","attachment":{"attachmentId":"sha256:…","mediaType":"image/png","bytes":24588,"width":1496,…}}` — instead of showing the image.
+
+Two independent gaps produced that. `read_image` declared no `output.presentationMeta`, so no presentation metadata told a client card how to present the reference — the tool card printed the raw result content as text. Separately, the tool-card layer had no image concept: `packages/client/ui-tool/src` contained no occurrence of `image` or `attachment`, and `ToolRow`'s card slots were terminal, diff, read, search, and web.
+
+The rendering capability already existed, but only on the message path. `MessageImages` draws durable image groups for user and assistant history through the `conversation.message.images` slot. That asymmetry explains a confusing observation: a **nested** `read_image` displayed correctly, because `execute` defers a real user message for a nested call, while a top-level call — which returns the image only as tool-result content — did not.
+
+## Decision
+
+**Host.** `read_image` gains an `output.presentationMeta` that persists `{ path }` — the path only.
+
+The attachment reference is deliberately not persisted there. The settled `content` already carries the image block with the complete reference, and that block is what a `tools/post-execute` hook replaces when it legitimately rewrites a result. A second copy in `meta` would therefore be a duplicate record of one fact, and a stale one exactly when the content changed — the card would keep showing an image the result no longer returns. The path is the one fact the content does not carry as a structured field: the model-facing envelope embeds the backend-resolved path as text, and the client never parses that text.
+
+No `presentResult`, and no new member of the closed `ToolResultView` union. Client cards derive from raw event fields, and host `presentCall`/`presentResult` values never enter the client ([ui-tool README](../../../../packages/client/ui-tool/README.md)), so a result-view arm would have been a public type extension with no consumer.
+
+**Client.** `imageCardModel` derives the card the way every other first-party card does: `parsedToolCall` validates the call head and its `file_path`, `block.meta` supplies the path, the attachment reference is narrowed out of the result's own image block, and the envelope is located in the same content. It matches its own envelope by shape rather than using `singleResultText`, because that helper accepts only a lone text block while an image read returns `[text envelope, image block]` — matching by shape also means content another layer prepended is never mistaken for the envelope.
+
+The narrowing checks the attachment id for existence only. The id is opaque and provider-owned: the local store mints content addresses, but consumers must neither parse that representation nor assume its shape, and a provider may change it without notice. Pattern-matching the local form would reject a legitimate id from an alternative store and silently degrade every image card in that deployment.
+
+`ToolRow` gains an `image` card slot, and `read_image` gets a keyed toolview that declares the Tool-owned `tool.call.images` slot as its child and renders the gallery through it. The tool layer never loads or authorizes anything: the row supplies only the references it derived from the result plus the `loadImage` loader the chat node now passes down (`ChatNodeOwnerProps.loadImage`), and the attachment presentation plugin fills the slot with the same gallery it uses for message images. An image-bearing tool therefore registers a keyed toolview (`read_image` is the template for the row assembly and card model; the `tool.call.images` child declaration is not reusable verbatim, because a slot is declared by exactly one entry); the generic fallback keeps its flattened text.
+
+The card keeps the derived envelope text below the gallery. That is not redundancy: `tool.call.images` renders nothing in a deployment without the attachment presentation plugin, and that empty gallery must not leave a blank card — measured, not assumed: a slot returning `null` rendered an empty container with no visible text.
+
+`read_image` joins the `read` variant and gets its own locale title key. Left unclassified it fell to `others`, which titles the row generically and derives no `filePath` (only read/write/edit variants do), so the openable path the row advertises would never have been openable.
+
+`read` and `read_image` are the same single-file card row with different card material, so their shared assembly lives in `read-family-row.tsx` rather than being copied.
+
+## Alternatives considered
+
+- **Add `card: 'image'` arm to `ToolResultView` and a `presentResult`.** This is what the first version did. Client cards derive from raw events and host presentation values never reach the client, so the arm had no consumer — an extension of a closed public union that nothing read. Dropped in favour of `presentationMeta` alone.
+- **Pass a rendering closure down (the `renderMessageImages` pattern).** The next version reused `ChatNodeOwnerProps.renderMessageImages` as a `renderImages` owner prop, mirroring what `AssistantMarkdown` and the message rows do. Review rejected it: the client rule forbids new ReactNode-valued owner props, and the compliant shape is a slot the tool layer itself declares. With `loadImage` down-threaded from the chat node, the row renders `tool.call.images` directly and no rendering capability crosses the owner boundary.
+- **Render the image on the generic fallback too.** The slot design cannot: a slot is declared by exactly one entry, and the fallback component is not a registered entry, so it has no dispatch seat for a child it did not declare. The keyed row is the only image render site; future image tools register their own.
+- **Use `singleResultText` like the read card.** It accepts only a lone text block by design, and an image read returns two, so the card matches its own envelope shape instead.
+- **Persist the reference in `meta` as well.** The first version did, and it read the card from there. Review pointed out the duplication, and the recorded log confirmed it: `meta.image` and the content block's `attachment` were byte-identical. Reading from the content instead leaves one record and follows a post-execute replacement.
+- **Validate the attachment id against `sha256:<hex>`.** Tried, then reverted: it contradicts the documented opacity of `AttachmentId` and would break any deployment whose store mints another shape.
+- **Give the image card its own primitive in `ui-primitives`.** Rejected as duplication — the message gallery's fit rules, crop anchors, and lightbox are the behavior a card needs.
+
+## Verification
+
+`read-image.spec.ts` covers the metadata projection, the omitted display name, and a real execution whose persisted reference matches what the attachment store committed. `image-card.client.spec.tsx` covers the derivation from metadata and envelope, path relativization, opaque ids from alternative stores, every rejection branch of the defensive narrowing, the running/error/nested declines, the keyed row render site dispatching `tool.call.images` with the loader, keyed registration with the child-slot declaration, and the empty-slot fallback.
+
+Negative controls were run against each assertion group before it was kept: removing the variant classification, disabling the image render branch, mistyping the registrant key, restoring the `sha256:` id pattern, and pointing the card's text back at the row's flattened output each turned the intended assertion red.
+
+## Consequences
+
+A top-level `read_image` now renders as the image, matching what a nested call already did, and the tool card gains an image kind. The image kind is not automatic from the metadata alone: the card also requires the `tool.call.images` slot to be filled (the attachment presentation plugin) and a keyed toolview for the tool, because the model narrows the call head to `read_image` and the slot is rendered from a declared child entry.
+
+The persisted presentation metadata adds one small `{ path }` record per image read to the session log. The attachment reference is not in the log as metadata at all — it lives in the settled result content's image block — and the image bytes themselves are never logged, because the store is content-addressed and the block carries only the attachment id.
+
+Because the card derives from `block.meta` plus the settled content, a session logged before this change carries no image metadata and replays as the generic text card. That is the documented fallback for every raw-event-derived card, not a special case here.

+ 57 - 0
.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.zh.md

@@ -0,0 +1,57 @@
+# Agent Note:工具卡片的图像结果
+
+Status: implemented
+
+[English](2026-08-20-tool-card-image-results.md) | 中文
+
+## 问题
+
+已结算的 `read_image` 调用在工具卡片里把原始附件对象当字面文本渲染出来——`{"type":"image","attachment":{"attachmentId":"sha256:…","mediaType":"image/png","bytes":24588,"width":1496,…}}`——而不是显示图像本身。
+
+这由两个彼此独立的缺口造成。`read_image` 没有声明 `output.presentationMeta`,因此没有任何呈现元数据告诉客户端卡片如何展示引用——工具卡片把原始结果内容当作文本打了出来。另一方面,工具卡片层完全没有图像概念:`packages/client/ui-tool/src` 中 `image` 和 `attachment` 一次都没出现,而 `ToolRow` 的卡片槽只有 terminal、diff、read、search、web。
+
+渲染能力其实已经存在,但只接在消息路径上。`MessageImages` 通过 `conversation.message.images` 槽位为用户与助手历史绘制持久图像组。这个不对称解释了一个容易困惑的现象:**嵌套的** `read_image` 能正确显示,因为嵌套调用时 `execute` 会 defer 一条真正的用户消息;而顶层调用只把图像作为工具结果内容返回,就不显示。
+
+## 决定
+
+**宿主侧。** `read_image` 获得只持久化 `{ path }` 的 `output.presentationMeta`——仅路径一项。
+
+附件引用有意不写在那里。已结算的 `content` 本身就带着含完整引用的 image 块,而当 `tools/post-execute` 钩子合法重写结果时,被替换的正是那个块。因此在 `meta` 里再存一份就是同一事实的重复记录,且恰恰在内容变化时变成过期副本——卡片会继续显示结果已不再返回的图像。路径是 content 唯一不作为结构化字段携带的事实:面向模型的信封把后端解析出的路径写成文本,而客户端从不解析那段文本。
+
+不加 `presentResult`,也不给封闭的 `ToolResultView` 联合新增成员。客户端卡片从原始 event 字段派生,宿主的 `presentCall`/`presentResult` 值永不进入客户端(见 [ui-tool README](../../../../packages/client/ui-tool/README.zh.md)),因此新增一个 result-view 分支等于扩展一个无人读取的封闭公共联合。
+
+**客户端侧。** `imageCardModel` 按其他所有第一方卡片的方式派生:`parsedToolCall` 校验调用头与其 `file_path`,`block.meta` 提供路径,附件引用从结果自己的 image 块中防御式 narrow 出来,信封在同一内容中定位。它按形状匹配自己的信封而不用 `singleResultText`,因为那个 helper 只接受单个文本块,而图像读取返回 `[text envelope, image block]`——按形状匹配同时意味着其他层前置的内容永远不会被误认为信封。
+
+该 narrowing 只检查附件 id 是否存在。id 是不透明且由提供方拥有的:本地存储铸造内容地址,但消费者既不得解析该表示、也不得假定其形状,且提供方可以不经通知改变它。按本地形式做模式匹配会拒绝替代存储铸造的合法 id,并让该部署中每个图像卡片静默降级。
+
+`ToolRow` 获得 `image` 卡片槽,`read_image` 获得按 key 注册的 toolview,并在其 registration 上把 Tool 自有的 `tool.call.images` 槽位声明为子槽,通过它渲染图库。工具层自己既不加载也不授权:这一行只提供从结果派生出的引用,以及聊天节点新下传的 `loadImage` loader(`ChatNodeOwnerProps.loadImage`),附件呈现插件用与消息图像相同的图库填充该槽位。因此携带图像的工具需要注册按 key 的 toolview(`read_image` 是行装配与 card model 的模板;`tool.call.images` 子槽声明不能逐字复用,因为一个槽位只能由一个 entry 声明);generic fallback 保留压平文本。
+
+卡片在图库下方保留派生出的信封文本。这不是冗余:在未组合附件呈现插件的部署里 `tool.call.images` 什么都不渲染,而空图库不能留下空白卡片——这是实测而非假设:用一个返回 `null` 的槽位探测,渲染出的是空容器,没有任何可见文本。
+
+`read_image` 归入 `read` variant 并获得自己的 locale 标题 key。不分类时它落到 `others`,标题变成通用文案且不派生 `filePath`(只有 read/write/edit variant 会派生),于是该行声称可点击打开的路径永远不可点击。
+
+`read` 与 `read_image` 是同一种单文件卡片行、只是卡片材料不同,因此它们共享的装配放在 `read-family-row.tsx` 而不是复制一份。
+
+## 考虑过的备选方案
+
+- **给 `ToolResultView` 加 `card: 'image'` 分支并实现 `presentResult`。** 第一版就是这么做的。客户端卡片从原始 event 派生、宿主呈现值不进入客户端,因此该分支没有消费方——等于扩展一个无人读取的封闭公共联合。改为只用 `presentationMeta`。
+- **向下传递渲染闭包(`renderMessageImages` 模式)。** 下一版复用了 `ChatNodeOwnerProps.renderMessageImages` 作为 `renderImages` owner prop,与 `AssistantMarkdown` 和消息行的做法一致。review 拒绝了它:客户端规则禁止新增 ReactNode-valued owner props,合规形态是工具层自己声明的槽位。把 `loadImage` 从聊天节点下传后,这一行直接渲染 `tool.call.images`,不再有任何渲染能力穿过 owner 边界。
+- **在 generic fallback 上也渲染图像。** 槽位设计做不到:一个槽位只能由一个 entry 声明,而 fallback 组件不是已注册 entry,没有为未声明的子槽提供 dispatch 席位。按 key 的行是唯一图像渲染点;将来的图像工具注册自己的行。
+- **像 read 卡片那样用 `singleResultText`。** 它按设计只接受单个文本块,而图像读取返回两块,因此卡片改为按自己的信封形状匹配。
+- **在 `meta` 里也持久化引用。** 第一版就是这么做的,卡片也从那里读取。review 指出了这处重复,实录日志也证实了:`meta.image` 与 content 中 image 块的 `attachment` 逐字节相同。改从 content 读取后只剩一份记录,并且会跟随 post-execute 的替换。
+- **按 `sha256:<hex>` 校验附件 id。** 试过后撤回:它与 `AttachmentId` 文档化的不透明性相矛盾,并会让任何采用其他 id 形状的部署失效。
+- **在 `ui-primitives` 里给图像卡片做专属 primitive。** 作为重复实现否决——消息图库的适配规则、裁剪锚点和灯箱正是卡片需要的行为。
+
+## 验证
+
+`read-image.spec.ts` 覆盖元数据投影、省略显示名,以及一次真实执行——其持久化的引用与附件存储实际提交的一致。`image-card.client.spec.tsx` 覆盖从元数据与信封的派生、路径相对化、来自替代存储的不透明 id、防御式 narrowing 的每个拒绝分支、running/error/嵌套三种拒绝、按 key 的行渲染点(携带 loader 分发 `tool.call.images`)、带子槽声明的按 key 注册,以及空槽位降级。
+
+每组断言在保留之前都跑过负例:移除 variant 分类、让图像渲染分支失效、把 registrant 的 key 打错、恢复 `sha256:` id 模式、把卡片文本指回行的压平输出——每一项都让目标断言变红。
+
+## 后果
+
+顶层 `read_image` 现在渲染为图像,与嵌套调用早已具备的行为一致;工具卡片获得一种图像种类。图像种类并不由元数据单独自动产生:卡片还要求 `tool.call.images` 槽位被填充(附件呈现插件),并且工具注册按 key 的 toolview——因为模型把调用头收窄到 `read_image`,槽位只能从声明的子 entry 渲染。
+
+持久化的呈现元数据为每次图像读取在会话日志中增加一条很小的 `{ path }` 记录。附件引用完全不在元数据里——它位于已结算结果 content 的 image 块中;图像字节本身从不进入日志,因为存储是内容寻址的,块里只携带附件 id。
+
+由于卡片从 `block.meta` 派生,本次改动之前记录的会话没有图像元数据,会以通用文本卡片重放。这是每个从原始 event 派生的卡片都遵循的既有降级路径,不是这里的特例。

+ 6 - 0
.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.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/proposed/process/2026-08-27-port-tool-owned-render.md
+2026-08-27-port-tool-owned-render.md: 4ab27142fdc2d13d7d152c21c2f246e340fb4711
+2026-08-27-port-tool-owned-render.zh.md: 9b5ece1a222551a308f6008e316fb4c251c36d88

+ 35 - 0
.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.md

@@ -0,0 +1,35 @@
+# Agent Note: Port tool-owned render into current DSH APIs
+
+Status: proposed
+
+English | [中文](2026-08-27-port-tool-owned-render.zh.md)
+
+## Problem
+
+The `dsh-tool-owned-render` prototype (`Chinesezjc/dsh-tool-owned-render`) ships tool-owned render registrants for `read`, `bash`, `write`/`edit`, `grep`/`glob`, and `web_search`/`web_fetch`, written against an older API where `ToolCallBlock` exposed `callView` / `resultView` and the client received host `presentResult` output. Current master derives client cards from raw `block.call` / `block.content` / `block.meta`, and `ctx.slots` requires the `@deepseek-ai/dsh-client-ui-renderer/client` module augmentation. A direct merge of the prototype does not typecheck, so its registrants cannot ship without a port.
+
+## Proposal
+
+- Add `packages/client/tool-owned-render` as a workspace package.
+- Port the `read`, `bash`, `write`/`edit`, `grep`/`glob`, and `web_search`/`web_fetch` registrants to derive from current `ToolCallBlock` fields.
+- Add a `read_image` registrant using the same ToolCard/Segment primitives.
+- Wire `ctx.slots` type augmentation through `dsh-client-ui-renderer`.
+- Keep PR #2828 mergeable while this port proceeds separately.
+
+## Alternatives considered
+
+- **Merge the prototype and fix its type errors in place** — rejected: every registrant would have to be re-derived from the current `ToolCallBlock` fields anyway, so the port is the same work with the obsolete `callView` / `resultView` contract already gone.
+- **Fold the port into PR #2828** — rejected: the image card is one feature with a defined scope, and a second package plus five more registrants would enlarge the review surface of an already large PR.
+
+## Acceptance criteria
+
+- `packages/client/tool-owned-render` exists as a workspace package.
+- The ported registrants derive card state from current `ToolCallBlock` fields and typecheck on master.
+- A `read_image` registrant renders through the same primitives as `read`.
+- The `ctx.slots` type augmentation resolves through `dsh-client-ui-renderer`.
+- PR #2828 merges independently of this port.
+
+## Risks
+
+- The port may not reproduce the prototype's exact visual output, because the current card primitives differ from the old `callView` / `resultView` contract.
+- API drift while the port proceeds can stale this proposal; the acceptance criteria are re-checked against master at port time.

+ 35 - 0
.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 把 tool-owned render 移植到当前 DSH API
+
+状态:proposed
+
+[English](2026-08-27-port-tool-owned-render.md) | 中文
+
+## 问题
+
+`dsh-tool-owned-render` 原型(`Chinesezjc/dsh-tool-owned-render`)带有 `read`、`bash`、`write`/`edit`、`grep`/`glob`、`web_search`/`web_fetch` 的 tool-owned render 注册项,基于旧 API 编写:`ToolCallBlock` 暴露 `callView` / `resultView`,客户端能拿到 host `presentResult` 输出。当前 master 从原始 `block.call` / `block.content` / `block.meta` 推导客户端卡片,`ctx.slots` 也需要 `@deepseek-ai/dsh-client-ui-renderer/client` 模块增强。直接合并原型不能通过类型检查,因此这些注册项不经移植无法发布。
+
+## 提案
+
+- 新增 `packages/client/tool-owned-render` workspace 包。
+- 把 `read`、`bash`、`write`/`edit`、`grep`/`glob`、`web_search`/`web_fetch` 注册项移植到从当前 `ToolCallBlock` 字段推导。
+- 增加 `read_image` 注册项,使用同一套 ToolCard/Segment 原语。
+- 通过 `dsh-client-ui-renderer` 接通 `ctx.slots` 类型增强。
+- 移植单独推进,保持 PR #2828 可合并。
+
+## 已考虑的替代方案
+
+- **直接合并原型并就地修复类型错误** — 否决:每个注册项反正都要按当前 `ToolCallBlock` 字段重新推导,移植就是同一份工作,只是旧的 `callView` / `resultView` 契约已不存在。
+- **把移植并入 PR #2828** — 否决:image 卡片是一个范围明确的单一功能,再加一个新包和五个注册项会扩大本已很大的 PR 的审查面。
+
+## 验收标准
+
+- `packages/client/tool-owned-render` 作为 workspace 包存在。
+- 移植后的注册项从当前 `ToolCallBlock` 字段推导卡片状态,并在 master 上通过类型检查。
+- `read_image` 注册项与 `read` 使用同一套原语渲染。
+- `ctx.slots` 类型增强通过 `dsh-client-ui-renderer` 解析。
+- PR #2828 独立于本移植合并。
+
+## 风险
+
+- 移植可能无法复现原型的精确视觉输出,因为当前卡片原语与旧的 `callView` / `resultView` 契约不同。
+- 移植推进期间 API 继续漂移会使本提案过时;验收标准在移植时按当时的 master 重新核对。

+ 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: 8e115e30aed68e543eca2f1aac6e28ad9f57cf73
-slots.zh.md: 3894b69d8d020b4bb67ce325d389ab3f20cfcc9a
+slots.md: a37374ff11f9d460a2566a7a30f8318e69cbfa0b
+slots.zh.md: 09329b1f299f4912490f888efa3146ec0ce11e28

+ 1 - 0
docs/subsystems/slots.md

@@ -135,6 +135,7 @@ root
 │  │     │  ├─ conversation.chat.commandview
 │  │     │  ├─ conversation.chat.turnTail
 │  │     │  └─ tool.call.toolview
+│  │     │     ├─ tool.call.images
 │  │     │     └─ tool.view.cordis
 │  │     ├─ conversation.message.images
 │  │     └─ conversation.trajectory.images

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

@@ -135,6 +135,7 @@ root
 │  │     │  ├─ conversation.chat.commandview
 │  │     │  ├─ conversation.chat.turnTail
 │  │     │  └─ tool.call.toolview
+│  │     │     ├─ tool.call.images
 │  │     │     └─ tool.view.cordis
 │  │     ├─ conversation.message.images
 │  │     └─ conversation.trajectory.images

+ 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: 9fa03432b43686dd55af494640708a8bf0983f9a
-README.zh.md: 48e467280bfb83b2f4341f0e4c833b0b44cdce18
+README.md: 47faee79e1e638927dfbff117e7ab8ea18360363
+README.zh.md: 483484e2bec057d422b03440a1386682d6bebb9f

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

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This package renders everything the conversation UI shows about attachments: pending draft images under the composer, a full-viewport drop invitation, durable images in Chat and Trajectory, and a lightbox for the original image. It is a pure presentation layer — attachment data, image loading, and callbacks come from the conversation package through declared slots. Choose it for the DeepSeek Chat-style image experience; non-image files have no surface here.
+This package renders everything the conversation UI shows about attachments: pending draft images under the composer, a full-viewport drop invitation, durable images in Chat, Trajectory, and Tool results, and a lightbox for the original image. It is a pure presentation layer — attachment data, image loading, and callbacks come from the conversation package through declared slots. Choose it for the DeepSeek Chat-style image experience; non-image files have no surface here.
 
 ## Table of Contents
 
@@ -25,7 +25,7 @@ This package renders everything the conversation UI shows about attachments: pen
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md); it waits for the conversation package's slot declarations and registers its surfaces into them. Users then see the draft-image rail with per-image remove and click-to-open, the drop overlay with its limits line, message images sized by count, and the Escape/mask/close lightbox.
+Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md) (and [`ui-tool`](../ui-tool/README.md) for the tool-result gallery); it waits for the conversation package's slot declarations and registers its surfaces into them. Users then see the draft-image rail with per-image remove and click-to-open, the drop overlay with its limits line, message images sized by count, the tool card's gallery, and the Escape/mask/close lightbox.
 
 ### Draft images
 
@@ -47,7 +47,7 @@ While a file drag is over the page, the full-viewport overlay announces the drop
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The plugin waits for `conversation.input.attachments`, `conversation.message.images`, and `conversation.trajectory.images` through `ctx.slots.inject`. It then registers the composer rail, document drop target, shared history gallery for Chat and Trajectory, and original-image lightbox. The presentation components are pure props: the conversation slot owner supplies attachment data, image loading, callbacks, and the locale translator; the package entry exports no components.
+The plugin waits for `conversation.input.attachments`, `conversation.message.images`, `conversation.trajectory.images`, and `tool.call.images` through `ctx.slots.inject`. It then registers the composer rail, document drop target, shared history gallery for Chat, Trajectory, and Tool results, and original-image lightbox. The presentation components are pure props: the slot owner supplies attachment data, image loading, callbacks, and the locale translator; the package entry exports no components.
 
 | File | Role |
 |---|---|

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

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包渲染对话 UI 中与附件相关的一切:composer 下的待发送草稿图片、全视口拖放邀请层、Chat 与 Trajectory 中的持久图片,以及查看原图的灯箱。它是纯呈现层——附件数据、图片加载与回调都经声明槽位来自 conversation 包。需要 DeepSeek Chat 风格的图片体验时选择它;非图片文件在此没有任何表面。
+本包渲染对话 UI 中与附件相关的一切:composer 下的待发送草稿图片、全视口拖放邀请层、Chat、Trajectory 与工具结果中的持久图片,以及查看原图的灯箱。它是纯呈现层——附件数据、图片加载与回调都经声明槽位来自 conversation 包。需要 DeepSeek Chat 风格的图片体验时选择它;非图片文件在此没有任何表面。
 
 ## 目录
 
@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-与 [`ui-conversation`](../ui-conversation/README.zh.md) 一起挂载本插件;它等待 conversation 包的槽位声明,并把自身表面注册进这些槽位。用户随即看到:带逐图删除与点击打开的草稿图片栏、带上限说明的拖放遮罩、按数量定尺寸的消息图片,以及支持 Escape/遮罩/关闭按钮的灯箱。
+与 [`ui-conversation`](../ui-conversation/README.zh.md)(以及工具结果图库所需的 [`ui-tool`](../ui-tool/README.zh.md))一起挂载本插件;它等待 conversation 包的槽位声明,并把自身表面注册进这些槽位。用户随即看到:带逐图删除与点击打开的草稿图片栏、带上限说明的拖放遮罩、按数量定尺寸的消息图片、工具卡片的图库,以及支持 Escape/遮罩/关闭按钮的灯箱。
 
 ### 草稿图片
 
@@ -47,7 +47,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-插件通过 `ctx.slots.inject` 等待 `conversation.input.attachments`、`conversation.message.images` 与 `conversation.trajectory.images`。随后它注册 composer rail、文档拖放目标、供 Chat 和 Trajectory 共用的历史图片 gallery,以及原图灯箱。呈现组件保持纯 props:conversation 槽位持有方提供附件数据、图片加载、回调与语言包翻译器;包入口不导出任何组件。
+插件通过 `ctx.slots.inject` 等待 `conversation.input.attachments`、`conversation.message.images`、`conversation.trajectory.images` 与 `tool.call.images`。随后它注册 composer rail、文档拖放目标、供 Chat、Trajectory 与工具结果共用的历史图片 gallery,以及原图灯箱。呈现组件保持纯 props:槽位持有方提供附件数据、图片加载、回调与语言包翻译器;包入口不导出任何组件。
 
 | 文件 | 职责 |
 |---|---|

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

@@ -35,7 +35,8 @@
         "@deepseek-ai/dsh-client-ui-chat",
         "@deepseek-ai/dsh-client-ui-conversation",
         "@deepseek-ai/dsh-client-ui-renderer",
-        "@deepseek-ai/dsh-client-ui-trajectory"
+        "@deepseek-ai/dsh-client-ui-trajectory",
+        "@deepseek-ai/dsh-client-ui-tool"
       ],
       "platform": "web"
     }
@@ -61,7 +62,8 @@
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
-    "@deepseek-ai/dsh-attachment": "workspace:^"
+    "@deepseek-ai/dsh-attachment": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-tool": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 8 - 1
packages/client/ui-attachment/src/client/index.ts

@@ -1,8 +1,9 @@
-/** Browser attachment plugin: fills conversation's composer and message-image slots. */
+/** Browser attachment plugin: fills conversation's composer and image slots. */
 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-tool/client'
 import type {} from '@deepseek-ai/dsh-client-ui-trajectory/client'
 import { ComposerAttachments } from './ComposerAttachments.tsx'
 import { MessageImages } from './MessageImages.tsx'
@@ -24,4 +25,10 @@ export function apply(ctx: ClientContext): void {
     name: 'conversation.trajectory.images',
     locale: 'conversation',
   }, MessageImages))
+  // The tool image gallery reuses the message gallery renderer: its owner
+  // carries the same images/loadImage/align share the message arm does.
+  ctx.slots.inject('tool.call.images', () => ctx.slots.register({
+    name: 'tool.call.images',
+    locale: 'conversation',
+  }, MessageImages))
 }

+ 6 - 0
packages/client/ui-attachment/tests/plugin.client.spec.ts

@@ -15,6 +15,7 @@ async function bench() {
       'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' },
       'conversation.message.images': { kind: 'single', scope: 'session' },
       'conversation.trajectory.images': { kind: 'single', scope: 'session' },
+      'tool.call.images': { kind: 'single', scope: 'session' },
     },
   } as never, () => null)
   const fiber = ctx.plugin({ inject: [...inject], apply })
@@ -42,11 +43,16 @@ describe('attachment plugin', () => {
       locale: 'conversation',
       component: MessageImages,
     }])
+    expect(ctx.slots.entries('tool.call.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)
+    expect(ctx.slots.entries('tool.call.images')).toHaveLength(0)
   })
 })

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

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

+ 3 - 2
packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx

@@ -80,7 +80,7 @@ function turnProcessLayout(
 export const ChatNodeSeat = memo(function ChatNodeSeat({
   nodeKey, historyIncomplete, compactTranscript,
   selectedCallId, cwd, openFile, inspectCall, forkAt,
-  renderMessageImages, fileMentions, useChat, useStore, actions, renderSlot, t,
+  loadImage, renderMessageImages, fileMentions, useChat, useStore, actions, renderSlot, t,
 }: ChatNodeSeatProps) {
   const node = useChat(snapshot => snapshot.nodes.get(nodeKey))
   const processSignature = useChat((snapshot) => {
@@ -181,12 +181,13 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
       openFile,
       inspectCall,
       forkAt,
+      loadImage,
       renderMessageImages,
       fileMentions,
       turnProcess,
     }, [
     node, selectedCallId, cwd, openFile, inspectCall, forkAt,
-    renderMessageImages, fileMentions, turnProcess,
+    loadImage, renderMessageImages, fileMentions, turnProcess,
   ])
   if (routedNode === undefined || owner === null) return null
   const location = routedNode.location

+ 1 - 0
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -606,6 +606,7 @@ export function ChatView({
               openFile={requestOpenFile}
               inspectCall={inspectCall}
               forkAt={forkAt}
+              loadImage={loadImage}
               renderMessageImages={renderMessageImages}
               fileMentions={fileMentions}
               renderSlot={renderSlot}

+ 7 - 0
packages/client/ui-chat/src/client/contract/slots.ts

@@ -66,6 +66,13 @@ export interface ChatNodeOwnerProps {
   openFile: (path: string) => void
   inspectCall: (callId: ToolCallId) => void
   forkAt: (seq: number) => void
+  /**
+   * Session-authorized image loader, down-threaded from the Chat view so a
+   * chat-node renderer can render the attachment presentation slot directly
+   * with only the durable references plus this loader, instead of receiving a
+   * rendering closure.
+   */
+  loadImage: MessageImageLoader
   renderMessageImages: RenderMessageImages
   fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
   /** Turn-process state when this Node belongs to a projected Turn. */

+ 2 - 0
packages/client/ui-conversation/src/client/locales.ts

@@ -102,6 +102,7 @@ export const zh = {
   'tool.title.stopCordis': '停止 Cordis 插件',
   'tool.title.removeCordis': '移除 Cordis 插件',
   'tool.title.pwsh': 'Pwsh',
+  'tool.title.readImage': '读取图片',
   'tool.title.grep': 'Grep',
   'tool.title.glob': 'Glob',
   'tool.title.webSearch': '网页搜索',
@@ -250,6 +251,7 @@ export const en = {
   'tool.title.stopCordis': 'Stop Cordis Plugin',
   'tool.title.removeCordis': 'Remove Cordis Plugin',
   'tool.title.pwsh': 'Pwsh',
+  'tool.title.readImage': 'Read image',
   'tool.title.grep': 'Grep',
   'tool.title.glob': 'Glob',
   'tool.title.webSearch': 'Search',

+ 2 - 2
packages/client/ui-tool/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-tool/README.md
-README.md: 773a93801ebc214e2d5c94d52864f5c5dd887100
-README.zh.md: 88df08d7b5b7d5d3978d90fd4df4cbbb2efeb1fa
+README.md: 3461f6a90f791cccad968604b6cdd83535d15647
+README.zh.md: d2eb38b8382f56c77827540309237e876623df33

+ 3 - 3
packages/client/ui-tool/README.md

@@ -39,11 +39,11 @@ ctx.slots.inject('tool.call.toolview', () =>
   }, BusinessToolRow))
 ```
 
-The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. A Code Dispatch block retains its event's `parentCallId`; a root Session call has no such field, so descendants keep the generic flattened form without another placement flag. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal Session slot runtime share but no React node or Runtime service.
+The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, the session-authorized `loadImage` loader (for a view whose result carries durable images), and plain `openFile`/`inspect` callbacks. A Code Dispatch block retains its event's `parentCallId`; a root Session call has no such field, so descendants keep the generic flattened form without another placement flag. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal Session slot runtime share but no React node or Runtime service.
 
 ### Built-in views
 
-This package owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`.
+This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`.
 
 -----
 
@@ -61,7 +61,7 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool
 
 ### Details and cards
 
-The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), and [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md) notes.
+The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card; the image card is row-only because its gallery renders through the tool-owned `tool.call.images` slot the details panel does not declare. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [image](../../../.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), and [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md) notes.
 
 </details>
 

+ 3 - 3
packages/client/ui-tool/README.zh.md

@@ -39,11 +39,11 @@ ctx.slots.inject('tool.call.toolview', () =>
   }, BusinessToolRow))
 ```
 
-owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`/`inspect` 回调。Code Dispatch block 保留事件的 `parentCallId`;root Session call 没有该字段,因此 descendant 无需另一项 placement 标志即可保持 generic 压平形态。路径摘要先相对 Session cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规 Session slot runtime share,但不会收到 React node 或 runtime service。
+owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`、会话授权的 `loadImage` loader(供结果携带持久图像的视图使用),以及普通的 `openFile`/`inspect` 回调。Code Dispatch block 保留事件的 `parentCallId`;root Session call 没有该字段,因此 descendant 无需另一项 placement 标志即可保持 generic 压平形态。路径摘要先相对 Session cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规 Session slot runtime share,但不会收到 React node 或 runtime service。
 
 ### 内置视图
 
-本包拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
+本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
 
 -----
 
@@ -61,7 +61,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
 
 ### 详情与卡片
 
-本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md)、[web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 与 [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md) 笔记负责。
+本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model;image 卡片仅属于行,因为其图库经由工具自有 `tool.call.images` 槽位渲染,而 details 面板不声明该槽位。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[image](../../../.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md)、[web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 与 [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md) 笔记负责。
 
 </details>
 

+ 2 - 1
packages/client/ui-tool/package.json

@@ -69,7 +69,8 @@
     "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
-    "@deepseek-ai/dsh-util-workspace-path": "workspace:^"
+    "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
+    "@deepseek-ai/dsh-attachment": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 2 - 0
packages/client/ui-tool/src/client/apply.ts

@@ -13,6 +13,7 @@ import { askQuestionToolview } from './tool/toolviews/ask-question-row.tsx'
 import { bashToolviewSample } from './tool/toolviews/bash-sample.tsx'
 import { fileMutationToolview } from './tool/toolviews/file-mutation-row.tsx'
 import { readToolview } from './tool/toolviews/read-row.tsx'
+import { readImageToolview } from './tool/toolviews/read-image-row.tsx'
 import { searchToolview } from './tool/toolviews/search-row.tsx'
 import { todoToolview } from './tool/toolviews/todo-row.tsx'
 import { webToolview } from './tool/toolviews/web-row.tsx'
@@ -48,6 +49,7 @@ export function apply(ctx: ClientContext): void {
 
   ctx.plugin(bashToolviewSample)
   ctx.plugin(readToolview)
+  ctx.plugin(readImageToolview)
   ctx.plugin(fileMutationToolview)
   ctx.plugin(searchToolview)
   ctx.plugin(webToolview)

+ 30 - 1
packages/client/ui-tool/src/client/contract/slots.ts

@@ -4,7 +4,7 @@ import type {
 } from '@deepseek-ai/dsh-client-ui-slots'
 import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client'
 import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client'
-import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { MessageImageLoader, MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -24,9 +24,30 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
      * function of what the turn already knows.
      */
     'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps }
+    /**
+     * Durable images of a settled image-bearing Tool call, rendered through
+     * the attachment presentation plugin. The Tool layer never imports an
+     * attachment implementation: a toolview declares this slot as a child and
+     * renders it with the image card's references plus the session-authorized
+     * loader it received in its owner, and the attachment plugin fills the
+     * gallery. Composing no attachment presentation plugin renders nothing,
+     * which is why the image card keeps its own envelope text beside the
+     * gallery.
+     */
+    'tool.call.images': { kind: 'single'; scope: 'session'; owner: ToolImagesOwnerProps }
   }
 }
 
+/** Owner currency of the Tool image gallery slot: references plus the loader. */
+export interface ToolImagesOwnerProps {
+  /** Durable references or submission-echo previews in result order. */
+  images: readonly MessageImageSource[]
+  /** Session-authorized image URL loader for the durable arm. */
+  loadImage: MessageImageLoader
+  /** Horizontal placement inside the owning record. */
+  align: 'start' | 'end'
+}
+
 /** Standard owner currency supplied to every atomic Tool view. */
 export interface ToolCallOwnerProps {
   /** Tool call identity, stable across running and settled forms. */
@@ -41,6 +62,14 @@ export interface ToolCallOwnerProps {
   home?: string | undefined
   /** Open a Tool argument path through the Host. */
   openFile: (path: string) => void
+  /**
+   * Session-authorized image loader for the `tool.call.images` slot, supplied
+   * by the chat node that owns this call. A composed chat node always
+   * supplies it (`ChatNodeOwnerProps.loadImage` is required), so the tool
+   * layer never imports an attachment implementation nor handles URL
+   * authorization.
+   */
+  loadImage: MessageImageLoader
   /** Inspect this call in the trajectory view when available. */
   inspect?: (() => void) | undefined
 }

+ 10 - 6
packages/client/ui-tool/src/client/tool/ToolCallTree.tsx

@@ -12,8 +12,8 @@ function callName(node: ToolCallBlock): string {
 
 /** One atomic call dispatched through the Tool-owned keyed slot. */
 const ToolCall = memo(function ToolCall({
-  renderSlot, callId, toolName, block, openFile, selected, cwd, home, inspectCall, t, children,
-}: Pick<ToolTreeProps, 'renderSlot' | 'openFile' | 'cwd' | 'inspectCall' | 't'> & {
+  renderSlot, callId, toolName, block, openFile, selected, cwd, home, inspectCall, loadImage, t, children,
+}: Pick<ToolTreeProps, 'renderSlot' | 'openFile' | 'cwd' | 'inspectCall' | 'loadImage' | 't'> & {
   callId: string
   toolName: string
   block: ToolCallBlock
@@ -28,8 +28,9 @@ const ToolCall = memo(function ToolCall({
     openFile,
     cwd,
     home,
+    loadImage,
     inspect: () => { inspectCall(callId) },
-  }), [callId, toolName, block, openFile, cwd, home, inspectCall])
+  }), [callId, toolName, block, openFile, cwd, home, loadImage, inspectCall])
   return (
     <div
       className={css.callRow}
@@ -47,8 +48,8 @@ const ToolCall = memo(function ToolCall({
 })
 
 const ToolCallBranch = memo(function ToolCallBranch({
-  renderSlot, block, selectedCallId, cwd, home, openFile, inspectCall, t,
-}: Pick<ToolTreeProps, 'renderSlot' | 'selectedCallId' | 'cwd' | 'openFile' | 'inspectCall' | 't'> & {
+  renderSlot, block, selectedCallId, cwd, home, openFile, inspectCall, loadImage, t,
+}: Pick<ToolTreeProps, 'renderSlot' | 'selectedCallId' | 'cwd' | 'openFile' | 'inspectCall' | 'loadImage' | 't'> & {
   block: ToolCallBlock
   home?: string | undefined
 }) {
@@ -63,6 +64,7 @@ const ToolCallBranch = memo(function ToolCallBranch({
       cwd={cwd}
       home={home}
       inspectCall={inspectCall}
+      loadImage={loadImage}
       t={t}
     >
       {block.subCalls.length > 0 ? (
@@ -77,6 +79,7 @@ const ToolCallBranch = memo(function ToolCallBranch({
               home={home}
               openFile={openFile}
               inspectCall={inspectCall}
+              loadImage={loadImage}
               t={t}
             />
           ))}
@@ -93,7 +96,7 @@ const ToolCallBranch = memo(function ToolCallBranch({
  * @returns the Tool call tree.
  */
 export function ToolCallTree({
-  renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostInfo, t,
+  renderSlot, node, selectedCallId, cwd, openFile, inspectCall, loadImage, useHostInfo, t,
 }: ToolTreeProps) {
   const home = useHostInfo(info => info.home)
   const block = node.data.root
@@ -106,6 +109,7 @@ export function ToolCallTree({
       home={home}
       openFile={openFile}
       inspectCall={inspectCall}
+      loadImage={loadImage}
       t={t}
     />
   )

+ 23 - 0
packages/client/ui-tool/src/client/tool/components/ToolRow.module.css

@@ -288,6 +288,7 @@
 .terminalBody,
 .diffBody,
 .readBody,
+.imageBody,
 .searchBody,
 .webBody {
   margin: 4px 0 4px 4px;
@@ -304,6 +305,28 @@
   color: var(--dsw-alias-label-tertiary);
 }
 
+/* The image card's in-card label: the view's replacement title, else the
+   shortened path — the same role ReadBlock's own label plays, so the presentation
+   contract's replacement-title rule is honoured here too. */
+.imageLabel {
+  margin-bottom: 4px;
+  overflow-wrap: anywhere;
+  font: var(--dsw-font-sm-13);
+  color: var(--dsw-alias-label-secondary);
+}
+
+/* The image card's own result text (media type, dimensions, byte size) under the
+   gallery, in the same muted tone the search recovery footer uses. Always
+   rendered: the attachment presentation slot is optional, so when nothing
+   occupies it the gallery is empty and this line is the only evidence an image
+   was returned. The enclosing .imageBody already carries the row indent. */
+.imageMeta {
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+  font: var(--dsw-font-xs-13);
+  color: var(--dsw-alias-label-tertiary);
+}
+
 /* In-row code renders at the smaller code size (12/18) via each primitive's
    rebindable content-font seam; standalone markdown code blocks keep 13/22. */
 .codeBody {

+ 88 - 45
packages/client/ui-tool/src/client/tool/components/ToolRow.tsx

@@ -4,9 +4,11 @@ import {
   CodeBlock, DiffBlock, DisclosureRow, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
   diffTotals,
 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PropsRenderSlots, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
+import type { MessageImageLoader } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../models/diff-card-model.ts'
 import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../models/read-card-model.ts'
+import type { ImageCardModel } from '../models/image-card-model.ts'
 import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../models/search-card-model.ts'
 import {
   localizeTerminalCardModel, terminalBlockLabels, type TerminalCardModel,
@@ -48,6 +50,21 @@ export interface ToolRowProps {
   terminal?: TerminalCardModel | null | undefined
   diff?: DiffCardModel | null | undefined
   read?: ReadCardModel | null | undefined
+  /**
+   * Image-card material for a call whose result is an image (derived by
+   * `imageCardModel`). Rendered through the `tool.call.images` slot, so the
+   * tool layer never imports an attachment implementation nor handles URL
+   * authorization.
+   */
+  image?: ImageCardModel | null | undefined
+  /**
+   * Dispatch the image gallery through the tool-owned `tool.call.images`
+   * slot, supplied by the toolview that owns this row together with the
+   * session-authorized loader.
+   */
+  renderSlot?: PropsRenderSlots<'tool.call.images'>['renderSlot'] | undefined
+  /** Session-authorized image URL loader for the gallery slot. */
+  loadImage?: MessageImageLoader | undefined
   search?: SearchCardModel | null | undefined
   web?: WebCardModelProps | null | undefined
   state: ToolRowState
@@ -101,6 +118,9 @@ export function ToolRow({
   terminal,
   diff,
   read,
+  image,
+  renderSlot,
+  loadImage,
   search,
   web,
   state,
@@ -119,11 +139,14 @@ export function ToolRow({
     : localizeTerminalCardModel(terminal, t)
   const diffBody = diff ?? null
   const readBody = read ?? null
+  const imageBody = image !== undefined && image !== null && renderSlot !== undefined && loadImage !== undefined
+    ? image
+    : null
   const searchBody = search ?? null
   const webBody = web ?? null
   const askQuestionBody = askQuestion ?? null
   const outputText = output ?? null
-  const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody
+  const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? imageBody ?? searchBody ?? webBody
   const expandable = body !== null || outputText !== null || card !== null
   const open = expanded && expandable
   const status = stateStatus(state, t)
@@ -215,54 +238,74 @@ export function ToolRow({
                 ? <DiffBlock {...diffBody.card} labels={diffLabels} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
                 : readBody !== null
                   ? <ReadBlock {...readBody} labels={readLabels} maxLines={CHAT_READ_MAX_LINES} className={css.readBody} />
-                  : searchBody !== null
+                  : imageBody !== null
                     ? (
-                      <>
-                        <SearchBlock
-                          {...searchBody.card}
-                          labels={searchLabels}
-                          maxLines={CHAT_SEARCH_MAX_LINES}
-                          className={css.searchBody}
-                        />
-                        {/* A capped search's recovery locator lives only in the result
-                          text; show it below the card so the dropped rows survive. */}
-                        {searchBody.recovery !== undefined && (
-                          <div className={css.searchRecovery}>{searchBody.recovery}</div>
-                        )}
-                      </>
+                      /* Label, gallery, then the result's OWN envelope text. The text
+                         comes from the image card model (which reads the result's text
+                         block), never from the row's flattened output: an image read's
+                         content is [text envelope, image block] and flattening
+                         JSON.stringifies the image block, printing the raw attachment
+                         object under the picture. It is not redundant either — the
+                         attachment slot can render nothing, and then this line is the
+                         only evidence an image was returned. */
+                      <div className={css.imageBody}>
+                        <div className={css.imageLabel}>{imageBody.label}</div>
+                        {renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', {
+                          images: imageBody.images,
+                          loadImage,
+                          align: 'start',
+                        })}
+                        <div className={css.imageMeta}>{imageBody.text}</div>
+                      </div>
                     )
-                    : webBody !== null
-                      ? <WebBlock {...webBody} labels={webLabels} className={css.webBody} />
-                      : (
+                    : searchBody !== null
+                      ? (
                         <>
-                          {variant === 'code' && body !== null && (
-                            <div className={css.bodyScroll}>
-                              <CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
-                            </div>
-                          )}
-                          {(cardBody !== null || outputText !== null) && (
-                            <div className={css.ioCard}>
-                              {cardBody !== null && (
-                                <div className={css.ioSection}>
-                                  <span className={css.ioLabel}>{t('row.input')}</span>
-                                  <span className={css.ioText}>{cardBody}</span>
-                                </div>
-                              )}
-                              {cardBody !== null && outputText !== null && (
-                                <span className={css.ioDivider} aria-hidden />
-                              )}
-                              {outputText !== null && (
-                                <div className={css.ioSection}>
-                                  <span className={css.ioLabel}>{t('row.output')}</span>
-                                  <span className={css.ioText} data-error={state === 'error' || undefined}>
-                                    {outputText}
-                                  </span>
-                                </div>
-                              )}
-                            </div>
+                          <SearchBlock
+                            {...searchBody.card}
+                            labels={searchLabels}
+                            maxLines={CHAT_SEARCH_MAX_LINES}
+                            className={css.searchBody}
+                          />
+                          {/* A capped search's recovery locator lives only in the result
+                          text; show it below the card so the dropped rows survive. */}
+                          {searchBody.recovery !== undefined && (
+                            <div className={css.searchRecovery}>{searchBody.recovery}</div>
                           )}
                         </>
-                      )}
+                      )
+                      : webBody !== null
+                        ? <WebBlock {...webBody} labels={webLabels} className={css.webBody} />
+                        : (
+                          <>
+                            {variant === 'code' && body !== null && (
+                              <div className={css.bodyScroll}>
+                                <CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
+                              </div>
+                            )}
+                            {(cardBody !== null || outputText !== null) && (
+                              <div className={css.ioCard}>
+                                {cardBody !== null && (
+                                  <div className={css.ioSection}>
+                                    <span className={css.ioLabel}>{t('row.input')}</span>
+                                    <span className={css.ioText}>{cardBody}</span>
+                                  </div>
+                                )}
+                                {cardBody !== null && outputText !== null && (
+                                  <span className={css.ioDivider} aria-hidden />
+                                )}
+                                {outputText !== null && (
+                                  <div className={css.ioSection}>
+                                    <span className={css.ioLabel}>{t('row.output')}</span>
+                                    <span className={css.ioText} data-error={state === 'error' || undefined}>
+                                      {outputText}
+                                    </span>
+                                  </div>
+                                )}
+                              </div>
+                            )}
+                          </>
+                        )}
           {inspect !== undefined && (
             <button
               type="button"

+ 232 - 0
packages/client/ui-tool/src/client/tool/models/image-card-model.ts

@@ -0,0 +1,232 @@
+/** Pure image-card derivation from raw result content and metadata. @module */
+import type { AttachmentId, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
+import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
+import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts'
+import { parsedToolCall } from './raw-tool-call.ts'
+
+/**
+ * The image-card material one settled call contributes: the display label plus
+ * the durable references the attachment slot renders as a gallery.
+ *
+ * The bytes are not here. `attachmentId` is opaque and provider-owned, so a UI
+ * resolves it to a session-authorized URL at render time; this model never parses
+ * it nor derives a path from it.
+ */
+export interface ImageCardModel {
+  /** Card label: the read path, shortened the way every other card's is. */
+  label: string
+  /** The durable images this result returned, in result order. */
+  images: readonly { readonly attachment: ImageAttachmentRef }[]
+  /**
+   * The model-facing envelope text, for the line under the gallery.
+   *
+   * Taken from the result's own text block rather than the row's flattened
+   * result text: an image read's content is `[text envelope, image block]`, and
+   * flattening JSON.stringifies the image block, which would print the raw
+   * attachment object under the picture — the symptom this card exists to remove.
+   */
+  text: string
+}
+
+/**
+ * The persisted `presentationMeta` this card reads: the authored path only.
+ *
+ * The attachment reference deliberately does NOT come from here. The settled
+ * `content` already carries the image block with its complete reference, so
+ * reading the reference from `meta` too would keep two copies of one fact — and a
+ * `tools/post-execute` hook that legitimately replaces the result content would
+ * leave the stale `meta` copy behind, showing an image the result no longer
+ * returns. `path` is the one fact `content` does not carry.
+ */
+interface ImageMeta {
+  path: string
+}
+
+/**
+ * Whether a wire value is a usable pixel or byte measure.
+ * @param value - unvalidated wire value.
+ * @returns true when it is a positive integer.
+ */
+function positiveInteger(value: unknown): value is number {
+  return typeof value === 'number' && Number.isInteger(value) && value > 0
+}
+
+/** The envelope `formatImageReadOutput` writes, matched by shape. */
+const IMAGE_ENVELOPE = /^<path>[^\n]*<\/path>\n<type>image<\/type>\n<content>\n[\s\S]*\n<\/content>$/u
+
+/** The media types a durable image block may claim; anything else declines.
+ *  Hand-written mirror of `ImageMediaType` from dsh-attachment — the wire
+ *  boundary needs a runtime check and feature plugins must not import values
+ *  from each other; a new member added there must be added here too, or the
+ *  decline point below silently degrades that image to the generic card. */
+const IMAGE_MEDIA_TYPES: ReadonlySet<ImageMediaType> = new Set([
+  'image/png', 'image/jpeg', 'image/webp', 'image/gif',
+])
+
+/** Runtime membership check; the cast is safe because the set holds exactly the four members. */
+function isImageMediaType(value: string): value is ImageMediaType {
+  return IMAGE_MEDIA_TYPES.has(value as ImageMediaType)
+}
+
+/**
+ * Validate that this block is a well-formed `read_image` call.
+ * @param block - running or settled Tool block.
+ * @returns whether the call head names read_image with a usable path argument.
+ */
+function validImageCall(block: ToolCallBlock): boolean {
+  const call = parsedToolCall(block)
+  if (call?.name !== 'read_image') return false
+  const { file_path: path } = call.args
+  return typeof path === 'string' && path.trim() !== ''
+}
+
+/**
+ * Narrow the persisted metadata, defensively. Every field arrives unvalidated on
+ * replay (an obsolete or hand-edited log reaches here), so any mismatch declines
+ * to the generic card rather than throwing.
+ *
+ * The attachment id is checked for existence only: it is opaque and
+ * provider-owned, and consumers must not parse that representation, so
+ * pattern-matching the local content-address form would reject a legitimate id
+ * minted by an alternative store.
+ * @param meta - persisted presentation metadata of unknown shape.
+ * @returns the narrowed path/image pair, or null when it does not match.
+ */
+function imageMeta(meta: unknown): ImageMeta | null {
+  if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return null
+  const { path } = meta as Record<string, unknown>
+  if (typeof path !== 'string' || path === '') return null
+  return { path }
+}
+
+/**
+ * Narrow every attachment reference carried by the result's image blocks, in
+ * order.
+ *
+ * The content is the single source of truth for the references: it is what the
+ * tool actually returned and what a post-execute hook would replace together
+ * with the rest of the content. Every field arrives unvalidated over the wire,
+ * so any malformed image block declines to the generic card rather than
+ * rendering a partial gallery.
+ *
+ * The attachment id is checked for existence only — it is opaque and
+ * provider-owned, so pattern-matching the local content-address form would reject
+ * a legitimate id minted by an alternative store.
+ * @param content - the settled result's content blocks.
+ * @returns the narrowed references, or null when no valid image block is present.
+ */
+function imageReferences(content: readonly unknown[]): ImageAttachmentRef[] | null {
+  const refs: ImageAttachmentRef[] = []
+  for (const part of content) {
+    if (typeof part !== 'object' || part === null) continue
+    const { type, attachment } = part as { type?: unknown; attachment?: unknown }
+    if (type !== 'image') continue
+    if (typeof attachment !== 'object' || attachment === null || Array.isArray(attachment)) return null
+    const {
+      attachmentId, mediaType, bytes, width, height, name, originalDimensions,
+    } = attachment as Record<string, unknown>
+    if (typeof attachmentId !== 'string' || attachmentId === '') return null
+    if (typeof mediaType !== 'string' || !isImageMediaType(mediaType)) return null
+    // Narrowed one at a time: a loop over the three does validate them, but the
+    // control-flow analysis does not carry that back to the object below.
+    if (!positiveInteger(bytes) || !positiveInteger(width) || !positiveInteger(height)) return null
+    if (name !== undefined && typeof name !== 'string') return null
+    // The store records input dimensions when normalization downsampled the
+    // image; rebuild the pair so a renderer that needs the original size gets
+    // it, and decline on a malformed pair like any other field.
+    let inputDimensions: ImageAttachmentRef['originalDimensions'] | undefined
+    if (originalDimensions !== undefined) {
+      if (typeof originalDimensions !== 'object' || originalDimensions === null || Array.isArray(originalDimensions)) return null
+      const { width: inputWidth, height: inputHeight } = originalDimensions as Record<string, unknown>
+      if (!positiveInteger(inputWidth) || !positiveInteger(inputHeight)) return null
+      inputDimensions = { width: inputWidth, height: inputHeight }
+    }
+    refs.push({
+      attachmentId: attachmentId as AttachmentId,
+      mediaType,
+      bytes,
+      width,
+      height,
+      ...name === undefined ? {} : { name },
+      ...inputDimensions === undefined ? {} : { originalDimensions: inputDimensions },
+    })
+  }
+  return refs.length > 0 ? refs : null
+}
+
+/**
+ * Read the text of every text block of a settled image result, joined in order.
+ *
+ * The envelope is one of them; a post-execute hook that appends further text
+ * blocks keeps them visible under the gallery instead of being dropped. The
+ * envelope shape is still the recognition gate: a result without it is not a
+ * well-formed image read and declines.
+ * @param content - the settled result's content blocks.
+ * @returns the joined text, or null when no envelope-shaped block is present.
+ */
+function imageTexts(content: readonly { type: string; text?: string }[]): string | null {
+  const parts: string[] = []
+  let sawEnvelope = false
+  for (const part of content) {
+    if (part.type !== 'text' || typeof part.text !== 'string') continue
+    if (IMAGE_ENVELOPE.test(part.text)) sawEnvelope = true
+    parts.push(part.text)
+  }
+  return sawEnvelope && parts.length > 0 ? parts.join('\n') : null
+}
+
+/**
+ * Whether the content carries only blocks the card consumes.
+ *
+ * `ContentBlock` is a merge-extensible union, so a post-execute hook could
+ * append a block of a type this card does not render (reasoning, an extension
+ * type, or a non-object). Rendering the card anyway would silently hide that
+ * block, so anything beyond a well-formed text or image object declines to the
+ * generic card, which shows the flattened content.
+ * @param content - the settled result's content blocks.
+ * @returns true when every block is a text or image object with usable fields.
+ */
+function fullyRendered(content: readonly unknown[]): boolean {
+  return content.every((part) => {
+    if (typeof part !== 'object' || part === null) return false
+    const { type, text } = part as { type?: unknown; text?: unknown }
+    return type === 'image' || (type === 'text' && typeof text === 'string')
+  })
+}
+
+/**
+ * Derive a settled root image card after validating its persisted metadata and
+ * model-facing image envelope.
+ *
+ * The card is result-side only: a call carries no content until `execute`
+ * returns, so a running `read_image` has none and this returns null for it.
+ * @param block - running or settled Tool block.
+ * @param sessionCwd - the session workspace root; a workspace-rooted absolute
+ *   path label displays relative to it. Absent leaves the path as authored.
+ * @param home - host account home; a leftover POSIX home path displays as `~`.
+ * @returns the image-card props, or null for the generic path.
+ */
+export function imageCardModel(
+  block: ToolCallBlock,
+  sessionCwd?: string,
+  home?: string,
+): ImageCardModel | null {
+  if (block.parentCallId !== undefined || !('kind' in block) || block.isError) return null
+  if (!validImageCall(block)) return null
+  const meta = imageMeta(block.meta)
+  if (meta === null) return null
+  // The card renders only text and image blocks; a block of any other type must
+  // not be silently hidden, so the whole card declines to the generic form.
+  if (!fullyRendered(block.content)) return null
+  // The references come from the result's own image blocks, the single source of
+  // truth; `meta` contributes only the path, which the content does not carry.
+  const refs = imageReferences(block.content)
+  if (refs === null) return null
+  const text = imageTexts(block.content)
+  if (text === null) return null
+  return {
+    label: abbreviateHomePath(relativizeToCwd(meta.path, sessionCwd), home),
+    images: refs.map(ref => ({ attachment: ref })),
+    text,
+  }
+}

+ 6 - 0
packages/client/ui-tool/src/client/tool/models/tool-call-model.ts

@@ -44,6 +44,11 @@ const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
   // with its own title from TOOL_TITLE_KEYS, not the generic `others` row.
   pwsh: 'bash',
   read: 'read',
+  // read_image is a single-file read: the same browse icon and the same openable
+  // path summary (FILE_PATH_VARIANTS covers `read`), with its own title key below.
+  // Left unclassified it falls to `others`, which titles the row generically and
+  // derives no filePath — so the path the row advertises as openable never is.
+  read_image: 'read',
   web_fetch: 'read',
   web_search: 'search',
   grep: 'search',
@@ -70,6 +75,7 @@ const TOOL_TITLE_KEYS: Record<string, ToolTitleKey> = {
   cordis_stop: 'tool.title.stopCordis',
   cordis_undefine: 'tool.title.removeCordis',
   pwsh: 'tool.title.pwsh',
+  read_image: 'tool.title.readImage',
 }
 
 /**

+ 59 - 0
packages/client/ui-tool/src/client/tool/toolviews/read-family-row.tsx

@@ -0,0 +1,59 @@
+// Shared assembly for the read-family toolview rows (`read`, `read_image`).
+//
+// Both rows are the same single-file card row: the browse icon in the shared
+// ToolRow chrome, the args-derived summary as an openable host path, no args body
+// (the path link is the only args interaction), and one result-side card as the
+// collapsed-by-default body. Only which card material they carry differs, so the
+// row assembly lives here once instead of being copied per tool.
+
+import type { ReactNode } from 'react'
+import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ToolCallViewProps } from '../../contract/slots.ts'
+import { toolRowModel } from '../models/tool-call-model.ts'
+import { ToolRow, type ToolRowProps } from '../components/ToolRow.tsx'
+
+/** Full row props of a read-family toolview: the runtime share plus its locale seat. */
+export type ReadFamilyRowProps = ToolCallViewProps & { t: ToolRowProps['t'] }
+
+/** read_image row props: the runtime share, the declared image child slot, and the locale seat. */
+export type ReadImageRowProps = ReadFamilyRowProps & PropsRenderSlots<'tool.call.images'>
+
+/**
+ * The card material one read-family row contributes: exactly the ToolRow card
+ * props that row owns. `read` supplies `read`; `read_image` supplies `image`
+ * together with the slot dispatcher and loader that draw it.
+ */
+export type ReadFamilyCard = Pick<ToolRowProps, 'read' | 'image' | 'renderSlot' | 'loadImage'>
+
+/**
+ * Compose a read-family row: the shared chrome and model-derived fields, plus the
+ * caller's card material.
+ * @param props - the toolview runtime share and locale seat.
+ * @param card - the card props this row owns.
+ * @returns the assembled ToolRow.
+ */
+export function readFamilyRow(
+  { toolName, block, cwd, home, openFile, inspect, t }: ReadFamilyRowProps,
+  card: ReadFamilyCard,
+): ReactNode {
+  const model = toolRowModel(toolName, block, cwd, home)
+  return (
+    <ToolRow
+      t={t}
+      variant={model.variant}
+      toolName={toolName}
+      icon={<IconBrowseOutline16 size={14} />}
+      title={t(model.titleKey)}
+      summary={model.summary}
+      body={null}
+      output={model.output}
+      errorSummary={model.errorSummary}
+      {...card}
+      state={model.state}
+      filePath={model.filePath}
+      onOpenFile={openFile}
+      inspect={inspect}
+    />
+  )
+}

+ 61 - 0
packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx

@@ -0,0 +1,61 @@
+// read_image toolview registrant: the keyed toolview hole for the read_image
+// tool. The row composes the shared read-family assembly and feeds it the durable
+// image reference as ToolRow's `image` card material, so the image renders through
+// the Tool-owned `tool.call.images` slot inside the collapsed-by-default expanded
+// body — the same unified interaction every other card row has. The attachment
+// presentation plugin fills that slot; the tool layer only ever supplies the
+// references and the session-authorized loader it received from the chat node.
+//
+// Claiming the `read_image` key suppresses the generic fallback for EVERY
+// read_image result, so this component must cover all of the tool's shapes, not
+// only the happy one: a running call (no result yet), a settled image, a refusal
+// (a text-only route, a missing attachment service, an unreadable file), and a
+// cancelled call. Each of those settles without an `image` card, and the row falls
+// back to its text body for them.
+//
+// Like the read card, the image card is result-side only: a call carries no
+// content until `execute` returns, so a running read_image shows the summary row
+// alone.
+
+import type { Context } from '@deepseek-ai/cordis'
+import { imageCardModel } from '../models/image-card-model.ts'
+import { readFamilyRow, type ReadImageRowProps } from './read-family-row.tsx'
+import { CONVERSATION_NS as NS } from '../../locale.ts'
+
+/**
+ * read_image row: the read-family chrome with the durably committed image as the
+ * row's collapsed-by-default card body, rendered through the `tool.call.images`
+ * slot this entry declares.
+ */
+export function ReadImageRow(props: ReadImageRowProps) {
+  const { block, cwd, home, renderSlot, loadImage } = props
+  return readFamilyRow(props, {
+    image: imageCardModel(block, cwd, home),
+    renderSlot,
+    loadImage,
+  })
+}
+
+/**
+ * The read_image row as a plain registrant plugin following the atomic Tool-view
+ * declaration across independent activation and reload lifetimes. Declaring
+ * `tool.call.images` as a child slot authorizes this entry's `renderSlot` to
+ * dispatch the gallery.
+ */
+export const readImageToolview = {
+  name: 'read-image-toolview',
+  inject: ['slots'],
+  /**
+   * Register the read_image row into the Tool-owned keyed view slot.
+   * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
+   */
+  apply(ctx: Context): void {
+    ctx.slots.inject('tool.call.toolview', () =>
+      ctx.slots.register({
+        name: 'tool.call.toolview',
+        key: 'read_image',
+        locale: NS,
+        children: { 'tool.call.images': { kind: 'single', scope: 'session' } },
+      }, ReadImageRow))
+  },
+}

+ 4 - 24
packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx

@@ -1,10 +1,8 @@
 import type { Context } from '@deepseek-ai/cordis'
-import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ToolCallViewProps } from '../../contract/slots.ts'
 import { readCardModel } from '../models/read-card-model.ts'
-import { toolRowModel } from '../models/tool-call-model.ts'
-import { ToolRow } from '../components/ToolRow.tsx'
+import { readFamilyRow } from './read-family-row.tsx'
 import { CONVERSATION_NS as NS } from '../../locale.ts'
 
 type ReadRowProps = ToolCallViewProps & PropsLocale<'conversation'>
@@ -12,27 +10,9 @@ type ReadRowProps = ToolCallViewProps & PropsLocale<'conversation'>
 /**
  * Lets users expand a completed read result and open its reported path.
  */
-export function ReadRow({ toolName, block, cwd, home, openFile, inspect, t }: ReadRowProps) {
-  const model = toolRowModel(toolName, block, cwd, home)
-  const read = readCardModel(block, cwd, home)
-  return (
-    <ToolRow
-      t={t}
-      variant={model.variant}
-      toolName={toolName}
-      icon={<IconBrowseOutline16 size={14} />}
-      title={t(model.titleKey)}
-      summary={model.summary}
-      body={null}
-      output={model.output}
-      errorSummary={model.errorSummary}
-      read={read}
-      state={model.state}
-      filePath={model.filePath}
-      onOpenFile={openFile}
-      inspect={inspect}
-    />
-  )
+export function ReadRow(props: ReadRowProps) {
+  const { block, cwd, home } = props
+  return readFamilyRow(props, { read: readCardModel(block, cwd, home) })
 }
 
 /** Registers the read tool's conversation row. */

+ 1 - 0
packages/client/ui-tool/tests/coverage-tails.client.spec.tsx

@@ -59,6 +59,7 @@ describe('Tool presentation tails', () => {
       content: [], isError: false, subCalls: [],
     }
     const props: GenericToolCardProps = {
+      loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
       callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t,
     }
     const view = render(<GenericToolCard {...props} />)

+ 3 - 1
packages/client/ui-tool/tests/diff-card.client.spec.tsx

@@ -168,6 +168,7 @@ describe('diffCardModel', () => {
 
 describe('chat row diff body', () => {
   const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
+    loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
     callId: 'c1', toolName: 'edit', block, openFile: vi.fn(), t,
   })
 
@@ -192,7 +193,8 @@ describe('chat row diff body', () => {
     // A non-file tool name so the row is not single-file (no path link), and its
     // args body is the fallback the diff card must not have replaced.
     const view = render(<GenericToolCard {...{
-      callId: 'c1', toolName: 'some_tool', openFile: vi.fn(), t,
+      callId: 'c1', toolName: 'some_tool', openFile: vi.fn(),
+      loadImage: vi.fn(() => Promise.reject(new Error('not used'))), t,
       block: settled({
         call: { name: 'some_tool', argsRaw: '{"foo":"bar"}' },
         meta: undefined,

+ 371 - 0
packages/client/ui-tool/tests/image-card.client.spec.tsx

@@ -0,0 +1,371 @@
+// @vitest-environment jsdom
+// The image render intent on the web side: the pure imageCardModel derivation over
+// a settled call's persisted metadata and raw envelope, and the chat tool row that
+// consumes it — the keyed ReadImageRow composing ToolRow with the image card as its
+// collapsed-by-default expanded body. Also pins the keyed 'read_image' toolview
+// registration (including its `tool.call.images` child-slot declaration) and the
+// text that stays readable when the attachment slot renders nothing.
+//
+// The image card differs from every other card in one load-bearing way: its bytes
+// are a session-authorized attachment, so the row cannot draw them itself. It
+// renders through the tool-owned `tool.call.images` slot, and an empty slot must
+// still leave the media type and dimensions visible.
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { Context } from '@deepseek-ai/cordis'
+import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
+import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
+import type { MessageImageLoader } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
+import type { ToolImagesOwnerProps, ToolTreeProps } from '../src/client/contract/slots.ts'
+import { imageCardModel } from '../src/client/tool/models/image-card-model.ts'
+import { ReadImageRow, readImageToolview } from '../src/client/tool/toolviews/read-image-row.tsx'
+
+afterEach(cleanup)
+
+const SID = 's1' as SessionId
+const t: ToolTreeProps['t'] = makeTranslate(zh, commonZh)
+const ARGS = '{"file_path":"shots/card.png"}'
+
+/** The envelope read_image writes beside its image block. */
+const ENVELOPE = '<path>shots/card.png</path>\n<type>image</type>\n<content>\nimage/png image, 1496x260 px, 24588 bytes\n</content>'
+
+/** The durable reference read_image persists through presentationMeta. */
+const sampleImage = {
+  attachmentId: 'sha256:fe6d588c8d5a8e93c743d80524b9376634ca1cc262db9e1d21c9e4c18fc856cc',
+  mediaType: 'image/png',
+  bytes: 24_588,
+  width: 1496,
+  height: 260,
+  name: 'card.png',
+}
+
+/** The persisted presentationMeta payload. */
+/** The persisted presentationMeta payload: the path only. */
+const imageMeta = (over?: Record<string, unknown>) => ({ path: 'shots/card.png', ...over })
+
+/** Result content with the image block replaced, for reference-side cases. */
+const withImage = (attachment: unknown) => [
+  { type: 'text', text: ENVELOPE },
+  { type: 'image', attachment },
+]
+
+const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
+  callId: 'c1', name: 'read_image', argsRaw: ARGS,
+  turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
+})
+
+/**
+ * A settled read_image node carrying the REAL content shape: [text envelope, image
+ * block]. A text-only fixture would hide that the row must not flatten the image
+ * block into JSON under the picture.
+ */
+const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
+  kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
+  call: { name: 'read_image', argsRaw: ARGS },
+  callTime: 1_000,
+  content: [
+    { type: 'text', text: ENVELOPE },
+    { type: 'image', attachment: sampleImage },
+  ],
+  isError: false,
+  meta: imageMeta(), subCalls: [], ...over,
+} as unknown as ToolResultNode)
+
+/**
+ * A renderSlot stub standing in for the attachment presentation plugin's
+ * `tool.call.images` gallery. The owner is the real `ToolImagesOwnerProps` —
+ * `MessageImageSource` is a union of a durable attachment arm and a
+ * submission-echo preview arm, so the stub renders both.
+ */
+const stubRenderSlot = (): PropsRenderSlots<'tool.call.images'>['renderSlot'] => (
+  vi.fn((_key: 'tool.call.images', owner: ToolImagesOwnerProps) => (
+    <div data-images>
+      {owner.images.map((image, index) => (
+        'attachment' in image ? (
+          <span key={image.attachment.attachmentId} data-image-id={image.attachment.attachmentId} />
+        ) : (
+          <span key={index} data-preview-url={image.preview.url} />
+        )
+      ))}
+    </div>
+  )) as unknown as PropsRenderSlots<'tool.call.images'>['renderSlot']
+)
+
+/** Session-authorized loader stand-in; the stub gallery never resolves it. */
+const loadImage: MessageImageLoader = vi.fn(() => Promise.reject(new Error('not used')))
+
+describe('imageCardModel', () => {
+  it('derives the card from settled image metadata and its raw envelope', () => {
+    expect(imageCardModel(settled())).toEqual({
+      label: 'shots/card.png',
+      images: [{ attachment: sampleImage }],
+      // The fallback line is the result's own envelope, not the row's flattened
+      // output (which would stringify the image block).
+      text: ENVELOPE,
+    })
+  })
+
+  it('omits an absent display name rather than carrying undefined through', () => {
+    const unnamed = { ...sampleImage }
+    delete (unnamed as { name?: string }).name
+    const attachment = imageCardModel(settled({ content: withImage(unnamed) } as never))?.images[0]?.attachment
+    expect(attachment).not.toHaveProperty('name')
+  })
+
+  it('relativizes a workspace-rooted path label, and leaves others as authored', () => {
+    expect(imageCardModel(settled({ meta: imageMeta({ path: '/w/app/shots/card.png' }) }), '/w/app')?.label)
+      .toBe('shots/card.png')
+    expect(imageCardModel(settled({ meta: imageMeta({ path: '/srv/other.png' }) }), '/w/app')?.label)
+      .toBe('/srv/other.png')
+  })
+
+  it('abbreviates a leftover POSIX home path label', () => {
+    expect(imageCardModel(settled({ meta: imageMeta({ path: '/Users/u/card.png' }) }), '/tmp/ws', '/Users/u')?.label)
+      .toBe('~/card.png')
+  })
+
+  it('accepts any non-empty attachment id, because the shape is provider-owned', () => {
+    // The id is opaque and provider-owned: consumers must not parse that
+    // representation, so pattern-checking the local content-address form would
+    // reject a legitimate id minted by an alternative store.
+    for (const id of ['b3:0123456789abcdef', 'blake3-xof:zzz', 'opaque-provider-token']) {
+      expect(imageCardModel(settled({ content: withImage({ ...sampleImage, attachmentId: id }) } as never))
+        ?.images[0]?.attachment.attachmentId).toBe(id)
+    }
+  })
+
+  it('keeps blocks a post-execute hook appended instead of dropping them', () => {
+    // The card derives from the settled content, and the content is what a
+    // post-execute hook rewrites: appended text and image blocks must stay
+    // visible under the gallery, not be silently dropped.
+    const appended = { ...sampleImage, attachmentId: 'sha256:appended-extra', name: 'appended.png' }
+    const model = imageCardModel(settled({
+      content: [
+        { type: 'text', text: ENVELOPE },
+        { type: 'image', attachment: sampleImage },
+        { type: 'text', text: 'analysis: the card area is empty' },
+        { type: 'image', attachment: appended },
+      ],
+    } as never))
+    expect(model?.text).toBe(`${ENVELOPE}\nanalysis: the card area is empty`)
+    expect(model?.images).toEqual([{ attachment: sampleImage }, { attachment: appended }])
+  })
+
+  it('declines when the content carries a block type the card does not render', () => {
+    // ContentBlock is merge-extensible: a post-execute hook appending e.g. a
+    // reasoning block must not be silently hidden — the card declines to the
+    // generic form, which shows the flattened content.
+    for (const extra of [
+      { type: 'reasoning', text: 'thinking' },
+      { type: 'tool-call', callId: 'x' },
+      { type: 'text' },
+      { type: 'text', text: 7 },
+      'not-an-object',
+      7,
+    ]) {
+      const node = settled({ content: [...settled().content, extra] } as never)
+      expect(() => imageCardModel(node)).not.toThrow()
+      expect(imageCardModel(node)).toBeNull()
+    }
+  })
+
+  it('preserves originalDimensions and declines a malformed pair', () => {
+    // The store records input dimensions when normalization downsampled the
+    // image; the card must carry them through to the gallery renderer.
+    const withDims = { ...sampleImage, originalDimensions: { width: 3000, height: 2000 } }
+    expect(imageCardModel(settled({ content: withImage(withDims) } as never))
+      ?.images[0]?.attachment.originalDimensions).toEqual({ width: 3000, height: 2000 })
+    for (const bad of [
+      { ...sampleImage, originalDimensions: null },
+      { ...sampleImage, originalDimensions: 'big' },
+      { ...sampleImage, originalDimensions: { width: 0, height: 1 } },
+      { ...sampleImage, originalDimensions: { width: 1 } },
+    ]) {
+      expect(imageCardModel(settled({ content: withImage(bad) } as never))).toBeNull()
+    }
+  })
+
+  it('declines malformed metadata instead of throwing', () => {
+    // Metadata arrives unvalidated on replay, so an obsolete or hand-edited log
+    // must land on the generic card, never crash the tool message. A log written
+    // before this change carries no path and lands here too.
+    for (const meta of [undefined, null, 'meta', [{ path: 'a.png' }], {}, { path: '' }, { path: 7 }]) {
+      expect(() => imageCardModel(settled({ meta }))).not.toThrow()
+      expect(imageCardModel(settled({ meta }))).toBeNull()
+    }
+  })
+
+  it('declines a malformed attachment reference in the content', () => {
+    // The reference is read from the result's own image block, which is wire data
+    // like every other field.
+    const bad: unknown[] = [
+      null, 'ref', [],
+      {},
+      { ...sampleImage, attachmentId: '' },
+      { ...sampleImage, attachmentId: 7 },
+      { ...sampleImage, mediaType: 7 },
+      { ...sampleImage, mediaType: '' },
+      { ...sampleImage, mediaType: 'text/html' },
+      { ...sampleImage, width: 'wide' },
+      { ...sampleImage, bytes: 0 },
+      { ...sampleImage, height: 1.5 },
+      { ...sampleImage, name: 5 },
+    ]
+    for (const attachment of bad) {
+      const node = settled({ content: withImage(attachment) } as never)
+      expect(() => imageCardModel(node)).not.toThrow()
+      expect(imageCardModel(node)).toBeNull()
+    }
+  })
+
+  it('declines when the content carries no image block at all', () => {
+    expect(imageCardModel(settled({ content: [{ type: 'text', text: ENVELOPE }] } as never))).toBeNull()
+  })
+
+  it('declines when no content block matches the image envelope', () => {
+    // `singleResultText` accepts only a lone text block and an image read returns
+    // two, so this derivation matches its own envelope by shape. Content another
+    // layer prepended must not be mistaken for it.
+    expect(imageCardModel(settled({ content: [{ type: 'text', text: 'hook preamble' }] } as never))).toBeNull()
+    expect(imageCardModel(settled({ content: [] }))).toBeNull()
+  })
+
+  it('declines a running call, an error result, and a nested call', () => {
+    // The image card is result-side only, and only a root call owns a row.
+    expect(imageCardModel(running())).toBeNull()
+    expect(imageCardModel(settled({ isError: true }))).toBeNull()
+    expect(imageCardModel(settled({ parentCallId: 'parent' }))).toBeNull()
+  })
+
+  it('declines a call head that is not read_image', () => {
+    expect(imageCardModel(settled({ call: { name: 'read', argsRaw: ARGS } }))).toBeNull()
+    expect(imageCardModel(settled({ call: { name: 'read_image', argsRaw: '{"file_path":"  "}' } }))).toBeNull()
+  })
+})
+
+describe('ReadImageRow keyed toolview', () => {
+  const list = () => createSnapshotStore<SessionListState>({
+    ids: [SID],
+    byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
+    current: SID,
+    phase: 'ready',
+    subagentsByParent: {}, jobsBySession: {},
+    currentAddress: undefined,
+  } as unknown as SessionListState)
+
+  const rowProps = (
+    block: RunningToolCall | ToolResultNode,
+    renderSlot?: PropsRenderSlots<'tool.call.images'>['renderSlot'],
+    loader: MessageImageLoader = loadImage,
+  ): Parameters<typeof ReadImageRow>[0] => ({
+    callId: 'c1', toolName: 'read_image', block, openFile: vi.fn(), renderSlot, loadImage: loader,
+    sessionId: SID, useSessions: bindSnapshotSelector(list()),
+    t,
+  } as unknown as Parameters<typeof ReadImageRow>[0])
+
+  const toggleRow = (view: { container: HTMLElement }) => {
+    fireEvent.click(view.container.querySelector('[data-expandable]')!)
+  }
+
+  it('classifies read_image into the read family so the row keeps its promises', () => {
+    // Unclassified, read_image falls to `others`: a generic title and no filePath
+    // (FILE_PATH_VARIANTS covers only read/write/edit), so the openable path the
+    // row advertises would never be openable.
+    const openFile = vi.fn()
+    const view = render(<ReadImageRow {...rowProps(settled(), stubRenderSlot())} openFile={openFile} />)
+    expect(view.container.querySelector('[data-variant]')?.getAttribute('data-variant')).toBe('read')
+    const link = view.container.querySelector('button[class*="fileLink"]')
+    expect(link).not.toBeNull()
+    fireEvent.click(link!)
+    expect(openFile).toHaveBeenCalledWith('shots/card.png')
+  })
+
+  it('expands to the image, dispatched through the tool-owned image slot', () => {
+    const renderSlot = stubRenderSlot()
+    const view = render(<ReadImageRow {...rowProps(settled(), renderSlot)} />)
+    expect(view.container.querySelector('[data-images]')).toBeNull()
+    toggleRow(view)
+    expect(view.container.querySelector('[data-images]')).not.toBeNull()
+    expect(renderSlot).toHaveBeenLastCalledWith('tool.call.images', {
+      images: [{ attachment: sampleImage }],
+      loadImage,
+      align: 'start',
+    })
+    expect(view.container.querySelector(`[data-image-id="${sampleImage.attachmentId}"]`)).not.toBeNull()
+  })
+
+  it('never prints the raw attachment object under the picture', () => {
+    // The row's flattened output JSON.stringifies the image block the real content
+    // carries, so the card takes its text from the derived envelope instead.
+    const view = render(<ReadImageRow {...rowProps(settled(), stubRenderSlot())} />)
+    toggleRow(view)
+    const text = view.container.textContent ?? ''
+    expect(text).not.toContain('"attachmentId"')
+    expect(text).not.toContain('"type": "image"')
+    expect(text).toContain('image/png image, 1496x260 px')
+  })
+
+  it('keeps the result text readable when the attachment slot renders nothing', () => {
+    // The attachment slot renders nothing in a deployment without the attachment
+    // presentation plugin. That must not leave a blank card.
+    const emptySlot = vi.fn(() => null)
+    const view = render(<ReadImageRow {...rowProps(settled(), emptySlot as never)} />)
+    toggleRow(view)
+    expect(emptySlot).toHaveBeenCalled()
+    expect(view.container.querySelector('[data-images]')).toBeNull()
+    expect(view.container.textContent).toContain('image/png image, 1496x260 px')
+  })
+
+  it('degrades to the text body when neither the slot nor the loader is supplied', () => {
+    const view = render(<ReadImageRow {...rowProps(settled(), undefined)} />)
+    toggleRow(view)
+    expect(view.container.querySelector('[data-images]')).toBeNull()
+  })
+
+  it('a running call renders the summary row alone', () => {
+    const renderSlot = stubRenderSlot()
+    const view = render(<ReadImageRow {...rowProps(running(), renderSlot)} />)
+    expect(view.container.querySelector('[data-images]')).toBeNull()
+    expect(renderSlot).not.toHaveBeenCalled()
+  })
+
+  it('a refusal renders its error without an image card', () => {
+    // read_image refuses a text-only route, a missing attachment service, and an
+    // unreadable file. Claiming the key means this row owns those shapes too.
+    const renderSlot = stubRenderSlot()
+    const view = render(<ReadImageRow {...rowProps(settled({
+      isError: true,
+      meta: undefined,
+      content: [{ type: 'text', text: 'Error: model "x" does not declare image input' }],
+    } as never), renderSlot)} />)
+    expect(view.container.querySelector('[data-images]')).toBeNull()
+    expect(renderSlot).not.toHaveBeenCalled()
+    expect(view.container.textContent).toContain('does not declare image input')
+  })
+
+  it('registers under the read_image key of the keyed toolview slot, declaring the image slot', () => {
+    const registered: { name: unknown; key?: unknown; children?: unknown }[] = []
+    const ctx = { slots: {
+      inject: (_name: string, callback: () => () => void) => callback(),
+      register: (options: { name: unknown; key?: unknown; children?: unknown }) => {
+        registered.push(options)
+        return () => undefined
+      },
+    } } as unknown as Context
+    readImageToolview.apply(ctx)
+    expect(registered).toEqual([{
+      name: 'tool.call.toolview',
+      key: 'read_image',
+      locale: 'conversation',
+      children: { 'tool.call.images': { kind: 'single', scope: 'session' } },
+    }])
+    expect(readImageToolview.inject).toEqual(['slots'])
+  })
+})

+ 2 - 1
packages/client/ui-tool/tests/read-card.client.spec.tsx

@@ -151,6 +151,7 @@ describe('readCardModel', () => {
 
 describe('GenericToolCard read body', () => {
   const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
+    loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
     callId: 'c1', toolName: 'read', block, openFile: vi.fn(), t,
   })
 
@@ -175,7 +176,7 @@ describe('GenericToolCard read body', () => {
     const view = render(<GenericToolCard {...({
       callId: 'c1', toolName: 'echo', block: settled({
         call: { name: 'echo', argsRaw: '{"text":"x"}' }, meta: undefined,
-      }), openFile: vi.fn(), t,
+      }), openFile: vi.fn(), loadImage: vi.fn(() => Promise.reject(new Error('not used'))), t,
     })} />)
     toggleRow(view)
     expect(view.container.querySelector('[data-read]')).toBeNull()

+ 1 - 0
packages/client/ui-tool/tests/search-card.client.spec.tsx

@@ -187,6 +187,7 @@ describe('searchCardModel', () => {
 
 describe('chat row search body (GenericToolCard fallback)', () => {
   const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): GenericToolCardProps => ({
+    loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
     callId: 'c1', toolName, block, openFile: vi.fn(), t,
   })
   /** The whole summary row is the expand toggle (ToolRow's unified interaction). */

+ 1 - 0
packages/client/ui-tool/tests/terminal-card.client.spec.tsx

@@ -274,6 +274,7 @@ describe('terminalCardModel', () => {
 
 describe('chat row terminal body', () => {
   const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
+    loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
     callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), t,
   })
 

+ 1 - 0
packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx

@@ -48,6 +48,7 @@ function props(
     openFile: vi.fn(),
     inspectCall: vi.fn(),
     forkAt: vi.fn(),
+    loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
     fileMentions: vi.fn(),
     useHostInfo: ((selector: (info: { home: string | undefined }) => unknown) => selector({ home })) as ToolTreeProps['useHostInfo'],
     t,

+ 1 - 0
packages/client/ui-tool/tests/tool-row.client.spec.tsx

@@ -401,6 +401,7 @@ describe('ToolRow', () => {
 
 describe('GenericToolCard', () => {
   const props = (toolName: string, block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
+    loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
     callId: 'c1', toolName, block, openFile: vi.fn(), t,
   })
 

+ 1 - 1
packages/client/ui-tool/tests/web-card.client.spec.tsx

@@ -138,7 +138,7 @@ describe('webCardModel', () => {
 
 describe('chat row web body', () => {
   const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolCallOwnerProps => ({
-    callId: block.callId, toolName, block, openFile: vi.fn(),
+    callId: block.callId, toolName, block, openFile: vi.fn(), loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
   })
   // WebRow reads only toolName/block off the full runtime share plus the locale
   // seat; the standard kit is unused, so the cast supplies the owner slice and

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

@@ -8,6 +8,9 @@
     "src"
   ],
   "references": [
+    {
+      "path": "../../attachment/attachment"
+    },
     {
       "path": "../../api/remotes/tsconfig.client.json"
     },

+ 1 - 0
packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx

@@ -329,6 +329,7 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi
     openFile: () => {},
     inspectCall: () => {},
     forkAt: () => {},
+    loadImage: () => Promise.reject(new Error('not used')),
     renderMessageImages: () => null,
     fileMentions: () => undefined,
     openSession,

+ 17 - 1
packages/experimental/webworker-packer/tests/image-loadable.spec.ts

@@ -73,7 +73,23 @@ describe('preview example overlays', () => {
  * suite through its post-build uninstrumented gate, and preview builds exercise
  * the same path against complete real artifacts.
  */
-const subjectBuilt = existsSync(join(repoRoot, 'packages/util/timeout/lib/index.js'))
+// The subject is zero-dep, but its peer/dependency closure (cordis, loader,
+// include, cosmokit, invariants) must also be built: on the complete lane
+// build and coverage run concurrently, so checking only the subject lets the
+// pack start before its real workspace dependencies exist.
+const subjectBuilt = [
+  'packages/util/timeout/lib/index.js',
+  'packages/runtime-diagnostics/invariants/lib/index.js',
+  'vendor/cordis/lib/index.js',
+  'vendor/cosmokit/lib/index.js',
+  'vendor/include/lib/index.js',
+  'vendor/loader/lib/index.js',
+  'packages/host/webserver/lib/index.js',
+  'packages/llm/plugin-package-inventory-deepseek/lib/index.js',
+  'native/landlock-run/packages/entry/lib/index.js',
+  'packages/preset/agent-presets/lib/typert.host.js',
+  'packages/preset/agent-presets/lib/typert.remote-client.js',
+].every(path => existsSync(join(repoRoot, path)))
 
 let memo: ReturnType<typeof packVfsImage> | undefined
 const packed = (): ReturnType<typeof packVfsImage> => memo ??= packVfsImage({

+ 54 - 10
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:202',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:209',
   },
   {
     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:190',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:197',
   },
   {
     key: 'conversation.chat.node',
@@ -266,10 +266,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       },
     ],
     ownerProps: [
-      '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n  selectedCallId?: ToolCallId | undefined\n  cwd?: string | undefined\n  openFile: (path: string) => void\n  inspectCall: (callId: ToolCallId) => void\n  forkAt: (seq: number) => void\n  renderMessageImages: RenderMessageImages\n  fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n  /** Turn-process state when this Node belongs to a projected Turn. */\n  turnProcess?: TurnProcessOwnerProps | undefined\n}',
+      '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n  selectedCallId?: ToolCallId | undefined\n  cwd?: string | undefined\n  openFile: (path: string) => void\n  inspectCall: (callId: ToolCallId) => void\n  forkAt: (seq: number) => void\n  /**\n   * Session-authorized image loader, down-threaded from the Chat view so a\n   * chat-node renderer can render the attachment presentation slot directly\n   * with only the durable references plus this loader, instead of receiving a\n   * rendering closure.\n   */\n  loadImage: MessageImageLoader\n  renderMessageImages: RenderMessageImages\n  fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n  /** Turn-process state when this Node belongs to a projected Turn. */\n  turnProcess?: TurnProcessOwnerProps | undefined\n}',
     ],
     ownerPropsReferences: [
       'MarkdownFileMentions',
+      'MessageImageLoader',
       'RenderMessageImages',
       'TurnProcessOwnerProps',
       'TurnTailOwnerProps',
@@ -313,7 +314,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:171',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:178',
   },
   {
     key: 'conversation.chat.turnTail',
@@ -358,7 +359,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:196',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:203',
   },
   {
     key: 'conversation.composer',
@@ -537,7 +538,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:208',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:215',
   },
   {
     key: 'conversation.hero.agentPreset',
@@ -1025,7 +1026,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:184',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:191',
   },
   {
     key: 'conversation.session',
@@ -2115,6 +2116,45 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.workspaces.directoryFlow\', () => ctx.slots.register(\n      { name: \'sidebar.workspaces.directoryFlow\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
     source: 'packages/client/ui-workspace/src/client/contract/slots.ts:59',
   },
+  {
+    key: 'tool.call.images',
+    kind: 'single',
+    scope: 'session',
+    summary: 'Durable images of a settled image-bearing Tool call, rendered through the attachment presentation plugin.',
+    doc: 'Durable images of a settled image-bearing Tool call, rendered through\nthe attachment presentation plugin. The Tool layer never imports an\nattachment implementation: a toolview declares this slot as a child and\nrenders it with the image card\'s references plus the session-authorized\nloader it received in its owner, and the attachment plugin fills the\ngallery. Composing no attachment presentation plugin renders nothing,\nwhich is why the image card keeps its own envelope text beside the\ngallery.',
+    registerOptions: [],
+    ownerProps: [
+      '/** Owner currency of the Tool image gallery slot: references plus the loader. */\nexport interface ToolImagesOwnerProps {\n  /** Durable references or submission-echo previews in result order. */\n  images: readonly MessageImageSource[]\n  /** Session-authorized image URL loader for the durable arm. */\n  loadImage: MessageImageLoader\n  /** Horizontal placement inside the owning record. */\n  align: \'start\' | \'end\'\n}',
+    ],
+    ownerPropsReferences: [
+      'MessageImageLoader',
+      'MessageImageSource',
+    ],
+    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 \'tool.call.toolview\' (client-ui-tool), 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(\'tool.call.images\', () => ctx.slots.register(\n      { name: \'tool.call.images\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
+    source: 'packages/client/ui-tool/src/client/contract/slots.ts:37',
+  },
   {
     key: 'tool.call.toolview',
     kind: 'keyed',
@@ -2130,9 +2170,12 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       },
     ],
     ownerProps: [
-      '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n  /** Tool call identity, stable across running and settled forms. */\n  callId: string\n  /** Wire Tool name and keyed dispatch value. */\n  toolName: string\n  /** Frozen running call or settled result node. */\n  block: ToolCallBlock\n  /** Session workspace root for relative summaries. */\n  cwd?: string | undefined\n  /** Host account home; POSIX home-rooted summaries display as `~`. */\n  home?: string | undefined\n  /** Open a Tool argument path through the Host. */\n  openFile: (path: string) => void\n  /** Inspect this call in the trajectory view when available. */\n  inspect?: (() => void) | undefined\n}',
+      '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n  /** Tool call identity, stable across running and settled forms. */\n  callId: string\n  /** Wire Tool name and keyed dispatch value. */\n  toolName: string\n  /** Frozen running call or settled result node. */\n  block: ToolCallBlock\n  /** Session workspace root for relative summaries. */\n  cwd?: string | undefined\n  /** Host account home; POSIX home-rooted summaries display as `~`. */\n  home?: string | undefined\n  /** Open a Tool argument path through the Host. */\n  openFile: (path: string) => void\n  /**\n   * Session-authorized image loader for the `tool.call.images` slot, supplied\n   * by the chat node that owns this call. A composed chat node always\n   * supplies it (`ChatNodeOwnerProps.loadImage` is required), so the tool\n   * layer never imports an attachment implementation nor handles URL\n   * authorization.\n   */\n  loadImage: MessageImageLoader\n  /** Inspect this call in the trajectory view when available. */\n  inspect?: (() => void) | undefined\n}',
+    ],
+    ownerPropsReferences: [
+      'ChatNodeOwnerProps',
+      'MessageImageLoader',
     ],
-    ownerPropsReferences: [],
     standardProps: [
       'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
       'useSessions: UseSessions',
@@ -2147,7 +2190,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       'useProjection: UseProjection',
       'useTrajectory: UseTrajectory',
     ],
-    keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_run, cordis_stop, cordis_undefine, edit, glob, grep, read, skill, todo_write, web_fetch, web_search, write',
+    keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_run, cordis_stop, cordis_undefine, edit, glob, grep, read, read_image, skill, todo_write, web_fetch, web_search, write',
     hookContext: '',
     slotInject: '',
     declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-tool), so it exists while that entry is mounted',
@@ -2157,6 +2200,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       'client-ui-tool BashRow key \'bash\'',
       'client-ui-tool FileMutationRow key \'edit\'',
       'client-ui-tool FileMutationRow key \'write\'',
+      'client-ui-tool ReadImageRow key \'read_image\'',
       'client-ui-tool ReadRow key \'read\'',
       'client-ui-tool SearchRow key \'grep\'',
       'client-ui-tool SearchRow key \'glob\'',

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md
-README.md: 193f842aa76b7d3f4df717d1d23960ef4d3ee329
-README.zh.md: 3cbd6bec6d9a4b200d90c15b32291cacc2e41aa8
+README.md: 5a90287e21cd96fed3cf9b572ed2b84c5c25969f
+README.zh.md: 9ebf748e87015c6ec7b6ea94f174899e27d0a32b

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

@@ -239,7 +239,7 @@ These limits define when the tool suite is a poor fit or needs special operation
 - **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling `dsh-tool-fs-search` package supplies ripgrep-backed `glob` and `grep` rather than extending the filesystem seam.
 - **`read` handles UTF-8 text files only** — images use the separate extension-routed `read_image` tool; PDF, audio, and video remain deferred. A directory target is `FS_NOT_REGULAR_FILE`.
 - **Extension-declared media type** — the extension selects the declared type and the attachment store's magic-byte validation stays authoritative; a correctly formatted image under a wrong extension is refused with the rename remedy rather than sniffed.
-- **No inline image preview on the tool-result card** — UI surfaces render the image result generically (the durable reference, not pixels); inline rendering is deferred to the UI packages.
+- **Inline image preview rides the UI composition** — the tool-result card renders the image through the browser's `tool.call.images` slot, which the attachment presentation plugin fills; a UI without that plugin shows the result's envelope text instead.
 - **No attachment-region tool** — an agent may crop an image through another available tool when it has a filesystem path; a pasted or dragged image without a path cannot be re-read at higher resolution.
 - **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no timeout budget; cancellation rides `exec.signal` only ([provider rationale](../README.md)).
 

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

@@ -239,7 +239,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
 - **未交付面向模型的目录列表工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 `dsh-tool-fs-search` 包则提供基于 ripgrep 的 `glob` 与 `grep`,而不是扩展文件系统 seam。
 - **`read` 只处理 UTF-8 文本文件**:图像使用独立的、按扩展名路由的 `read_image` 工具;PDF、音频和视频仍延期处理。目录目标为 `FS_NOT_REGULAR_FILE`。
 - **媒体类型按扩展名声明**:扩展名选择声明类型,附件存储的魔数校验保持权威;扩展名错误但格式正确的图像会得到改名修复提示,而不是被嗅探接受。
-- **工具结果卡片没有内嵌图像预览**:UI 表面以通用形式渲染图像结果(持久引用而非像素);内嵌渲染延后到 UI 包处理
+- **内嵌图像预览依赖 UI 组合**:工具结果卡片经由浏览器的 `tool.call.images` 槽位渲染图像,由附件呈现插件填充;未组合该插件的 UI 改为显示结果的信封文本
 - **没有附件区域工具**:agent 在拥有文件系统路径时可以通过其他可用工具裁剪图片;没有路径的粘贴或拖入图片无法按更高分辨率重新读取。
 - **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明超时预算;取消只通过 `exec.signal` 传递(见[提供方理由](../README.zh.md))。
 

+ 9 - 0
packages/fs/tool-fs/src/read-image.ts

@@ -185,6 +185,15 @@ export function applyReadImageTool(ctx: Context): void {
         },
       },
       render: (_args, value) => imageReadContent(value),
+      // Persist the resolved path only. The attachment reference is NOT copied
+      // here: the settled `content` already carries the image block with the
+      // complete reference, so a second copy would keep two records of one fact —
+      // and a `tools/post-execute` hook that legitimately replaces the content
+      // would leave the stale copy behind, showing an image the result no longer
+      // returns. The path needs its own structured record because the content
+      // carries it only as model-facing envelope text, which the client does not
+      // parse.
+      presentationMeta: (_args, value) => ({ path: value.path }),
     },
     // Content-addressed attachment writes are idempotent, so concurrent reads
     // of the same file cannot conflict.

+ 68 - 0
packages/fs/tool-fs/tests/read-image.spec.ts

@@ -34,6 +34,8 @@ import {
 const PNG_1X1 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64')
 /** 3x3 red PNG used to trip a tiny configured pixel limit. */
 const PNG_3X3 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAIAAADZSiLoAAAAEElEQVR4nGP4z8AAQQxYWACPjgj4kWPEuQAAAABJRU5ErkJggg==', 'base64')
+/** 1x1 red GIF (GIF89a). */
+const GIF_1X1 = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64')
 
 const testToolSignal = new AbortController().signal
 
@@ -206,6 +208,37 @@ describe('read_image happy path', () => {
     expect(Buffer.from(stored.data)).toEqual(PNG_1X1)
   })
 
+  it('commits a GIF durably and renders the normalized envelope beside an image block', async () => {
+    await writeFile(join(dir, 'red.gif'), GIF_1X1)
+    const ctx = await setup()
+    const result = await readImage(ctx, { file_path: 'red.gif' }, agentOn('vision-model'))
+
+    expect(result.isError).toBe(false)
+    expect(result.content).toHaveLength(2)
+    const image = result.content[1] as { type: string; attachment: ImageAttachmentRef }
+    expect(image.type).toBe('image')
+    // GIFs do not pass through unchanged: normalization re-encodes this
+    // transparent 1x1 GIF as WebP while keeping the source file name.
+    expect(image.attachment.mediaType).toBe('image/webp')
+    expect(image.attachment.width).toBe(1)
+    expect(image.attachment.height).toBe(1)
+    expect(image.attachment.bytes).toBe(72)
+    expect(image.attachment.name).toBe('red.gif')
+    expect(image.attachment.attachmentId).toMatch(/^sha256:[0-9a-f]{64}$/)
+    expect(text(result)).toBe(formatImageReadOutput(join(dir, 'red.gif'), {
+      attachmentId: image.attachment.attachmentId,
+      mediaType: 'image/webp',
+      bytes: 72,
+      width: 1,
+      height: 1,
+    }))
+
+    const attachments = ctx.get('attachments')
+    if (attachments === undefined) throw new Error('expected the attachment service')
+    const stored = await attachments.readImage(image.attachment)
+    expect(Buffer.from(stored.data).subarray(0, 4).toString()).toBe('RIFF')
+  })
+
   it('emits fs/observed for the read image', async () => {
     await writeFile(join(dir, 'red.png'), PNG_1X1)
     const ctx = await setup()
@@ -614,3 +647,38 @@ describe('read keeps its text-only contract', () => {
     expect(text(txt)).toContain('<type>file</type>')
   })
 })
+
+describe('image result presentation', () => {
+  /** A canonical committed reference, shaped like a real saveImage outcome. */
+  const REF = {
+    attachmentId: `sha256:${'a'.repeat(64)}`,
+    mediaType: 'image/png' as const,
+    bytes: 24_588,
+    width: 1496,
+    height: 260,
+    name: 'card.png',
+  }
+  const VALUE = { path: '/w/app/shots/card.png', image: REF }
+
+  it('persists the path only, leaving the reference to the result content', async () => {
+    // The settled content already carries the image block with the complete
+    // reference, so copying it into meta would keep two records of one fact and a
+    // post-execute content replacement would strand the stale copy.
+    const ctx = await setup()
+    const meta = ctx.tools.get('read_image')?.output.presentationMeta?.({ file_path: 'shots/card.png' }, VALUE)
+    expect(meta).toEqual({ path: VALUE.path })
+  })
+
+  it('carries the committed reference in the result content, not in meta', async () => {
+    // Proves the single source of truth on the path a live call actually takes.
+    await writeFile(join(dir, 'red.png'), PNG_1X1)
+    const ctx = await setup()
+    const result = await call(ctx, 'read_image', { file_path: 'red.png' }, agentOn('vision-model'))
+    expect(result.isError).toBe(false)
+    expect(result.meta).toEqual({ path: join(dir, 'red.png') })
+    const image = result.content.find(block => block.type === 'image')
+    expect(image?.attachment.width).toBe(1)
+    expect(image?.attachment.height).toBe(1)
+    expect(image?.attachment.attachmentId).toMatch(/^sha256:/u)
+  })
+})

+ 6 - 0
pnpm-lock.yaml

@@ -1944,6 +1944,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-client-ui-tool':
+        specifier: workspace:^
+        version: link:../ui-tool
       '@deepseek-ai/dsh-client-ui-trajectory':
         specifier: workspace:^
         version: link:../ui-trajectory
@@ -3521,6 +3524,9 @@ importers:
       '@deepseek-ai/dsh-api-workspace-controller':
         specifier: workspace:^
         version: link:../../api/workspace-controller
+      '@deepseek-ai/dsh-attachment':
+        specifier: workspace:^
+        version: link:../../attachment/attachment
       '@deepseek-ai/dsh-client-connection':
         specifier: workspace:^
         version: link:../connection

+ 1 - 1
snapshots/session/read-image-dimension/session.jsonl

@@ -17,7 +17,7 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15],"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":1,"callId":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}}
-{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-dimension"},"content":[{"type":"tool-result","toolCallId":"read-image-dimension","content":[{"type":"text","text":"<path>{{cwd}}/wide.png</path>\n<type>image</type>\n<content>\nimage/png image, 2001x1 px, 133 bytes\n</content>"},{"type":"image","attachment":{"attachmentId":"sha256:0333f95051f5c038cab720d90112f1775e9ff1f8f7dddc86653e80ff241c5720","mediaType":"image/png","bytes":133,"width":2001,"height":1,"name":"wide.png"}}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[17],"surfaceOp":"append"}
+{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-dimension"},"content":[{"type":"tool-result","toolCallId":"read-image-dimension","content":[{"type":"text","text":"<path>{{cwd}}/wide.png</path>\n<type>image</type>\n<content>\nimage/png image, 2001x1 px, 133 bytes\n</content>"},{"type":"image","attachment":{"attachmentId":"sha256:0333f95051f5c038cab720d90112f1775e9ff1f8f7dddc86653e80ff241c5720","mediaType":"image/png","bytes":133,"width":2001,"height":1,"name":"wide.png"}}],"isError":false}],"role":"user","id":"{{message:4}}"},"meta":{"path":"{{cwd}}/wide.png"}},"sourceEventSeqs":[17],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"step/start","data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}

+ 29 - 0
snapshots/session/read-image-gif/session.jsonl

@@ -0,0 +1,29 @@
+{"type":"session","version":0,"id":"{{session:1}}","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0}
+{"type":"permission/preset","data":{"preset":"danger-full-access"}}
+{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
+{"type":"approval/policy","data":{"policy":"never"}}
+{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image to look at red.gif in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
+{"type":"turn/start","data":{"turn":1}}
+{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
+{"type":"step/start","data":{"turn":1,"step":1}}
+{"type":"user/message","data":{"content":[{"type":"text","text":"Use read_image to look at red.gif in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
+{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"}
+{"type":"session/title","data":{"title":"Use read_image to look at","messageSeqs":[7],"source":{"kind":"fallback"}}}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-gif-call","name":"read_image","arguments":"{\"file_path\":\"red.gif\"}"}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-gif-call","name":"read_image","arguments":"{\"file_path\":\"red.gif\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15],"surfaceOp":"append"}
+{"type":"tool/call","data":{"turn":1,"step":1,"callId":"read-image-gif-call","name":"read_image","arguments":"{\"file_path\":\"red.gif\"}"}}
+{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-gif-call"},"content":[{"type":"tool-result","toolCallId":"read-image-gif-call","content":[{"type":"text","text":"<path>{{cwd}}/red.gif</path>\n<type>image</type>\n<content>\nimage/webp image, 1x1 px, 72 bytes\n</content>"},{"type":"image","attachment":{"attachmentId":"sha256:a401bf42b76acb849a8a0852f293b536d7b0fc6433aa5bf3c74afe1576203878","mediaType":"image/webp","bytes":72,"width":1,"height":1,"name":"red.gif"}}],"isError":false}],"role":"user","id":"{{message:4}}"},"meta":{"path":"{{cwd}}/red.gif"}},"sourceEventSeqs":[17],"surfaceOp":"append"}
+{"type":"step/end","data":{"turn":1,"step":1}}
+{"type":"step/start","data":{"turn":1,"step":2}}
+{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[21,22,23,24],"surfaceOp":"append"}
+{"type":"step/end","data":{"turn":1,"step":2}}
+{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 7 - 0
snapshots/session/read-image-gif/snapshot.yml

@@ -0,0 +1,7 @@
+version: 1
+scenario: read-image-gif
+profile: headless
+composition: image
+recording: authored
+header:
+  class: image

BIN
snapshots/session/read-image-gif/workspace/red.gif


+ 1 - 1
snapshots/session/read-image-reencode/session.jsonl

@@ -17,7 +17,7 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-reencode-call","name":"read_image","arguments":"{\"file_path\":\"gradient.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15],"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":1,"callId":"read-image-reencode-call","name":"read_image","arguments":"{\"file_path\":\"gradient.png\"}"}}
-{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-reencode-call"},"content":[{"type":"tool-result","toolCallId":"read-image-reencode-call","content":[{"type":"text","text":"<path>{{cwd}}/gradient.png</path>\n<type>image</type>\n<content>\nimage/jpeg image, 840x840 px, 10162 bytes\n</content>"},{"type":"image","attachment":{"attachmentId":"sha256:a9725ac66134512fc8e8971cb0356f9d217529493073d361724ca87c9c7aa968","mediaType":"image/jpeg","bytes":10162,"width":840,"height":840,"name":"gradient.png"}}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[17],"surfaceOp":"append"}
+{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-reencode-call"},"content":[{"type":"tool-result","toolCallId":"read-image-reencode-call","content":[{"type":"text","text":"<path>{{cwd}}/gradient.png</path>\n<type>image</type>\n<content>\nimage/jpeg image, 840x840 px, 10162 bytes\n</content>"},{"type":"image","attachment":{"attachmentId":"sha256:a9725ac66134512fc8e8971cb0356f9d217529493073d361724ca87c9c7aa968","mediaType":"image/jpeg","bytes":10162,"width":840,"height":840,"name":"gradient.png"}}],"isError":false}],"role":"user","id":"{{message:4}}"},"meta":{"path":"{{cwd}}/gradient.png"}},"sourceEventSeqs":[17],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"step/start","data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}

+ 1 - 1
snapshots/session/read-image/session.jsonl

@@ -17,7 +17,7 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15],"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":1,"callId":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}
-{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-call"},"content":[{"type":"tool-result","toolCallId":"read-image-call","content":[{"type":"text","text":"<path>{{cwd}}/red.png</path>\n<type>image</type>\n<content>\nimage/png image, 1x1 px, 69 bytes\n</content>"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[17],"surfaceOp":"append"}
+{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-call"},"content":[{"type":"tool-result","toolCallId":"read-image-call","content":[{"type":"text","text":"<path>{{cwd}}/red.png</path>\n<type>image</type>\n<content>\nimage/png image, 1x1 px, 69 bytes\n</content>"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"isError":false}],"role":"user","id":"{{message:4}}"},"meta":{"path":"{{cwd}}/red.png"}},"sourceEventSeqs":[17],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"step/start","data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}