feat(fs): add a search render-intent card for grep and glob results
grep and glob returned only model-facing text; the structured matches/paths
never reached the client. Add a card:'search' result view with a kind
discriminant ('matches' grouped by file for grep, 'paths' for glob), projected
through each tool's output.presentationMeta and read back in presentResult. The
projections re-apply the same inline cap and per-line budget as the render text
and report total + truncated, so a UI never presents a capped page as complete.
A UI without the search card falls back to content; the TUI is unchanged. The
web consumer is a follow-up.
This commit is contained in:
@@ -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-07-30-search-render-card.md
|
||||
2026-07-30-search-render-card.md: de59992cebcdf056e3446e4f546f8bff4b10e421
|
||||
2026-07-30-search-render-card.zh.md: 8b91255094c24972c05add92ee65f8c76c60a882
|
||||
@@ -0,0 +1,51 @@
|
||||
# Agent Note: Search render intent — grep and glob emit a structured search card
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-search-render-card.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`grep` and `glob` return structured canonical values — `grep` a flat `{ matches: [{ path, lineNumber, line }] }`, `glob` a `{ paths: string[] }` — but every UI only ever saw their model-facing render text: `grep` groups its matches under file headers with `Line N:` rows, `glob` prints a newline-joined path list, and both append a spill footer when the inline cap ({@link module:@deepseek-ai/dsh-tool-fs-search/grep} `grepMaxMatches`, default 250; {@link module:@deepseek-ai/dsh-tool-fs-search/glob} `globMaxResults`, default 100) drops later results to a spill file. A web frontend that wants to render a search result as an expandable per-file group of matches, or as a selectable path list, had to re-parse that text. Both tools already declared a call-time [render intent](../architecture/2026-07-02-tool-render-intent-union.md) (`GenericCallView`, `kind: 'search'`) but no result-time view, so the completed call fell back to the generic card that renders the raw text.
|
||||
|
||||
The structured canonical value does not cross the wire: only the model-facing render text and, when a tool declares `output.presentationMeta`, a JSON metadata payload reach the client, threaded through the `tool/result` event ([canonical-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md)). A result-time view carrying structured data therefore has to project that data into `presentationMeta` and read it back in `presentResult` — the same path `write`/`edit` use for their diff cards.
|
||||
|
||||
## Decision
|
||||
|
||||
`packages/core/tools/src/presentation.ts` adds `card: 'search'` to the `ToolResultView` union as `SearchResultView`, a `kind`-discriminated view that expresses both tools' shapes: `SearchMatchesResultView` (`kind: 'matches'`) carries `grep`'s matches grouped by file as `files: { path, matches: { lineNumber, line }[] }[]`, and `SearchPathsResultView` (`kind: 'paths'`) carries `glob`'s flat `paths: string[]`. Both carry `truncated: boolean` and `total: number`, and an optional `content?: ContentBlock[]`.
|
||||
|
||||
One view with two shapes rather than two cards, because both tools are the same visual object — a search result — and a web consumer switches on one `card` value, then on `kind` for the row shape. The discriminated `kind` keeps each shape's fields non-optional (a matches view always has `files`, a paths view always has `paths`) instead of a single interface where every shape-specific field is optional.
|
||||
|
||||
The card tag is result-time only. A search call stays a `GenericCallView` (`kind: 'search'`): the pending state has no matches or paths to show, so there is nothing a `SearchCallView` would carry that the generic title does not. This is the asymmetry with the terminal card, whose call view carries the command, cwd, and description that exist before execution; a search's structured content exists only after `execute`.
|
||||
|
||||
`packages/fs/tool-fs-search/src/presentation.ts` owns the projection and the narrowing. `grepSearchMeta`/`globSearchMeta` project the canonical value into a `SearchMeta` payload each tool declares as `output.presentationMeta`; `presentGrepResult`/`presentGlobResult` read `result.meta` back through `searchViewFromMeta` and attach the model-facing `result.content` as the view's `content`. The projections apply the SAME inline cap and per-line preview budget the model-facing render applies, and report `total` as every result the search found (before capping) with `truncated` set when the cap dropped results. This is the truncation-honesty point: the model saw a capped inline result plus a spill footer, so the card must not present the retained page as the complete result — a UI reads `truncated`/`total` to show a capped indicator rather than claiming completeness the model never had.
|
||||
|
||||
`searchViewFromMeta` narrows the opaque `meta` defensively and returns `undefined` on any malformed or absent payload, exactly as `diffsFromMeta` does, so a presenter run on an older or hand-edited replayed log falls back to the generic card instead of throwing. `presentResult` returns `undefined` for a failed result, for absent meta (a nested `run_code` dispatch computes no `presentationMeta`), and for the other tool's meta shape (each presenter narrows to its own `kind`).
|
||||
|
||||
The `SearchMeta` member shapes are object-literal `type` aliases, not the `SearchFileMatches`/`SearchLineMatch` interfaces the view exposes. Only a type alias is assignable to the `JsonValue` index signature `presentationMeta` returns; the two are structurally identical, so the projected value still reads back as a `SearchResultView`.
|
||||
|
||||
The TUI (`packages/ui/tui/src/components/transcript.ts`) needs no dedicated arm: its result-view switch handles `terminal` and `diff` explicitly and falls through to a generic arm that renders `view.content ?? this.result?.content`. Because `SearchResultView` carries the model-facing text as `content`, the TUI renders it as the same text it already showed. The web frontend that renders the structured `files`/`paths` shape is a separate later PR; this PR is the backend contract and its two producers.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A single flat `SearchResultView` interface with optional `files?` and `paths?`.** Rejected: it makes both shape-specific fields optional on every value and lets a malformed view carry both or neither. The `kind` discriminant keeps each shape's fields required and lets a consumer switch exhaustively.
|
||||
|
||||
**A call-time `SearchCallView` mirroring the terminal card's both-sides symmetry.** Rejected: a search call has no matches or paths before `execute`, so the view would carry only the title the `GenericCallView` already carries. The terminal card's call view earns its tag because a command, cwd, and description exist at call time; a search's structured content does not.
|
||||
|
||||
**Carry the structured result in a bespoke channel instead of `presentationMeta`.** Rejected: the canonical value is execution-local and never reaches the client, and `presentationMeta` is the established seam that persists a tool's JSON presentation payload with `tool/result` and threads it back to `presentResult`. Adding a second channel would duplicate that path.
|
||||
|
||||
## Consequences
|
||||
|
||||
`grep` and `glob` now compute `presentationMeta` on every non-nested successful call, a bounded projection over the already-parsed matches or paths. The projection re-applies the retention cap the render already applied, so the retained set is computed twice per call; the input is bounded by the raw-output cap, so this is not a new scaling concern.
|
||||
|
||||
A UI without a search card renders the attached `content` text, so no consumer regresses. The web consumer that renders the structured shape reads `truncated`/`total` and the per-file groups; because the view carries only the retained page, a UI wanting the complete result follows the spill locator in the model-facing text, exactly as the model does.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/fs/tool-fs-search/tests/presentation.spec.ts` pins the pure layer: `groupMatchesByFile`'s first-seen file order, `grepSearchMeta`/`globSearchMeta` projection with the cap applied and `total` reporting the pre-cap count, the per-line preview budget on a projected match line, and `searchViewFromMeta`'s narrowing of both good shapes plus every malformed case (non-object/array meta, missing or mistyped `truncated`/`total`, unknown `kind`, malformed `files` entries, non-string `paths`). `packages/fs/tool-fs-search/tests/tools.spec.ts` pins the wiring through the real tool registry: a capped `grep`/`glob` execute produces the `SearchMeta` on `result.meta` and `presentResult` builds the search view with `content` attached, a nested `run_code` dispatch computes no meta so `presentResult` falls back, and a failed or cross-shape or malformed result falls back to the generic card. Per-file 100% coverage holds over the search package `src`.
|
||||
|
||||
## Related
|
||||
|
||||
- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this extends with the `search` result tag.
|
||||
- [Canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) — the value/render/`presentationMeta` split this projection rides; the structured value stays execution-local, the card rides `meta`.
|
||||
- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors on the backend: a tool projects its result into `presentationMeta` and a `presentResult` view; the search card's web consumer is the analogous follow-up.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Agent Note: Search render intent — grep and glob emit a structured search card
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-search-render-card.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`grep` 与 `glob` 返回结构化的规范值——`grep` 是扁平的 `{ matches: [{ path, lineNumber, line }] }`,`glob` 是 `{ paths: string[] }`——但每一个 UI 见到的只有它们面向模型的渲染文本:`grep` 把匹配按文件分组,文件头下是 `Line N:` 行;`glob` 打印换行连接的路径列表;当内联上限({@link module:@deepseek-ai/dsh-tool-fs-search/grep} `grepMaxMatches`,默认 250;{@link module:@deepseek-ai/dsh-tool-fs-search/glob} `globMaxResults`,默认 100)把后续结果溢出到 spill 文件时,两者都追加一段溢出脚注。想把搜索结果渲染成可展开的按文件分组匹配、或渲染成可选择的路径列表的 web 前端,只能去重新解析这段文本。两个工具都已声明了调用期的[渲染意图](../architecture/2026-07-02-tool-render-intent-union.md)(`GenericCallView`,`kind: 'search'`),但没有结果期视图,于是已完成的调用回退到渲染原始文本的通用卡片。
|
||||
|
||||
结构化的规范值不过线:只有面向模型的渲染文本、以及当工具声明 `output.presentationMeta` 时的一段 JSON 元数据抵达客户端,二者通过 `tool/result` 事件穿线([规范输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md))。因此携带结构化数据的结果期视图必须把该数据投射进 `presentationMeta`,再在 `presentResult` 里读回——正是 `write`/`edit` 的 diff 卡片所走的路径。
|
||||
|
||||
## Decision
|
||||
|
||||
`packages/core/tools/src/presentation.ts` 向 `ToolResultView` 联合类型加入 `card: 'search'`,即 `SearchResultView`:一个以 `kind` 区分的视图,表达两个工具的形状。`SearchMatchesResultView`(`kind: 'matches'`)以 `files: { path, matches: { lineNumber, line }[] }[]` 携带 `grep` 按文件分组的匹配;`SearchPathsResultView`(`kind: 'paths'`)携带 `glob` 的扁平 `paths: string[]`。两者都携带 `truncated: boolean` 与 `total: number`,以及可选的 `content?: ContentBlock[]`。
|
||||
|
||||
一个视图两种形状,而非两张卡片,因为两个工具是同一个视觉对象——一个搜索结果——web 消费方先在一个 `card` 值上分派,再在 `kind` 上分派行的形状。区分性的 `kind` 让每种形状各自的字段保持非可选(matches 视图恒有 `files`,paths 视图恒有 `paths`),而不是让所有形状相关字段都变成可选的单一接口。
|
||||
|
||||
卡片标签只在结果期。搜索调用仍是 `GenericCallView`(`kind: 'search'`):pending 状态没有匹配或路径可展示,因此 `SearchCallView` 能携带的东西不会超出通用标题。这是与 terminal 卡片的不对称之处——terminal 的调用视图携带执行前就存在的命令、cwd 与描述;而搜索的结构化内容只在 `execute` 之后才存在。
|
||||
|
||||
`packages/fs/tool-fs-search/src/presentation.ts` 拥有投射与收窄。`grepSearchMeta`/`globSearchMeta` 把规范值投射为一段 `SearchMeta`,各工具将其声明为 `output.presentationMeta`;`presentGrepResult`/`presentGlobResult` 通过 `searchViewFromMeta` 把 `result.meta` 读回,并把面向模型的 `result.content` 作为视图的 `content` 附上。投射施加与面向模型渲染相同的内联上限与每行预览预算,并把 `total` 报告为搜索找到的全部结果(截断之前),当上限丢弃了结果时把 `truncated` 置为真。这就是截断诚实性的要点:模型看到的是被截断的内联结果加一段溢出脚注,因此卡片不得把保留的那一页当作完整结果呈现——UI 读取 `truncated`/`total` 去展示截断指示,而非宣称模型从未拥有的完整性。
|
||||
|
||||
`searchViewFromMeta` 防御性地收窄不透明的 `meta`,对任何畸形或缺失的 payload 返回 `undefined`,与 `diffsFromMeta` 完全一致,因此在较旧或手工编辑过的回放日志上运行的呈现器会回退到通用卡片而非抛错。`presentResult` 对失败结果、对缺失的 meta(嵌套 `run_code` 分发不计算 `presentationMeta`)、对另一个工具的 meta 形状(每个呈现器只收窄到自己的 `kind`)都返回 `undefined`。
|
||||
|
||||
`SearchMeta` 的成员形状是对象字面量 `type` 别名,而不是视图对外暴露的 `SearchFileMatches`/`SearchLineMatch` 接口。只有 type 别名可以赋值给 `presentationMeta` 返回的 `JsonValue` 索引签名;二者结构完全相同,因此投射出的值仍能读回为 `SearchResultView`。
|
||||
|
||||
TUI(`packages/ui/tui/src/components/transcript.ts`)无需专用分支:它的结果视图 switch 显式处理 `terminal` 与 `diff`,并落到一个渲染 `view.content ?? this.result?.content` 的通用分支。因为 `SearchResultView` 以 `content` 携带了面向模型的文本,TUI 渲染出的仍是它此前已展示的同一段文本。渲染结构化 `files`/`paths` 形状的 web 前端是后续独立的 PR;本 PR 是后端契约及其两个生产者。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**单一扁平的 `SearchResultView` 接口,带可选的 `files?` 与 `paths?`。** 否决:它让两种形状相关字段在每个值上都成为可选,并允许一个畸形视图同时携带二者或都不携带。`kind` 区分符让每种形状的字段保持必填,并让消费方能穷尽分派。
|
||||
|
||||
**一个调用期的 `SearchCallView`,镜像 terminal 卡片两侧对称。** 否决:搜索调用在 `execute` 之前没有匹配或路径,视图只会携带 `GenericCallView` 已携带的标题。terminal 卡片的调用视图之所以配得上其标签,是因为命令、cwd 与描述在调用期就存在;而搜索的结构化内容不存在。
|
||||
|
||||
**用一个专门的通道而非 `presentationMeta` 携带结构化结果。** 否决:规范值是执行局部的、绝不抵达客户端,而 `presentationMeta` 是既有的接缝,它把工具的 JSON 呈现 payload 随 `tool/result` 持久化并穿线回 `presentResult`。再加一条通道只会重复这条路径。
|
||||
|
||||
## Consequences
|
||||
|
||||
`grep` 与 `glob` 现在在每次非嵌套的成功调用上计算 `presentationMeta`,这是对已解析的匹配或路径做的一次有界投射。投射重新施加渲染已施加过的保留上限,因此每次调用会计算两遍保留集;输入受原始输出上限约束,故这不是新的伸缩性问题。
|
||||
|
||||
没有搜索卡片的 UI 渲染附上的 `content` 文本,因此没有消费方回退。渲染结构化形状的 web 消费方读取 `truncated`/`total` 与按文件分组;因为视图只携带保留的那一页,想要完整结果的 UI 沿面向模型文本里的 spill 定位符去取,与模型的做法完全一致。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/fs/tool-fs-search/tests/presentation.spec.ts` 钉住纯函数层:`groupMatchesByFile` 的首见文件顺序,`grepSearchMeta`/`globSearchMeta` 施加上限后的投射与把 `total` 报告为截断前计数,投射出的匹配行上的每行预览预算,以及 `searchViewFromMeta` 对两种良态形状的收窄外加所有畸形情形(非对象/数组 meta、缺失或类型错误的 `truncated`/`total`、未知 `kind`、畸形 `files` 条目、非字符串 `paths`)。`packages/fs/tool-fs-search/tests/tools.spec.ts` 通过真实工具注册表钉住穿线:一次被截断的 `grep`/`glob` execute 在 `result.meta` 上产出 `SearchMeta`,且 `presentResult` 构建出附带 `content` 的搜索视图;嵌套 `run_code` 分发不计算 meta 于是 `presentResult` 回退;失败、跨形状或畸形结果回退到通用卡片。搜索包 `src` 上维持逐文件 100% 覆盖。
|
||||
|
||||
## Related
|
||||
|
||||
- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) —— 本 PR 以 `search` 结果标签扩展的 `card` 标签词汇。
|
||||
- [Canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) —— 本投射所乘的 value/render/`presentationMeta` 拆分;结构化值留在执行局部,卡片乘 `meta`。
|
||||
- [Web terminal card](2026-07-28-web-terminal-card.md) —— 本 PR 在后端所镜像的先例:工具把结果投射进 `presentationMeta` 与一个 `presentResult` 视图;搜索卡片的 web 消费方是类似的后续工作。
|
||||
Reference in New Issue
Block a user