Tool-card image results
工具卡片的图像结果
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 ca
English
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), 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 — a nested call (a read_image dispatched from inside run_code) persists no meta, so the call's own file_path argument fills the label — 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 toToolResultViewand apresentResult. 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 ofpresentationMetaalone. - Pass a rendering closure down (the
renderMessageImagespattern). The next version reusedChatNodeOwnerProps.renderMessageImagesas arenderImagesowner prop, mirroring whatAssistantMarkdownand 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. WithloadImagedown-threaded from the chat node, the row renderstool.call.imagesdirectly 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
singleResultTextlike 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
metaas well. The first version did, and it read the card from there. Review pointed out the duplication, and the recorded log confirmed it:meta.imageand the content block'sattachmentwere 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 ofAttachmentIdand 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 declines, the nested-call derivation with its argument-path fallback, 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 read_image result now renders as the image on the tool card for both a top-level call and a nested one (a call dispatched from inside run_code), and the tool card gains an image kind. A nested call already displayed its image on the message path — execute defers a real user message for it — but its own tool row stayed generic; the card derivation now covers it too, with the call's file_path argument standing in for the persisted path. 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.
中文
问题
已结算的 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),因此新增一个 result-view 分支等于扩展一个无人读取的封闭公共联合。
客户端侧。 imageCardModel 按其他所有第一方卡片的方式派生:parsedToolCall 校验调用头与其 file_path,block.meta 提供路径——嵌套调用(从 run_code 内部派发的 read_image)不持久化 meta,于是用调用自身的 file_path 参数补足标签——附件引用从结果自己的 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作为renderImagesowner 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 的结果现在在工具卡片上渲染为图像,顶层调用与嵌套调用(从 run_code 内部派发的调用)皆然;工具卡片获得一种图像种类。嵌套调用此前已在消息路径上显示图像——execute 会为它 defer 一条真正的用户消息——但它自己的工具行仍是 generic;现在卡片派生也覆盖它,用调用自身的 file_path 参数替代持久化路径。图像种类并不由元数据单独自动产生:卡片还要求 tool.call.images 槽位被填充(附件呈现插件),并且工具注册按 key 的 toolview——因为模型把调用头收窄到 read_image,槽位只能从声明的子 entry 渲染。
持久化的呈现元数据为每次图像读取在会话日志中增加一条很小的 { path } 记录。附件引用完全不在元数据里——它位于已结算结果 content 的 image 块中;图像字节本身从不进入日志,因为存储是内容寻址的,块里只携带附件 id。
由于卡片从 block.meta 派生,本次改动之前记录的会话没有图像元数据,会以通用文本卡片重放。这是每个从原始 event 派生的卡片都遵循的既有降级路径,不是这里的特例。