Ver código fonte

feat(ui-tool): render the image card for nested read_image calls

Chinesezjc 3 semanas atrás
pai
commit
56ca8af0ee

+ 2 - 2
.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.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-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
+2026-08-20-tool-card-image-results.md: 7659bc46858b0d65c300e51020867a7c156c4d11
+2026-08-20-tool-card-image-results.zh.md: 10f4b3182d8302ce34660b6b4abf6bd47d968baf

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

@@ -20,7 +20,7 @@ The attachment reference is deliberately not persisted there. The settled `conte
 
 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.
+**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.
 
@@ -44,13 +44,13 @@ The card keeps the derived envelope text below the gallery. That is not redundan
 
 ## 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.
+`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 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.
+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.
 

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

@@ -20,7 +20,7 @@ Status: implemented
 
 不加 `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]`——按形状匹配同时意味着其他层前置的内容永远不会被误认为信封。
+**客户端侧。** `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,并让该部署中每个图像卡片静默降级。
 
@@ -44,13 +44,13 @@ Status: implemented
 
 ## 验证
 
-`read-image.spec.ts` 覆盖元数据投影、省略显示名,以及一次真实执行——其持久化的引用与附件存储实际提交的一致。`image-card.client.spec.tsx` 覆盖从元数据与信封的派生、路径相对化、来自替代存储的不透明 id、防御式 narrowing 的每个拒绝分支、running/error/嵌套三种拒绝、按 key 的行渲染点(携带 loader 分发 `tool.call.images`)、带子槽声明的按 key 注册,以及空槽位降级。
+`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 渲染。
+`read_image` 的结果现在在工具卡片上渲染为图像,顶层调用与嵌套调用(从 `run_code` 内部派发的调用)皆然;工具卡片获得一种图像种类。嵌套调用此前已在消息路径上显示图像——`execute` 会为它 defer 一条真正的用户消息——但它自己的工具行仍是 generic;现在卡片派生也覆盖它,用调用自身的 `file_path` 参数替代持久化路径。图像种类并不由元数据单独自动产生:卡片还要求 `tool.call.images` 槽位被填充(附件呈现插件),并且工具注册按 key 的 toolview——因为模型把调用头收窄到 `read_image`,槽位只能从声明的子 entry 渲染。
 
 持久化的呈现元数据为每次图像读取在会话日志中增加一条很小的 `{ path }` 记录。附件引用完全不在元数据里——它位于已结算结果 content 的 image 块中;图像字节本身从不进入日志,因为存储是内容寻址的,块里只携带附件 id。
 

+ 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: 3461f6a90f791cccad968604b6cdd83535d15647
-README.zh.md: d2eb38b8382f56c77827540309237e876623df33
+README.md: ee2eabb732f192e4b119421a9fe10dd1a8d93807
+README.zh.md: f2d8c778b1bb84e381bb638f6e85829e41e252da

+ 24 - 21
packages/client/ui-tool/src/client/tool/models/image-card-model.ts

@@ -31,6 +31,10 @@ export interface ImageCardModel {
 /**
  * The persisted `presentationMeta` this card reads: the authored path only.
  *
+ * Root calls persist it; a nested call (a read_image dispatched from inside
+ * run_code) settles without `meta`, so the card falls back to the call's own
+ * `file_path` argument for the label.
+ *
  * 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
@@ -68,29 +72,18 @@ 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.
+ * to the generic card rather than throwing. An absent `meta` (a nested call
+ * persists none) leaves the path to the call's own `file_path` argument.
  *
  * 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.
+ * @returns the narrowed path, or null when it does not match.
  */
 function imageMeta(meta: unknown): ImageMeta | null {
   if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return null
@@ -195,11 +188,14 @@ function fullyRendered(content: readonly unknown[]): boolean {
 }
 
 /**
- * Derive a settled root image card after validating its persisted metadata and
- * model-facing image envelope.
+ * Derive a settled image card after validating the call head, persisted
+ * metadata (or its argument fallback), and the 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.
+ * Both root and nested calls settle as ToolResultNode; the nested one (a
+ * read_image dispatched from inside run_code) persists no presentationMeta, so
+ * its label falls back to the call's own `file_path` argument.
  * @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.
@@ -211,10 +207,17 @@ export function imageCardModel(
   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
+  // Result-side only: a running call is not a ToolResultNode and carries no
+  // content, so it has no card here.
+  if (!('kind' in block) || block.isError) return null
+  const call = parsedToolCall(block)
+  if (call?.name !== 'read_image') return null
+  const { file_path: filePath } = call.args
+  if (typeof filePath !== 'string' || filePath.trim() === '') return null
+  // The label path: root calls persist it in presentationMeta; a nested call
+  // persists none, so its own file_path argument fills the label.
+  const metaPath = imageMeta(block.meta)?.path
+  const path = metaPath ?? filePath
   // 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
@@ -225,7 +228,7 @@ export function imageCardModel(
   const text = imageTexts(block.content)
   if (text === null) return null
   return {
-    label: abbreviateHomePath(relativizeToCwd(meta.path, sessionCwd), home),
+    label: abbreviateHomePath(relativizeToCwd(path, sessionCwd), home),
     images: refs.map(ref => ({ attachment: ref })),
     text,
   }

+ 19 - 7
packages/client/ui-tool/tests/image-card.client.spec.tsx

@@ -192,13 +192,16 @@ describe('imageCardModel', () => {
     }
   })
 
-  it('declines malformed metadata instead of throwing', () => {
+  it('falls back to the call argument when metadata is malformed, 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.
+    // must never crash the tool message. A call whose meta does not match still
+    // carries a usable file_path argument, which fills the label; only a call
+    // with no usable argument declines (covered by the call-head case below).
     for (const meta of [undefined, null, 'meta', [{ path: 'a.png' }], {}, { path: '' }, { path: 7 }]) {
       expect(() => imageCardModel(settled({ meta }))).not.toThrow()
-      expect(imageCardModel(settled({ meta }))).toBeNull()
+      const card = imageCardModel(settled({ meta }))
+      expect(card).not.toBeNull()
+      expect(card?.label).toBe('shots/card.png')
     }
   })
 
@@ -237,11 +240,20 @@ describe('imageCardModel', () => {
     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.
+  it('declines a running call and an error result, and derives a nested call from its own path', () => {
+    // The image card is result-side only: a running call has no content.
     expect(imageCardModel(running())).toBeNull()
     expect(imageCardModel(settled({ isError: true }))).toBeNull()
-    expect(imageCardModel(settled({ parentCallId: 'parent' }))).toBeNull()
+    // A nested call (a read_image dispatched from inside run_code) settles as a
+    // ToolResultNode too and renders the card; it persists no presentationMeta,
+    // so the label falls back to the call's file_path argument.
+    const nested = imageCardModel(settled({ parentCallId: 'parent', meta: undefined }))
+    expect(nested).not.toBeNull()
+    expect(nested?.label).toBe('shots/card.png')
+    expect(nested?.images).toHaveLength(1)
+    // Persisted meta still wins over the argument when a nested call has one.
+    const withMeta = imageCardModel(settled({ parentCallId: 'parent', meta: { path: 'shots/persisted.png' } }))
+    expect(withMeta?.label).toBe('shots/persisted.png')
   })
 
   it('declines a call head that is not read_image', () => {

+ 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: 5a90287e21cd96fed3cf9b572ed2b84c5c25969f
-README.zh.md: 9ebf748e87015c6ec7b6ea94f174899e27d0a32b
+README.md: 6792ad6ef45623d9d0ad43275154954283e172db
+README.zh.md: b891eebace4a5a3d6ec28c82cd630d20a5842187

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

@@ -244,6 +244,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
 - **对象路径重新走源准入**:对规范化附件对象调用 `read_image` 会把其字节作为新来源重新准入,因此把 `maxImageBytes`/`maxMessageImageBytes` 配置得低于规范化图片字节预算的部署可能拒绝 `ctx.attachments.readImage` 仍可读取的对象路径;默认配置下规范化预算(4 MiB)远低于源上限(20 MiB)。
 - **内嵌图像预览依赖 UI 组合**:工具结果卡片经由浏览器的 `tool.call.images` 槽位渲染图像,由附件呈现插件填充;未组合该插件的 UI 改为显示结果的信封文本。
 **没有附件区域工具**:agent 在拥有文件系统路径时可以通过其他可用工具裁剪图片;没有路径的粘贴或拖入图片无法按更高分辨率重新读取。
+- **没有附件区域工具**:agent 在拥有文件系统路径时可以通过其他可用工具裁剪图片;没有路径的粘贴或拖入图片无法按更高分辨率重新读取。
 - **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明超时预算;取消只通过 `exec.signal` 传递(见[提供方理由](../README.zh.md))。
 
 <a id="dev-note"></a>