From 7b6f33f87258c6d2d68a79df1302511519b2e49e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 21:57:49 +0800 Subject: [PATCH] refactor(fs): minimize and cap search card meta; keep TUI byte-identical Address the review of the search render card: - The search result view carries no `content`: it was a no-op for every consumer and serialized the whole search text twice. A UI without a search card falls back to the raw tool/result content; the TUI stays byte-identical to the pre-search-card generic fallback. - Bound the serialized presentationMeta with a configurable searchMetaMaxBytes (default 64 KiB): the inline item cap does not bound bytes, and spill-policy only shrinks content, never meta. capMetaBytes drops trailing groups/paths. - Share one retention pass (retainGrepMatches/retainGlobPaths in search-core) between the model-facing render and the meta projection; remove the second cap/preview implementation and the presentation<->grep module cycle by moving GrepMatch/previewLine to search-core. - Rename the result-view discriminant kind -> shape so it no longer collides with GenericCallView.kind (ToolCallKind, whose values include 'search'). - Narrow the entry export surface to consumed symbols. - Sync the three bilingual ToolResultView doc pairs and the Agent Note pair; document the deliberate empty-card acceptance vs diffsFromMeta. - Regenerate config/tool/cordis catalogs for the new config field. --- .../2026-07-30-search-render-card.i18n.yaml | 4 +- .../feature/2026-07-30-search-render-card.md | 36 +++-- .../2026-07-30-search-render-card.zh.md | 60 ++++---- docs/config-catalog.md | 4 +- docs/cookbook/adding-a-tool.i18n.yaml | 6 +- docs/cookbook/adding-a-tool.md | 1 + docs/cookbook/adding-a-tool.zh.md | 1 + docs/core-data-structures/tools.i18n.yaml | 4 +- docs/core-data-structures/tools.md | 2 +- docs/core-data-structures/tools.zh.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/presentation.ts | 38 ++--- packages/fs/tool-fs-search/src/glob.ts | 33 +++-- packages/fs/tool-fs-search/src/grep.ts | 69 +++------ packages/fs/tool-fs-search/src/index.ts | 26 +++- .../fs/tool-fs-search/src/presentation.ts | 133 ++++++++++++------ packages/fs/tool-fs-search/src/search-core.ts | 71 ++++++++++ .../tool-fs-search/tests/presentation.spec.ts | 97 +++++++++---- .../fs/tool-fs-search/tests/tools.spec.ts | 17 ++- packages/ui/tui/src/components/transcript.ts | 15 +- 23 files changed, 403 insertions(+), 228 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-search-render-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-search-render-card.i18n.yaml index 4a00c287a2..ec0e020502 100644 --- a/.agents/notes/implemented/feature/2026-07-30-search-render-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-search-render-card.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-07-30-search-render-card.md -2026-07-30-search-render-card.md: de59992cebcdf056e3446e4f546f8bff4b10e421 -2026-07-30-search-render-card.zh.md: 8b91255094c24972c05add92ee65f8c76c60a882 +2026-07-30-search-render-card.md: 36f772d7198ef30d6c243549cbaa9f16c780268b +2026-07-30-search-render-card.zh.md: 7d7ba352f19f3fb83cb6b7d049980776dc2c277e diff --git a/.agents/notes/implemented/feature/2026-07-30-search-render-card.md b/.agents/notes/implemented/feature/2026-07-30-search-render-card.md index de59992ceb..36f772d719 100644 --- a/.agents/notes/implemented/feature/2026-07-30-search-render-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-search-render-card.md @@ -6,43 +6,53 @@ 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. +`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 (`grepMaxMatches`, default 250; `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[]`. +`packages/core/tools/src/presentation.ts` adds `card: 'search'` to the `ToolResultView` union as `SearchResultView`, a `shape`-discriminated view that expresses both tools' shapes: `SearchMatchesResultView` (`shape: 'matches'`) carries `grep`'s matches grouped by file as `files: { path, matches: { lineNumber, line }[] }[]`, and `SearchPathsResultView` (`shape: 'paths'`) carries `glob`'s flat `paths: string[]`. Both carry `truncated: boolean` and `total: number`. -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 discriminant is `shape`, not `kind`, deliberately: the same presentation module already gives `GenericCallView` a `kind: ToolCallKind` field whose values include `'search'` (the icon category). A bridge holding a `ToolCallView | ToolResultView` would see two `kind` fields with two meanings; `shape` for the result variant keeps the two apart. + +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 `shape` for the row layout. The discriminated `shape` keeps each variant'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 view carries **no** result text. An earlier revision attached the model-facing `result.content` to the view; that was a no-op for every consumer (the TUI already falls back to `result.content`, and web fallbacks read the raw `tool/result` content), and it serialized the whole search text a second time into the persisted view. The view is the structured shape only; a UI without a search card falls back to the raw `tool/result` content. 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. +`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`. They consume the SAME retained result the model-facing render consumes — `retainGrepMatches`/`retainGlobPaths` in `search-core.ts` run the inline cap and per-line preview budget ONCE, and both the render and the projection take that outcome — so text and card never disagree about which results survived, and there is no second retention pass. `total` is every result the search found (before capping); `truncated` is 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 meta has its own byte budget.** The inline cap bounds the item COUNT, but the retained matches of a broad search (hundreds of long lines) can still serialize to hundreds of kilobytes, and `meta` is persisted with the session log and re-sent on every request. A deployment's final output budget (`dsh-spill-policy`, `maxInlineBytes`) only shrinks a result's `content` — `PostToolDecision` has no `meta` channel — so the projection owns keeping `meta` bounded. `capMetaBytes` drops trailing file groups / paths until the serialized meta fits `searchMetaMaxBytes` (config, default 64 KiB) and marks the result `truncated`. A single item too large to fit on its own is kept: the invariant is a bounded payload wherever droppable, never an empty card that hides a real result. -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`. +`searchViewFromMeta` narrows the opaque `meta` defensively and returns `undefined` on any malformed or absent payload, so a presenter run on an older or hand-edited replayed log falls back to the generic card instead of throwing. It DOES accept a zero-result payload (`files: []` / `paths: []`) as a valid empty card — this is a deliberate departure from the mirrored `diffsFromMeta`, which rejects empty `diffs`, because a zero-match grep is a legitimate result a UI shows as "no matches", not an absent projection. `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 `shape`). -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. +The `SearchMeta` member shapes are object-literal `type` aliases, not the `SearchFileMatches`/`SearchLineMatch` interfaces the view exposes, because 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 a `search` view falls through to the same dim generic body, reading the model-facing text from `this.result?.content`. Because the search view carries no `content` of its own and grep/glob returned a generic card before this PR, the TUI output stays byte-identical to the pre-search-card fallback. 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 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 `shape` discriminant keeps each variant'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. +**Reuse `kind` as the shape discriminant.** Rejected: `kind` already means `ToolCallKind` (the icon category, whose values include `'search'`) on the call view in the same module. A second `kind` with a different meaning on the result view collides for any bridge holding both. -**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. +**Attach the model-facing text as the view's `content`.** Rejected: a no-op for every current consumer and a second serialization of the whole search text into the persisted view. The view is the structured shape; text fallback reads the raw result content. + +**A meta channel on `PostToolDecision` so `dsh-spill-policy` bounds `meta` like it bounds `content`.** Rejected for this PR: it changes the core tool decision contract and the spill-policy plugin for one tool's payload. The projection bounding its own `meta` at a config byte cap is self-contained and keeps the seam unchanged. + +**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. ## 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. +`grep` and `glob` now compute `presentationMeta` on every non-nested successful call, a bounded projection over the already-retained matches or paths — the same retention outcome the render consumes, so there is no second retention pass and no doubled search text on the wire. The serialized meta is bounded by `searchMetaMaxBytes`, so a broad search no longer persists an unbounded structured copy into the session log. -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. +A UI without a search card renders the raw `tool/result` content, so no consumer regresses, and the TUI stays byte-identical. The web consumer that renders the structured shape reads `truncated`/`total` and the per-file groups; because the view carries only the retained, byte-bounded 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`. +`packages/fs/tool-fs-search/tests/presentation.spec.ts` pins the pure layer: `groupMatchesByFile`'s first-seen file order; `grepSearchMeta`/`globSearchMeta` projection over a shared retention outcome with `total` reporting the pre-cap count and `truncated` carried through; the per-line preview budget the retention pass applied; the serialized-meta byte cap dropping trailing groups/paths while keeping a single oversized item; and `searchViewFromMeta`'s narrowing of both good shapes, the zero-result empty card, and every malformed case (non-object/array meta, missing or mistyped `truncated`/`total`, unknown `shape`, 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 (no `content`), 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 diff --git a/.agents/notes/implemented/feature/2026-07-30-search-render-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-search-render-card.zh.md index 8b91255094..7d7ba352f1 100644 --- a/.agents/notes/implemented/feature/2026-07-30-search-render-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-search-render-card.zh.md @@ -1,51 +1,61 @@ -# Agent Note: Search render intent — grep and glob emit a structured search card +# Agent Note:搜索渲染意图 —— grep 与 glob 产出结构化搜索卡片 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'`),但没有结果期视图,于是已完成的调用回退到渲染原始文本的通用卡片。 +`grep` 与 `glob` 返回结构化的 canonical 值 —— `grep` 是扁平的 `{ matches: [{ path, lineNumber, line }] }`,`glob` 是 `{ paths: string[] }` —— 但每个 UI 只见过它们面向模型的渲染文本:`grep` 把匹配按文件头分组、每行 `Line N:`,`glob` 打印换行连接的路径列表,两者在内联上限(`grepMaxMatches`,默认 250;`globMaxResults`,默认 100)把后续结果落到 spill 文件时都追加一个 spill 脚注。想把搜索结果渲染成可展开的按文件匹配组、或可选择的路径列表的 web 前端,只能去重新解析那段文本。两个工具都已声明调用时的[渲染意图](../architecture/2026-07-02-tool-render-intent-union.md)(`GenericCallView`,`kind: 'search'`),但没有结果时视图,所以已完成的调用回退到渲染原始文本的 generic 卡片。 -结构化的规范值不过线:只有面向模型的渲染文本、以及当工具声明 `output.presentationMeta` 时的一段 JSON 元数据抵达客户端,二者通过 `tool/result` 事件穿线([规范输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md))。因此携带结构化数据的结果期视图必须把该数据投射进 `presentationMeta`,再在 `presentResult` 里读回——正是 `write`/`edit` 的 diff 卡片所走的路径。 +结构化 canonical 值不跨线传输:只有面向模型的渲染文本、以及当工具声明了 `output.presentationMeta` 时的一份 JSON 元数据,会经 `tool/result` 事件到达客户端([canonical-output 契约](../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[]`。 +`packages/core/tools/src/presentation.ts` 把 `card: 'search'` 作为 `SearchResultView` 加入 `ToolResultView` 联合,这是一个以 `shape` 判别的视图,表达两个工具的形状:`SearchMatchesResultView`(`shape: 'matches'`)以 `files: { path, matches: { lineNumber, line }[] }[]` 承载 `grep` 按文件分组的匹配,`SearchPathsResultView`(`shape: 'paths'`)承载 `glob` 的扁平 `paths: string[]`。两者都带 `truncated: boolean` 与 `total: number`。 -一个视图两种形状,而非两张卡片,因为两个工具是同一个视觉对象——一个搜索结果——web 消费方先在一个 `card` 值上分派,再在 `kind` 上分派行的形状。区分性的 `kind` 让每种形状各自的字段保持非可选(matches 视图恒有 `files`,paths 视图恒有 `paths`),而不是让所有形状相关字段都变成可选的单一接口。 +判别子是 `shape` 而非 `kind`,是刻意为之:同一个 presentation 模块已经给 `GenericCallView` 一个 `kind: ToolCallKind` 字段,其取值恰好包含 `'search'`(图标类别)。持有 `ToolCallView | ToolResultView` 的桥接层会看到两个含义不同的 `kind` 字段;结果变体用 `shape` 把两者分开。 -卡片标签只在结果期。搜索调用仍是 `GenericCallView`(`kind: 'search'`):pending 状态没有匹配或路径可展示,因此 `SearchCallView` 能携带的东西不会超出通用标题。这是与 terminal 卡片的不对称之处——terminal 的调用视图携带执行前就存在的命令、cwd 与描述;而搜索的结构化内容只在 `execute` 之后才存在。 +用一个带两种形状的视图而非两张卡片,因为两个工具是同一个视觉对象 —— 一个搜索结果 —— web 消费方先在一个 `card` 值上分支,再在 `shape` 上分支决定行布局。判别式 `shape` 让每个变体的字段保持非可选(matches 视图总有 `files`,paths 视图总有 `paths`),而不是一个所有形状相关字段都可选的单一接口。 -`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` 去展示截断指示,而非宣称模型从未拥有的完整性。 +该视图**不**携带结果文本。早期版本曾把面向模型的 `result.content` 附到视图上;那对每个消费方都是 no-op(TUI 本就回退到 `result.content`,web 回退读原始 `tool/result` 内容),却把整段搜索文本又序列化进持久化视图一遍。视图只承载结构化形状;无 search 卡片的 UI 回退到原始 `tool/result` 内容。 -`searchViewFromMeta` 防御性地收窄不透明的 `meta`,对任何畸形或缺失的 payload 返回 `undefined`,与 `diffsFromMeta` 完全一致,因此在较旧或手工编辑过的回放日志上运行的呈现器会回退到通用卡片而非抛错。`presentResult` 对失败结果、对缺失的 meta(嵌套 `run_code` 分发不计算 `presentationMeta`)、对另一个工具的 meta 形状(每个呈现器只收窄到自己的 `kind`)都返回 `undefined`。 +卡片标签只在结果时存在。搜索调用保持为 `GenericCallView`(`kind: 'search'`):pending 状态没有匹配或路径可展示,所以 `SearchCallView` 能携带的东西不会比 generic 标题更多。这是与 terminal 卡片的不对称之处 —— terminal 的调用视图携带执行前就存在的命令、cwd、description;搜索的结构化内容只在 `execute` 之后才存在。 -`SearchMeta` 的成员形状是对象字面量 `type` 别名,而不是视图对外暴露的 `SearchFileMatches`/`SearchLineMatch` 接口。只有 type 别名可以赋值给 `presentationMeta` 返回的 `JsonValue` 索引签名;二者结构完全相同,因此投射出的值仍能读回为 `SearchResultView`。 +`packages/fs/tool-fs-search/src/presentation.ts` 拥有投影与收窄。`grepSearchMeta`/`globSearchMeta` 把 canonical 值投影为每个工具声明为 `output.presentationMeta` 的 `SearchMeta` 载荷;`presentGrepResult`/`presentGlobResult` 经 `searchViewFromMeta` 把 `result.meta` 读回。它们消费与面向模型渲染相同的已保留结果 —— `search-core.ts` 里的 `retainGrepMatches`/`retainGlobPaths` 只跑一次内联上限与每行预览预算,render 与投影都取这份产出 —— 所以文本与卡片对哪些结果幸存永不分歧,也没有第二次保留计算。`total` 是搜索找到的全部结果(截断前);`truncated` 在上限丢弃了结果时置位。这是截断诚实点:模型看到的是被截断的内联结果加一个 spill 脚注,所以卡片不能把保留页当作完整结果 —— UI 读 `truncated`/`total` 显示截断指示,而非宣称模型从未有过的完整性。 -TUI(`packages/ui/tui/src/components/transcript.ts`)无需专用分支:它的结果视图 switch 显式处理 `terminal` 与 `diff`,并落到一个渲染 `view.content ?? this.result?.content` 的通用分支。因为 `SearchResultView` 以 `content` 携带了面向模型的文本,TUI 渲染出的仍是它此前已展示的同一段文本。渲染结构化 `files`/`paths` 形状的 web 前端是后续独立的 PR;本 PR 是后端契约及其两个生产者。 +**meta 有自己的字节预算。** 内联上限约束的是条目数,但一次宽泛搜索保留下来的匹配(数百条长行)仍可序列化到数百 KB,而 `meta` 会随会话日志持久化并在每次请求时重发。部署的最终输出预算(`dsh-spill-policy` 的 `maxInlineBytes`)只缩减结果的 `content` —— `PostToolDecision` 没有 `meta` 通道 —— 所以投影自己负责把 `meta` 约束住。`capMetaBytes` 丢弃末尾的文件组/路径,直到序列化 meta 装进 `searchMetaMaxBytes`(配置,默认 64 KiB),并把结果标记 `truncated`。单个大到自身都装不下的条目会被保留:不变量是可丢弃处一律有界,绝不产出隐藏了真实结果的空卡片。 -## Alternatives considered +`searchViewFromMeta` 防御性地收窄不透明的 `meta`,对任何畸形或缺失载荷返回 `undefined`,使在较旧或手工编辑的回放日志上运行的 presenter 回退到 generic 卡片而非抛错。它确实接受零结果载荷(`files: []` / `paths: []`)为合法的空卡片 —— 这是与被镜像的 `diffsFromMeta` 的刻意偏离(后者拒绝空 `diffs`),因为零匹配的 grep 是 UI 展示为「no matches」的合法结果,而非缺失的投影。`presentResult` 对失败结果、对缺失 meta(嵌套 `run_code` 分发不计算 `presentationMeta`)、以及对另一工具的 meta 形状(每个 presenter 收窄到自己的 `shape`)返回 `undefined`。 -**单一扁平的 `SearchResultView` 接口,带可选的 `files?` 与 `paths?`。** 否决:它让两种形状相关字段在每个值上都成为可选,并允许一个畸形视图同时携带二者或都不携带。`kind` 区分符让每种形状的字段保持必填,并让消费方能穷尽分派。 +`SearchMeta` 的成员形状是对象字面量 `type` 别名,而非视图暴露的 `SearchFileMatches`/`SearchLineMatch` 接口,因为只有 type 别名可赋给 `presentationMeta` 返回的 `JsonValue` 索引签名;两者结构等价,所以投影值仍读回为 `SearchResultView`。 -**一个调用期的 `SearchCallView`,镜像 terminal 卡片两侧对称。** 否决:搜索调用在 `execute` 之前没有匹配或路径,视图只会携带 `GenericCallView` 已携带的标题。terminal 卡片的调用视图之所以配得上其标签,是因为命令、cwd 与描述在调用期就存在;而搜索的结构化内容不存在。 +TUI(`packages/ui/tui/src/components/transcript.ts`)不需要专门分支:它的结果视图 switch 显式处理 `terminal` 与 `diff`,`search` 视图落入同一个变暗的 generic body,从 `this.result?.content` 读取面向模型的文本。因为搜索视图不带自己的 `content`,而本 PR 之前 grep/glob 返回的是 generic 卡片,所以 TUI 输出与无 search 卡片的回退逐字节一致。渲染结构化 `files`/`paths` 形状的 web 前端是另一个后续 PR;本 PR 是后端契约及其两个生产者。 -**用一个专门的通道而非 `presentationMeta` 携带结构化结果。** 否决:规范值是执行局部的、绝不抵达客户端,而 `presentationMeta` 是既有的接缝,它把工具的 JSON 呈现 payload 随 `tool/result` 持久化并穿线回 `presentResult`。再加一条通道只会重复这条路径。 +## 考虑过的备选 -## Consequences +**一个扁平的 `SearchResultView` 接口,带可选 `files?` 与 `paths?`。** 否决:它让两个形状相关字段在每个值上都可选,并允许畸形视图同时带两者或都不带。`shape` 判别式让每个变体的字段保持必需,并让消费方穷尽分支。 -`grep` 与 `glob` 现在在每次非嵌套的成功调用上计算 `presentationMeta`,这是对已解析的匹配或路径做的一次有界投射。投射重新施加渲染已施加过的保留上限,因此每次调用会计算两遍保留集;输入受原始输出上限约束,故这不是新的伸缩性问题。 +**复用 `kind` 作形状判别子。** 否决:同一模块里调用视图上的 `kind` 已经表示 `ToolCallKind`(图标类别,取值含 `'search'`)。结果视图上再有一个含义不同的 `kind`,对任何同时持有两者的桥接层都会冲突。 -没有搜索卡片的 UI 渲染附上的 `content` 文本,因此没有消费方回退。渲染结构化形状的 web 消费方读取 `truncated`/`total` 与按文件分组;因为视图只携带保留的那一页,想要完整结果的 UI 沿面向模型文本里的 spill 定位符去取,与模型的做法完全一致。 +**把面向模型的文本作为视图的 `content` 附上。** 否决:对每个当前消费方是 no-op,且把整段搜索文本第二次序列化进持久化视图。视图是结构化形状;文本回退读原始结果内容。 -## Testing +**在 `PostToolDecision` 上加 meta 通道,让 `dsh-spill-policy` 像约束 `content` 那样约束 `meta`。** 本 PR 否决:它为一个工具的载荷改动核心工具决策契约与 spill-policy 插件。投影在配置字节上限处约束自己的 `meta` 是自包含的,且保持 seam 不变。 -`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% 覆盖。 +**镜像 terminal 卡片双侧对称的调用时 `SearchCallView`。** 否决:搜索调用在 `execute` 前没有匹配或路径,视图只会携带 `GenericCallView` 已有的标题。 -## 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 消费方是类似的后续工作。 +`grep` 与 `glob` 现在在每次非嵌套的成功调用上计算 `presentationMeta`,这是对已保留匹配或路径的一次有界投影 —— 与 render 消费的是同一份保留产出,所以没有第二次保留计算,线上也没有翻倍的搜索文本。序列化 meta 受 `searchMetaMaxBytes` 约束,所以宽泛搜索不再把无界的结构化副本持久化进会话日志。 + +无 search 卡片的 UI 渲染原始 `tool/result` 内容,所以没有消费方退化,TUI 也逐字节一致。渲染结构化形状的 web 消费方读 `truncated`/`total` 与按文件分组;因为视图只携带保留的、字节有界的页,想要完整结果的 UI 跟随面向模型文本里的 spill 定位符,与模型的做法完全一致。 + +## 测试 + +`packages/fs/tool-fs-search/tests/presentation.spec.ts` 钉住纯层:`groupMatchesByFile` 的首见文件顺序;`grepSearchMeta`/`globSearchMeta` 在共享保留产出上的投影,`total` 报告截断前计数、`truncated` 被带过;保留过程施加的每行预览预算;序列化 meta 字节上限丢弃末尾组/路径同时保留单个超大条目;以及 `searchViewFromMeta` 对两种良好形状、零结果空卡片、以及每种畸形情形(非对象/数组 meta、缺失或误型的 `truncated`/`total`、未知 `shape`、畸形 `files` 条目、非字符串 `paths`)的收窄。`packages/fs/tool-fs-search/tests/tools.spec.ts` 钉住经真实工具注册表的接线:被截断的 `grep`/`glob` execute 在 `result.meta` 上产出 `SearchMeta`,`presentResult` 构建搜索视图(无 `content`),嵌套 `run_code` 分发不计算 meta 故 `presentResult` 回退,失败或跨形状或畸形结果回退到 generic 卡片。搜索包 `src` 上保持 per-file 100% 覆盖。 + +## 相关 + +- [工具调用呈现的带标签渲染意图联合](../architecture/2026-07-02-tool-render-intent-union.md) —— 本 PR 用 `search` 结果标签扩展的 `card` 标签词汇。 +- [Canonical 工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md) —— 本投影所乘的 value/render/`presentationMeta` 划分;结构化值留在执行本地,卡片乘 `meta`。 +- [Web terminal 卡片](2026-07-28-web-terminal-card.md) —— 本 PR 在后端镜像的先例:工具把结果投影进 `presentationMeta` 与一个 `presentResult` 视图;搜索卡片的 web 消费方是与之类比的后续。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3e8f197a8e..12cb6d6500 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1672,6 +1672,8 @@ export interface Config { grepMaxMatches?: number /** Max bytes retained for one matched-line preview (the cut preserves UTF-8 boundaries). */ grepMaxLineBytes?: number + /** Max bytes of one search's serialized `presentationMeta`; trailing groups/paths drop past it so the persisted, re-sent card stays bounded. */ + searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */ @@ -1679,7 +1681,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-fs-search/src/index.ts:65`](../packages/fs/tool-fs-search/src/index.ts) +Source: [`packages/fs/tool-fs-search/src/index.ts:71`](../packages/fs/tool-fs-search/src/index.ts) ## `@deepseek-ai/dsh-tool-goal` diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 423737be39..0ce5d0df1c 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -1,6 +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 -adding-a-tool.md: d06e3d8e3c7da1f71a55bf9c4f56cd4b2cc03697 -adding-a-tool.zh.md: 53f608eba3b26b124f873990fa13ce1572c0baf2 +# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-tool.md +adding-a-tool.md: 75a4d87aab77c7dfcc31a1e8d0d58bc41e9e3f7e +adding-a-tool.zh.md: ba76c14437f15b6c5381107cf2d7bd40eb5861b2 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index d06e3d8e3c..75a4d87aab 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -78,6 +78,7 @@ Both methods return a **`card`-tagged render intent** — pick the card kind tha - `generic` supplies an optional title and content. - `terminal` supplies raw output and optional exit metadata; each UI renders its capable or fallback view. - `diff` supplies applied hunks, often derived by `output.presentationMeta` and carried in persisted `result.meta` so replay reproduces them. Mutation tools keep a diff result because the completed view replaces the pending card. + - `search` supplies a discovery result reconstructed from persisted `result.meta`: grouped-by-file matches (`shape: 'matches'`, grep) or a flat path list (`shape: 'paths'`, glob), plus `truncated`/`total` so a UI never presents a capped result as complete. The view carries no result text (a UI without a search card falls back to the raw result content), and there is no `search` call view — a discovery call's pending state stays a generic card, since matches exist only after `execute`. (tool-fs-search `grep`/`glob`.) Hard rules (they bite if broken): diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 53f608eba3..ba76c14437 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -78,6 +78,7 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 - `generic` 提供可选的标题和内容。 - `terminal` 提供原始输出和可选的退出元数据;各 UI 根据自身能力渲染对应视图或回退视图。 - `diff` 提供已应用的 hunk,通常由 `output.presentationMeta` 派生并通过持久化的 `result.meta` 携带,使回放能重现它们。变更类工具保留 diff 结果,因为完成后的视图会替换 pending 卡片。 + - `search` 提供从持久化 `result.meta` 重建的发现型结果:按文件分组的匹配(`shape: 'matches'`,grep)或扁平路径列表(`shape: 'paths'`,glob),外加 `truncated`/`total` 使 UI 永不把被截断的结果当作完整结果呈现。该视图不携带结果文本(无 search 卡片的 UI 回退到原始结果内容),也没有 `search` 调用视图——发现型调用的 pending 状态保持为 generic 卡片,因为匹配只在 `execute` 之后才存在。(tool-fs-search 的 `grep`/`glob`。) 硬性规则(违反会出问题): diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index fa49f46c59..326a95a9b1 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.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/core-data-structures/tools.md -tools.md: dad7f7421caa94940801407fd4ef7fd936eb05c9 -tools.zh.md: 8386e5870e665e90ee0dbada8cb98084281001a7 +tools.md: c6a6ebf4e65cc8abf93ebeff756dde5819b8806a +tools.zh.md: 0a83ae92f2b400e1279f397a434ac8e4ad020464 diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index dad7f7421c..c6a6ebf4e6 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -447,7 +447,7 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' } How a tool wants its call shown in a UI (an editor tool-call card, a CLI log line), provider-neutral so a tool describes itself without depending on any client protocol. `presentCall`/`presentResult` return a **`card`-tagged render intent** — a discriminated union a UI bridge switches on: - `ToolCallView` (pending): `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` (the default card; `locations` is `{ path, line? }[]` files the call reads/modifies, for editor follow-along), `{ card: 'terminal', title, description?, cwd? }` (a shell command → a terminal card), or `{ card: 'diff', title, diffs, locations? }` (a file create/modify → an inline diff card; `diffs` is `{ path, oldText, newText }[]`, `oldText: null` for a new file). -- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, while another may derive a fenced ` ```console ` fallback), or `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image). Completed views replace pending views, so mutation tools return a diff result even when it duplicates the call-time snippet. +- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, while another may derive a fenced ` ```console ` fallback), `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image), or `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search → grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob); `truncated`/`total` report whether the inline result was capped so a UI never presents a partial result as complete; the view carries no result text — a UI without a search card falls back to the raw result content). Completed views replace pending views, so mutation tools return a diff result even when it duplicates the call-time snippet; a search has no `card: 'search'` call-time analogue (its pending state stays a generic card, since matches exist only after `execute`). `ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`) and `FileDiff` (`{ path, oldText, newText }`) are the shared file-card vocabulary. The design is pinned in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); the TUI and host/client runtime project this neutral vocabulary into their own views. diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 8386e5870e..0a83ae92f2 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -447,7 +447,7 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' } 工具希望其调用在 UI 中如何呈现(编辑器工具调用卡片、CLI(命令行界面)日志行),提供方无关,使工具在不依赖任何客户端协议的情况下描述自身。`presentCall`/`presentResult` 返回一个 **`card` 标签的渲染意图**——一个可辨识联合类型,UI 桥接层据此分发: - `ToolCallView`(待执行):`{ card: 'generic', title, kind?, rawInput?, content?, locations? }`(默认卡片;`locations` 是 `{ path, line? }[]`,表示调用读取/修改的文件,供编辑器跟随)、`{ card: 'terminal', title, description?, cwd? }`(shell 命令→终端卡片)、或 `{ card: 'diff', title, diffs, locations? }`(文件创建/修改→行内 diff 卡片;`diffs` 是 `{ path, oldText, newText }[]`,新文件时 `oldText: null`)。 -- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,其他 UI 可以派生围栏 ` ```console ` 回退)、或 `{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 diff 结果。 +- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,其他 UI 可以派生围栏 ` ```console ` 回退)、`{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff)、或 `{ card: 'search', shape, title?, truncated, total, … }`(已完成的发现型搜索→`shape: 'matches'`(grep)为按文件分组的匹配,`shape: 'paths'`(glob)为扁平路径列表;`truncated`/`total` 报告内联结果是否被截断,使 UI 永不把部分结果当作完整结果呈现;该视图不携带结果文本——无 search 卡片的 UI 回退到原始结果内容)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 diff 结果;搜索没有 `card: 'search'` 的调用时对应视图(其 pending 状态保持为 generic 卡片,因为匹配只在 `execute` 之后才存在)。 `ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)用于为通用卡片选择图标。`FileLocation`(`{ path, line? }`)与 `FileDiff`(`{ path, oldText, newText }`)是共享的文件卡片词汇。该设计由[渲染意图联合类型 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)固定;TUI 和 host/client 运行时将这套中性词汇投影为各自的视图。 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index e57855c29b..fddda8225e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2165,11 +2165,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SearchMatchesResultView', - declaration: 'export interface SearchMatchesResultView {\n card: \'search\';\n kind: \'matches\';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n content?: ContentBlock[];\n}', + declaration: 'export interface SearchMatchesResultView {\n card: \'search\';\n shape: \'matches\';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n}', }, { name: 'SearchPathsResultView', - declaration: 'export interface SearchPathsResultView {\n card: \'search\';\n kind: \'paths\';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n content?: ContentBlock[];\n}', + declaration: 'export interface SearchPathsResultView {\n card: \'search\';\n shape: \'paths\';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n}', }, { name: 'SearchResultView', diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 429e76ed6a..854b5d78bf 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: e5adb153e77d7a2d8c4068b016194ab6abb6473e -README.zh.md: c67a2f2ee4ac2a9d587c6efbf2b5c60d14fc58c2 +README.md: a8afab7839983d300c2c17627e34dafaa4648d8b +README.zh.md: 8beb63e8376f397ac859a6a04ab2f35316ef6d27 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index e5adb153e7..a8afab7839 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -108,7 +108,7 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names: - Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`. -- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`. +- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, or `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search — grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob), with `truncated`/`total` so a UI never presents a capped result as complete; the view carries no result text and a search has no `card: 'search'` call-time analogue). Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index c67a2f2ee4..8beb63e837 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -108,7 +108,7 @@ ctx.tools.register(defineTool({ 工具可以选择拥有纯 `presentCall()` 和 `presentResult()` 呈现意图,使 UI 无需特殊处理工具名称: - 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。 -- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }` 或 `{ card: 'diff', title?, diffs }`。 +- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }` 或 `{ card: 'search', shape, title?, truncated, total, … }`(已完成的发现型搜索——`shape: 'matches'`(grep)为按文件分组的匹配,`shape: 'paths'`(glob)为扁平路径列表,配 `truncated`/`total` 使 UI 永不把被截断的结果当作完整结果呈现;该视图不携带结果文本,且搜索没有 `card: 'search'` 的调用时对应视图)。 返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接接口调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) 规定卡片词汇。 diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts index 338a73faa1..d8b6352b52 100644 --- a/packages/core/tools/src/presentation.ts +++ b/packages/core/tools/src/presentation.ts @@ -196,12 +196,14 @@ export interface SearchFileMatches { /** * A completed content search (`grep`) rendered as a search card whose matches are * grouped by file, so a capable UI can list each file as an expandable group of - * its matched lines. `kind: 'matches'` discriminates this shape from the path - * shape ({@link SearchPathsResultView}) within {@link SearchResultView}. + * its matched lines. `shape: 'matches'` discriminates this variant from the path + * variant ({@link SearchPathsResultView}) within {@link SearchResultView}. The + * discriminant is `shape`, not `kind`, so it never collides with the + * {@link ToolCallKind} `kind` an icon-picking bridge reads off a call view. */ export interface SearchMatchesResultView { card: 'search' - kind: 'matches' + shape: 'matches' /** Replacement title for the completed call. Omit to keep the pending-state title. */ title?: string /** Matched lines grouped by file, in first-seen file order. */ @@ -214,22 +216,16 @@ export interface SearchMatchesResultView { truncated: boolean /** Total matches the search found before capping (equals the retained count when not `truncated`). */ total: number - /** - * UI-facing content blocks reproducing the model-facing result text, so a UI - * without a dedicated search card renders it as text. Omit to let the UI render - * the raw result content. - */ - content?: ContentBlock[] } /** * A completed path search (`glob`) rendered as a search card whose result is a flat - * path list. `kind: 'paths'` discriminates this shape from the grouped-matches - * shape ({@link SearchMatchesResultView}) within {@link SearchResultView}. + * path list. `shape: 'paths'` discriminates this variant from the grouped-matches + * variant ({@link SearchMatchesResultView}) within {@link SearchResultView}. */ export interface SearchPathsResultView { card: 'search' - kind: 'paths' + shape: 'paths' /** Replacement title for the completed call. Omit to keep the pending-state title. */ title?: string /** The discovered paths, in the tool's result order (the retained page when `truncated`). */ @@ -242,24 +238,18 @@ export interface SearchPathsResultView { truncated: boolean /** Total paths the search found before capping (equals `paths.length` when not `truncated`). */ total: number - /** - * UI-facing content blocks reproducing the model-facing result text, so a UI - * without a dedicated search card renders it as text. Omit to let the UI render - * the raw result content. - */ - content?: ContentBlock[] } /** * A completed search rendered as a search card, the result-time view a discovery * tool (`grep`, `glob`) returns from `presentResult`. One `card: 'search'` view - * with two `kind`-discriminated shapes: grouped-by-file content matches + * with two `shape`-discriminated variants: grouped-by-file content matches * ({@link SearchMatchesResultView}) and a flat path list * ({@link SearchPathsResultView}). Both carry a `truncated`/`total` signal so a UI - * never presents a capped result as complete, and an optional `content` a UI - * without a search card renders as text. There is no call-time analogue: a search - * call stays a {@link GenericCallView} (`kind: 'search'`) because the pending - * state has no matches or paths to show — the structured shape exists only after - * `execute`. + * never presents a capped result as complete. The view carries no result text: a + * UI without a search card falls back to the raw `tool/result` content. There is + * no call-time analogue: a search call stays a {@link GenericCallView} + * (`kind: 'search'`) because the pending state has no matches or paths to show — + * the structured shape exists only after `execute`. */ export type SearchResultView = SearchMatchesResultView | SearchPathsResultView diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 8fd2d20ebf..ba7990ca2e 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -12,12 +12,11 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' -import { ItemRetainer } from '@deepseek-ai/dsh-retention' import type { RetainedItems } from '@deepseek-ai/dsh-retention' import type { SpillRef } from '@deepseek-ai/dsh-spill' import type {} from '@deepseek-ai/dsh-bash' import type {} from '@deepseek-ai/dsh-system-prompt' -import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' +import { retainGlobPaths, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' import { globSearchMeta, searchViewFromMeta } from './presentation.ts' import { singleQuote } from './shell-quote.ts' import { acceptedSurfaceValue } from './surface.ts' @@ -44,6 +43,8 @@ export const GLOB_VCS_EXCLUDES: readonly string[] = ['.git', '.svn', '.hg', '.bz export interface GlobToolCaps { /** Max paths retained inline; later paths go to the formatted spill file. */ maxResults: number + /** Max bytes of serialized `presentationMeta`; trailing paths drop past it. */ + maxMetaBytes: number /** Cap on the complete raw `rg` stdout the tool will parse. */ rawOutputMaxBytes: number /** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */ @@ -118,12 +119,10 @@ export function formatGlobOutput(retained: RetainedItems, spillRef: Spil return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})` } -/** Retain and format one canonical path list for the Native surface. */ -function renderGlobPaths(paths: string[], maxResults: number, spillRef?: SpillRef): string { - if (paths.length === 0) return 'No files found' - const retainer = new ItemRetainer({ kind: 'head', maxItems: maxResults }) - for (const path of paths) retainer.push(path) - return formatGlobOutput(retainer.finish(), spillRef) +/** Format one already-retained path list for the Native surface. */ +function formatRetainedGlob(retained: RetainedItems, spillRef?: SpillRef): string { + if (retained.seen === 0) return 'No files found' + return formatGlobOutput(retained, spillRef) } /** @@ -139,10 +138,10 @@ export function presentGlobCall(args: { pattern: string; path?: string }): Gener /** * Completed-call presentation: the search card projected from the result's - * `presentationMeta` (the discovered path list, with the truncation signal), with - * the model-facing result text attached as `content` for a UI without a search - * card. Malformed or absent metadata (an obsolete or hand-edited replayed log) - * falls back to the generic card. + * `presentationMeta` (the discovered path list, with the truncation signal). A UI + * without a search card falls back to the raw `tool/result` content, so the view + * carries no result text of its own. Malformed or absent metadata (an obsolete or + * hand-edited replayed log) falls back to the generic card. * * @param _args - the raw tool arguments; unused, the view derives from the result. * @param result - the final model-facing tool result carrying the projected metadata. @@ -151,8 +150,8 @@ export function presentGlobCall(args: { pattern: string; path?: string }): Gener export function presentGlobResult(_args: { pattern: string; path?: string }, result: ToolResult): SearchResultView | undefined { if (result.isError) return undefined const view = searchViewFromMeta(result.meta) - if (view === undefined || view.kind !== 'paths') return undefined - return { ...view, content: result.content } + if (view === undefined || view.shape !== 'paths') return undefined + return view } /** @@ -187,8 +186,8 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { paths: { type: 'array', required: true, items: { type: 'string' } }, }, }, - render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults) }], - presentationMeta: (_args, value) => globSearchMeta(value.paths, caps.maxResults), + render: (_args, value) => [{ type: 'text', text: formatRetainedGlob(retainGlobPaths(value.paths, caps.maxResults)) }], + presentationMeta: (_args, value) => globSearchMeta(retainGlobPaths(value.paths, caps.maxResults), caps.maxMetaBytes), }, async execute(args, exec) { const input = parseGlobArgs(args) @@ -217,7 +216,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { const spillRef = await trySaveFormattedResult(ctx, exec, 'glob-results.txt', paths.join('\n')) return { kind: 'accept', - content: [{ type: 'text', text: renderGlobPaths(paths, caps.maxResults, spillRef) }], + content: [{ type: 'text', text: formatRetainedGlob(retainGlobPaths(paths, caps.maxResults), spillRef) }], ...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {}, } }) diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index 4f3273f0fb..b7e67ea153 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -13,12 +13,12 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' -import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention' import type { RetainedItems } from '@deepseek-ai/dsh-retention' import type { SpillRef } from '@deepseek-ai/dsh-spill' import type {} from '@deepseek-ai/dsh-bash' import type {} from '@deepseek-ai/dsh-system-prompt' -import { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' +import type { GrepMatch } from './search-core.ts' +import { SearchError, previewLine, retainGrepMatches, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' import { grepSearchMeta, searchViewFromMeta } from './presentation.ts' import { singleQuote } from './shell-quote.ts' import { acceptedSurfaceValue } from './surface.ts' @@ -42,6 +42,8 @@ export interface GrepToolCaps { maxMatches: number /** Max bytes retained per matched-line preview. */ maxLineBytes: number + /** Max bytes of serialized `presentationMeta`; trailing file groups drop past it. */ + maxMetaBytes: number /** Cap on the complete raw `rg` stdout the tool will parse. */ rawOutputMaxBytes: number /** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */ @@ -55,13 +57,6 @@ export interface GrepInput { include?: string } -/** One parsed match: the file, the 1-based line number, and the (possibly previewed) line text. */ -export interface GrepMatch { - path: string - lineNumber: number - line: string -} - /** * Reject an `include` that is not ONE positive glob filter: blank strings, * negated patterns (`!…`), and comma-separated lists. A comma inside a brace @@ -178,22 +173,6 @@ export function parseGrepMatches(stdout: string): GrepMatch[] { return matches } -/** - * Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and - * mark the cut. The cap is a per-line budget fact; the complete line stays in - * the searched file for `read`. - * - * @param line - the matched line text (trailing newline already stripped). - * @param maxBytes - the preview budget in bytes. - * @returns the preview, suffixed with ` (line truncated)` when bytes were cut. - */ -export function previewLine(line: string, maxBytes: number): string { - const retainer = new TextRetainer({ kind: 'head', maxBytes }) - retainer.push(line) - const kept = retainer.finish() - return kept.truncated ? `${kept.text} (line truncated)` : kept.text -} - /** `match` / `matches` for a count. */ function matchNoun(count: number): string { return count === 1 ? 'match' : 'matches' @@ -242,18 +221,10 @@ export function formatGrepOutput(retained: RetainedItems, spillRef: S return `${header}\n\n${body}\n\n(${recovery})` } -/** Apply the Native per-line preview budget without changing the canonical matches. */ -function previewGrepMatches(matches: GrepMatch[], maxLineBytes: number): GrepMatch[] { - return matches.map(match => ({ ...match, line: previewLine(match.line, maxLineBytes) })) -} - -/** Retain and format one canonical match list for the Native surface. */ -function renderGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number, spillRef?: SpillRef): string { - if (matches.length === 0) return 'No matches found' - const previewed = previewGrepMatches(matches, maxLineBytes) - const retainer = new ItemRetainer({ kind: 'head', maxItems: maxMatches }) - for (const match of previewed) retainer.push(match) - return formatGrepOutput(retainer.finish(), spillRef) +/** Format one already-retained match list for the Native surface. */ +function formatRetainedGrep(retained: RetainedItems, spillRef?: SpillRef): string { + if (retained.seen === 0) return 'No matches found' + return formatGrepOutput(retained, spillRef) } /** @@ -271,10 +242,10 @@ export function presentGrepCall(args: { pattern: string; path?: string; include? /** * Completed-call presentation: the search card projected from the result's - * `presentationMeta` (matches grouped by file, with the truncation signal), with - * the model-facing result text attached as `content` for a UI without a search - * card. Malformed or absent metadata (an obsolete or hand-edited replayed log) - * falls back to the generic card. + * `presentationMeta` (matches grouped by file, with the truncation signal). A UI + * without a search card falls back to the raw `tool/result` content, so the view + * carries no result text of its own. Malformed or absent metadata (an obsolete or + * hand-edited replayed log) falls back to the generic card. * * @param _args - the raw tool arguments; unused, the view derives from the result. * @param result - the final model-facing tool result carrying the projected metadata. @@ -286,8 +257,8 @@ export function presentGrepResult( ): SearchResultView | undefined { if (result.isError) return undefined const view = searchViewFromMeta(result.meta) - if (view === undefined || view.kind !== 'matches') return undefined - return { ...view, content: result.content } + if (view === undefined || view.shape !== 'matches') return undefined + return view } /** @@ -337,9 +308,10 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { }, render: (_args, value) => [{ type: 'text', - text: renderGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes), + text: formatRetainedGrep(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes)), }], - presentationMeta: (_args, value) => grepSearchMeta(value.matches, caps.maxMatches, caps.maxLineBytes), + presentationMeta: (_args, value) => + grepSearchMeta(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes), caps.maxMetaBytes), }, async execute(args, exec) { const input = parseGrepArgs(args) @@ -368,17 +340,20 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { if (value === undefined) return decision const matches = value.matches if (matches.length <= caps.maxMatches) return decision + // The spill artifact holds the COMPLETE result: preview each line, but keep + // every match (no inline cap), so the recovery file is the full search. + const previewedAll = matches.map(match => ({ ...match, line: previewLine(match.line, caps.maxLineBytes) })) const spillRef = await trySaveFormattedResult( ctx, exec, 'grep-results.txt', - `Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewGrepMatches(matches, caps.maxLineBytes))}`, + `Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewedAll)}`, ) return { kind: 'accept', content: [{ type: 'text', - text: renderGrepMatches(matches, caps.maxMatches, caps.maxLineBytes, spillRef), + text: formatRetainedGrep(retainGrepMatches(matches, caps.maxMatches, caps.maxLineBytes), spillRef), }], ...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {}, } diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index 0c53776d1e..f7b75bbc2a 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -31,7 +31,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts' import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts' -import { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts' +import { RAW_OUTPUT_MAX_BYTES, SEARCH_META_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts' export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, presentGlobResult } from './glob.ts' export type { GlobInput, GlobToolCaps } from './glob.ts' @@ -46,13 +46,19 @@ export { parseGrepMatches, presentGrepCall, presentGrepResult, - previewLine, } from './grep.ts' -export type { GrepInput, GrepMatch, GrepToolCaps } from './grep.ts' -export { globSearchMeta, grepSearchMeta, groupMatchesByFile, searchViewFromMeta } from './presentation.ts' -export type { SearchMeta } from './presentation.ts' -export { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS, SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' -export type { RipgrepRun, SearchErrorCode } from './search-core.ts' +export type { GrepInput, GrepToolCaps } from './grep.ts' +export { + RAW_OUTPUT_MAX_BYTES, + SEARCH_META_MAX_BYTES, + SEARCH_TIMEOUT_MS, + SearchError, + previewLine, + runRipgrep, + toWorkdirRelative, + trySaveFormattedResult, +} from './search-core.ts' +export type { GrepMatch, RipgrepRun, SearchErrorCode } from './search-core.ts' export { singleQuote } from './shell-quote.ts' /** Cordis plugin name used by loader diagnostics. */ @@ -69,6 +75,8 @@ export interface Config { grepMaxMatches?: number /** Max bytes retained for one matched-line preview (the cut preserves UTF-8 boundaries). */ grepMaxLineBytes?: number + /** Max bytes of one search's serialized `presentationMeta`; trailing groups/paths drop past it so the persisted card stays bounded. */ + searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */ @@ -79,6 +87,7 @@ export const Config: z = z.object({ globMaxResults: z.number().default(GLOB_MAX_RESULTS), grepMaxMatches: z.number().default(GREP_MAX_MATCHES), grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES), + searchMetaMaxBytes: z.number().default(SEARCH_META_MAX_BYTES), rawOutputMaxBytes: z.number().default(RAW_OUTPUT_MAX_BYTES), timeoutMs: z.number().default(SEARCH_TIMEOUT_MS), }) @@ -133,6 +142,7 @@ export async function apply(ctx: Context, config: Config): Promise { assertPositiveInteger('globMaxResults', resolved.globMaxResults) assertPositiveInteger('grepMaxMatches', resolved.grepMaxMatches) assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes) + assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes) assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes) assertPositiveInteger('timeoutMs', resolved.timeoutMs) if (!await ripgrepAvailable(ctx)) { @@ -141,12 +151,14 @@ export async function apply(ctx: Context, config: Config): Promise { } applyGlobTool(ctx, { maxResults: resolved.globMaxResults, + maxMetaBytes: resolved.searchMetaMaxBytes, rawOutputMaxBytes: resolved.rawOutputMaxBytes, timeoutMs: resolved.timeoutMs, }) applyGrepTool(ctx, { maxMatches: resolved.grepMaxMatches, maxLineBytes: resolved.grepMaxLineBytes, + maxMetaBytes: resolved.searchMetaMaxBytes, rawOutputMaxBytes: resolved.rawOutputMaxBytes, timeoutMs: resolved.timeoutMs, }) diff --git a/packages/fs/tool-fs-search/src/presentation.ts b/packages/fs/tool-fs-search/src/presentation.ts index 479a64d7d1..669c4b1d0a 100644 --- a/packages/fs/tool-fs-search/src/presentation.ts +++ b/packages/fs/tool-fs-search/src/presentation.ts @@ -1,7 +1,7 @@ /** * Result-time search-card presentation for `grep` and `glob`. Both tools land on * one `card: 'search'` render intent ({@link SearchResultView}) with two - * `kind`-discriminated shapes: `grep` projects its matches grouped by file + * `shape`-discriminated variants: `grep` projects its matches grouped by file * ({@link SearchMatchesResultView}), `glob` projects a flat path list * ({@link SearchPathsResultView}). This module owns the value→`presentationMeta` * projection each tool declares and the defensive `meta`→view narrowing each @@ -9,11 +9,19 @@ * * The canonical value never crosses the wire — only the model-facing render text * and this JSON `meta` do — so the structured shape a UI renders MUST ride in - * `meta`. Each projection applies the SAME inline cap the model-facing render - * applies ({@link module:@deepseek-ai/dsh-tool-fs-search/grep} `grepMaxMatches`, - * {@link module:@deepseek-ai/dsh-tool-fs-search/glob} `globMaxResults`) and reports - * `total` (every result found) and `truncated`, so a UI never presents a capped - * result as complete. + * `meta`. Each projection consumes the SAME retained matches/paths the + * model-facing render consumes ({@link module:@deepseek-ai/dsh-tool-fs-search/search-core} + * `retainGrepMatches`/`retainGlobPaths`), so text and card agree about which + * results survived the inline cap, and reports `total` (every result found) and + * `truncated`, so a UI never presents a capped result as complete. + * + * A second, independent cap bounds the JSON `meta` itself: the retained matches + * of a broad search (hundreds of long lines) can still serialize to hundreds of + * kilobytes, and `meta` is persisted with the session log and re-sent on every + * request. {@link capMetaBytes} drops trailing groups/paths until the serialized + * `meta` fits `maxMetaBytes` and marks the result `truncated`; a deployment's + * final output budget (`dsh-spill-policy`) only shrinks `content`, never `meta`, + * so this projection owns keeping `meta` bounded. * * @module @deepseek-ai/dsh-tool-fs-search/presentation */ @@ -23,9 +31,8 @@ import type { SearchLineMatch, SearchResultView, } from '@deepseek-ai/dsh-tools' -import { ItemRetainer } from '@deepseek-ai/dsh-retention' -import type { GrepMatch } from './grep.ts' -import { previewLine } from './grep.ts' +import type { RetainedItems } from '@deepseek-ai/dsh-retention' +import type { GrepMatch } from './search-core.ts' /** * The `grep`/`glob` tools' private `tool/result` `meta` payload: the capped, @@ -42,8 +49,8 @@ import { previewLine } from './grep.ts' * back as a {@link SearchResultView}. */ export type SearchMeta = - | { kind: 'matches'; files: MetaFileMatches[]; truncated: boolean; total: number } - | { kind: 'paths'; paths: string[]; truncated: boolean; total: number } + | { shape: 'matches'; files: MetaFileMatches[]; truncated: boolean; total: number } + | { shape: 'paths'; paths: string[]; truncated: boolean; total: number } /** One matched line in {@link SearchMeta} (the JSON-assignable form of {@link SearchLineMatch}). */ type MetaLineMatch = { lineNumber: number; line: string } @@ -72,38 +79,73 @@ export function groupMatchesByFile(matches: GrepMatch[]): MetaFileMatches[] { return Array.from(byFile, ([path, fileMatches]) => ({ path, matches: fileMatches })) } -/** - * Project the canonical `grep` matches into {@link SearchMeta} for the search - * card. Applies the per-line preview budget and the inline match cap exactly as - * the model-facing render does, groups the retained matches by file, and reports - * `total` (every parsed match) and `truncated`. - * - * @param matches - every match the search parsed (the canonical value's matches). - * @param maxMatches - the inline match cap (the `grepMaxMatches` config). - * @param maxLineBytes - the per-matched-line preview budget in bytes. - * @returns the `matches`-shaped search metadata. - */ -export function grepSearchMeta(matches: GrepMatch[], maxMatches: number, maxLineBytes: number): SearchMeta { - const retainer = new ItemRetainer({ kind: 'head', maxItems: maxMatches }) - for (const match of matches) retainer.push({ ...match, line: previewLine(match.line, maxLineBytes) }) - const retained = retainer.finish() - return { kind: 'matches', files: groupMatchesByFile(retained.items), truncated: retained.truncated, total: retained.seen } +/** The serialized UTF-8 byte size of one meta payload (the size persisted and re-sent). */ +function metaBytes(meta: SearchMeta): number { + return Buffer.byteLength(JSON.stringify(meta), 'utf8') } /** - * Project the canonical `glob` paths into {@link SearchMeta} for the search card. - * Applies the inline path cap exactly as the model-facing render does and reports - * `total` (every discovered path) and `truncated`. + * Drop trailing top-level items (file groups or paths) until the serialized meta + * fits `maxMetaBytes`, marking the result `truncated` when anything was dropped. + * `total` is preserved (it counts what the search found, not what meta retains). + * A single item too large to fit on its own is kept: the invariant is a bounded + * payload wherever droppable, never an empty card that hides a real result. * - * @param paths - every path the search discovered (the canonical value's paths). - * @param maxResults - the inline path cap (the `globMaxResults` config). + * @param meta - the projected meta, already capped to the inline item count. + * @param maxMetaBytes - the serialized-meta byte budget. + * @returns the same meta when it fits, else a byte-bounded copy marked `truncated`. + */ +function capMetaBytes(meta: SearchMeta, maxMetaBytes: number): SearchMeta { + if (metaBytes(meta) <= maxMetaBytes) return meta + if (meta.shape === 'matches') { + const files = [...meta.files] + while (files.length > 1 && metaBytes({ ...meta, files, truncated: true }) > maxMetaBytes) files.pop() + return { ...meta, files, truncated: true } + } + const paths = [...meta.paths] + while (paths.length > 1 && metaBytes({ ...meta, paths, truncated: true }) > maxMetaBytes) paths.pop() + return { ...meta, paths, truncated: true } +} + +/** + * Project the retained `grep` matches into {@link SearchMeta} for the search + * card. Consumes the same {@link RetainedItems} the model-facing render consumes + * (preview budget and inline match cap already applied), groups the retained + * matches by file, reports `total` (every parsed match) and `truncated`, then + * bounds the serialized meta to `maxMetaBytes`. + * + * @param retained - the retention outcome over every parsed match (previewed, capped). + * @param maxMetaBytes - the serialized-meta byte budget. + * @returns the `matches`-shaped search metadata. + */ +export function grepSearchMeta(retained: RetainedItems, maxMetaBytes: number): SearchMeta { + const meta: SearchMeta = { + shape: 'matches', + files: groupMatchesByFile(retained.items), + truncated: retained.truncated, + total: retained.seen, + } + return capMetaBytes(meta, maxMetaBytes) +} + +/** + * Project the retained `glob` paths into {@link SearchMeta} for the search card. + * Consumes the same {@link RetainedItems} the model-facing render consumes (inline + * path cap already applied), reports `total` (every discovered path) and + * `truncated`, then bounds the serialized meta to `maxMetaBytes`. + * + * @param retained - the retention outcome over every discovered path (capped). + * @param maxMetaBytes - the serialized-meta byte budget. * @returns the `paths`-shaped search metadata. */ -export function globSearchMeta(paths: string[], maxResults: number): SearchMeta { - const retainer = new ItemRetainer({ kind: 'head', maxItems: maxResults }) - for (const path of paths) retainer.push(path) - const retained = retainer.finish() - return { kind: 'paths', paths: retained.items, truncated: retained.truncated, total: retained.seen } +export function globSearchMeta(retained: RetainedItems, maxMetaBytes: number): SearchMeta { + const meta: SearchMeta = { + shape: 'paths', + paths: retained.items, + truncated: retained.truncated, + total: retained.seen, + } + return capMetaBytes(meta, maxMetaBytes) } /** Whether `value` is a valid {@link SearchLineMatch} (defensive narrowing from opaque `meta`). */ @@ -124,8 +166,13 @@ function isSearchFileMatches(value: unknown): value is SearchFileMatches { * Narrow opaque live or replayed result metadata to a {@link SearchResultView}. * Malformed metadata returns `undefined` so `presentResult` can fall back to the * generic card instead of throwing during replay of an older or hand-edited log. - * The returned view carries no `content`; the caller attaches the model-facing - * result text so a UI without a search card renders it as text. + * The view carries no result text: a UI without a search card falls back to the + * raw `tool/result` content. + * + * A zero-result meta (`files: []` / `paths: []`) narrows to a valid empty card — + * unlike the mirrored `diffsFromMeta`, which rejects empty diffs, because a + * zero-match grep is a legitimate result a UI shows as "no matches", not an + * absent projection. * * @param meta - result metadata (the {@link SearchMeta} the tool projected). * @returns the search view, or `undefined` for absent or malformed metadata. @@ -135,15 +182,15 @@ export function searchViewFromMeta(meta: unknown): SearchResultView | undefined const record = meta as Record const { truncated, total } = record if (typeof truncated !== 'boolean' || typeof total !== 'number') return undefined - if (record.kind === 'matches') { + if (record.shape === 'matches') { const { files } = record if (!Array.isArray(files) || !files.every(isSearchFileMatches)) return undefined - return { card: 'search', kind: 'matches', files: files, truncated, total } + return { card: 'search', shape: 'matches', files: files, truncated, total } } - if (record.kind === 'paths') { + if (record.shape === 'paths') { const { paths } = record if (!Array.isArray(paths) || !paths.every((path): path is string => typeof path === 'string')) return undefined - return { card: 'search', kind: 'paths', paths, truncated, total } + return { card: 'search', shape: 'paths', paths, truncated, total } } return undefined } diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 0eff077fea..402fc9d655 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -19,6 +19,8 @@ import { isAbsolute, relative, sep } from 'node:path' import type { Context } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' +import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention' +import type { RetainedItems } from '@deepseek-ai/dsh-retention' import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import type { ToolExecution } from '@deepseek-ai/dsh-tools' @@ -36,6 +38,18 @@ export const RAW_OUTPUT_MAX_BYTES = 20_000_000 */ export const SEARCH_TIMEOUT_MS = 30_000 +/** + * Default cap in bytes on one search's serialized `presentationMeta` (the + * `searchMetaMaxBytes` config). The inline match/path caps already bound the item + * COUNT, but retained matches of a broad search (many long lines) can still + * serialize to hundreds of kilobytes, and `meta` is persisted with the session + * log and re-sent on every request. A deployment's final output budget + * (`dsh-spill-policy`) only shrinks a result's `content`, never its `meta`, so the + * projection owns this cap. 64 KiB holds the full default-capped result of a + * typical search while bounding the pathological one. + */ +export const SEARCH_META_MAX_BYTES = 65_536 + /** * Stable, machine-routable codes for search failures. Package-owned (not * `FsErrorCode`) because these tools are bash-backed discovery, not `ctx.fs` @@ -212,6 +226,63 @@ export function toWorkdirRelative(path: string, workdir: string): string { return rel } +/** One parsed match: the file, the 1-based line number, and the (possibly previewed) line text. */ +export interface GrepMatch { + path: string + lineNumber: number + line: string +} + +/** + * Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and + * mark the cut. The cap is a per-line budget fact; the complete line stays in + * the searched file for `read`. + * + * @param line - the matched line text (trailing newline already stripped). + * @param maxBytes - the preview budget in bytes. + * @returns the preview, suffixed with ` (line truncated)` when bytes were cut. + */ +export function previewLine(line: string, maxBytes: number): string { + const retainer = new TextRetainer({ kind: 'head', maxBytes }) + retainer.push(line) + const kept = retainer.finish() + return kept.truncated ? `${kept.text} (line truncated)` : kept.text +} + +/** + * Apply the shared inline cap to a canonical `grep` match list: preview each + * retained line to `maxLineBytes` and keep the first `maxMatches`. The single + * retention pass both the model-facing render ({@link module:@deepseek-ai/dsh-tool-fs-search/grep} + * `formatGrepOutput`) and the search-card projection + * ({@link module:@deepseek-ai/dsh-tool-fs-search/presentation} `grepSearchMeta`) + * consume, so text and card never disagree about which matches survived. + * + * @param matches - every match the search parsed (the canonical value's matches). + * @param maxMatches - the inline match cap (the `grepMaxMatches` config). + * @param maxLineBytes - the per-matched-line preview budget in bytes. + * @returns the retention outcome over the previewed matches. + */ +export function retainGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number): RetainedItems { + const retainer = new ItemRetainer({ kind: 'head', maxItems: maxMatches }) + for (const match of matches) retainer.push({ ...match, line: previewLine(match.line, maxLineBytes) }) + return retainer.finish() +} + +/** + * Apply the shared inline cap to a canonical `glob` path list: keep the first + * `maxResults`. The single retention pass both the model-facing render and the + * search-card projection consume. + * + * @param paths - every path the search discovered (the canonical value's paths). + * @param maxResults - the inline path cap (the `globMaxResults` config). + * @returns the retention outcome over the paths. + */ +export function retainGlobPaths(paths: string[], maxResults: number): RetainedItems { + const retainer = new ItemRetainer({ kind: 'head', maxItems: maxResults }) + for (const path of paths) retainer.push(path) + return retainer.finish() +} + /** * Best-effort save of one COMPLETE formatted search result through * `ctx.spillStore.saveText()` — the model-facing recovery path for a capped diff --git a/packages/fs/tool-fs-search/tests/presentation.spec.ts b/packages/fs/tool-fs-search/tests/presentation.spec.ts index 7f3131a2ab..59c47aece2 100644 --- a/packages/fs/tool-fs-search/tests/presentation.spec.ts +++ b/packages/fs/tool-fs-search/tests/presentation.spec.ts @@ -2,9 +2,10 @@ * Unit tests for the search-card presentation layer (`src/presentation.ts`): the * canonical value → `presentationMeta` projections (`grepSearchMeta`, * `globSearchMeta`, `groupMatchesByFile`) and the defensive `meta` → view - * narrowing (`searchViewFromMeta`). These pin the by-file grouping, the inline - * cap and `truncated`/`total` honesty, and the malformed-metadata fallback a - * replayed or hand-edited log can deliver. + * narrowing (`searchViewFromMeta`). These pin the by-file grouping, the + * `truncated`/`total` honesty over already-retained input, the serialized-meta + * byte cap, and the malformed-metadata fallback a replayed or hand-edited log can + * deliver. */ import { describe, expect, it } from 'vitest' @@ -15,10 +16,14 @@ import { groupMatchesByFile, searchViewFromMeta, } from '../src/presentation.ts' -import type { GrepMatch } from '../src/grep.ts' +import type { GrepMatch } from '../src/search-core.ts' +import { retainGlobPaths, retainGrepMatches } from '../src/search-core.ts' const match = (path: string, lineNumber: number, line: string): GrepMatch => ({ path, lineNumber, line }) +/** A byte cap large enough that no test payload here is meta-capped. */ +const WIDE = 1_000_000 + describe('groupMatchesByFile', () => { it('groups matches by first-seen file order, keeping line/lineNumber only', () => { expect(groupMatchesByFile([ @@ -38,38 +43,73 @@ describe('groupMatchesByFile', () => { describe('grepSearchMeta', () => { it('projects grouped matches with total and a false truncation flag within the cap', () => { - const meta = grepSearchMeta([match('a.ts', 1, 'one'), match('a.ts', 2, 'two')], 10, 2000) + const meta = grepSearchMeta(retainGrepMatches([match('a.ts', 1, 'one'), match('a.ts', 2, 'two')], 10, 2000), WIDE) expect(meta).toEqual({ - kind: 'matches', + shape: 'matches', files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }], truncated: false, total: 2, }) }) - it('caps the retained matches and reports the pre-cap total when truncated', () => { - const meta = grepSearchMeta([match('a.ts', 1, 'one'), match('a.ts', 2, 'two'), match('b.ts', 3, 'three')], 2, 2000) + it('reports the pre-cap total and truncation from the shared retention pass', () => { + const meta = grepSearchMeta(retainGrepMatches([match('a.ts', 1, 'one'), match('a.ts', 2, 'two'), match('b.ts', 3, 'three')], 2, 2000), WIDE) expect(meta).toEqual({ - kind: 'matches', + shape: 'matches', files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }], truncated: true, total: 3, }) }) - it('applies the per-line preview budget (UTF-8 boundary) to the projected line', () => { - const meta = grepSearchMeta([match('a.txt', 1, 'aéaéaéaé')], 10, 7) - expect(meta).toMatchObject({ kind: 'matches', files: [{ path: 'a.txt', matches: [{ lineNumber: 1, line: 'aéaéa (line truncated)' }] }] }) + it('carries the per-line preview budget (UTF-8 boundary) the retention pass applied', () => { + const meta = grepSearchMeta(retainGrepMatches([match('a.txt', 1, 'aéaéaéaé')], 10, 7), WIDE) + expect(meta).toMatchObject({ shape: 'matches', files: [{ path: 'a.txt', matches: [{ lineNumber: 1, line: 'aéaéa (line truncated)' }] }] }) + }) + + it('drops trailing file groups until the serialized meta fits the byte cap, marking it truncated', () => { + const retained = retainGrepMatches( + [match('a.ts', 1, 'x'.repeat(60)), match('b.ts', 2, 'y'.repeat(60)), match('c.ts', 3, 'z'.repeat(60))], + 10, + 2000, + ) + // One 60-byte group serializes to ~110 bytes; a 260-byte cap holds two, not three. + const meta = grepSearchMeta(retained, 260) + expect(meta.shape).toBe('matches') + if (meta.shape !== 'matches') throw new Error('unreachable') + expect(meta.truncated).toBe(true) + expect(meta.total).toBe(3) + expect(meta.files.length).toBeLessThan(3) + expect(Buffer.byteLength(JSON.stringify(meta), 'utf8')).toBeLessThanOrEqual(260) + }) + + it('keeps a single oversized group rather than emit an empty card', () => { + const meta = grepSearchMeta(retainGrepMatches([match('a.ts', 1, 'x'.repeat(500))], 10, 2000), 50) + expect(meta.shape).toBe('matches') + if (meta.shape !== 'matches') throw new Error('unreachable') + expect(meta.files).toHaveLength(1) + expect(meta.truncated).toBe(true) }) }) describe('globSearchMeta', () => { it('projects the path list with total and a false truncation flag within the cap', () => { - expect(globSearchMeta(['a.ts', 'b.ts'], 10)).toEqual({ kind: 'paths', paths: ['a.ts', 'b.ts'], truncated: false, total: 2 }) + expect(globSearchMeta(retainGlobPaths(['a.ts', 'b.ts'], 10), WIDE)).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: false, total: 2 }) }) - it('caps the retained paths and reports the pre-cap total when truncated', () => { - expect(globSearchMeta(['a.ts', 'b.ts', 'c.ts'], 2)).toEqual({ kind: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 }) + it('reports the pre-cap total and truncation from the shared retention pass', () => { + expect(globSearchMeta(retainGlobPaths(['a.ts', 'b.ts', 'c.ts'], 2), WIDE)).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 }) + }) + + it('drops trailing paths until the serialized meta fits the byte cap, marking it truncated', () => { + const retained = retainGlobPaths([`${'a'.repeat(100)}.ts`, `${'b'.repeat(100)}.ts`, `${'c'.repeat(100)}.ts`], 10) + const meta = globSearchMeta(retained, 180) + expect(meta.shape).toBe('paths') + if (meta.shape !== 'paths') throw new Error('unreachable') + expect(meta.truncated).toBe(true) + expect(meta.total).toBe(3) + expect(meta.paths.length).toBeLessThan(3) + expect(Buffer.byteLength(JSON.stringify(meta), 'utf8')).toBeLessThanOrEqual(180) }) }) @@ -80,15 +120,22 @@ describe('searchViewFromMeta (defensive narrowing)', () => { const m = (value: unknown): JsonValue | undefined => value as JsonValue | undefined it('narrows a well-formed matches payload into a matches view', () => { - const meta = { kind: 'matches', files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }] }], truncated: true, total: 5 } + const meta = { shape: 'matches', files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }] }], truncated: true, total: 5 } expect(searchViewFromMeta(m(meta))).toEqual({ card: 'search', ...meta }) }) it('narrows a well-formed paths payload into a paths view', () => { - const meta = { kind: 'paths', paths: ['a.ts', 'b.ts'], truncated: false, total: 2 } + const meta = { shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: false, total: 2 } expect(searchViewFromMeta(m(meta))).toEqual({ card: 'search', ...meta }) }) + it('narrows a zero-result payload into a valid empty card (not a rejected projection)', () => { + expect(searchViewFromMeta(m({ shape: 'matches', files: [], truncated: false, total: 0 }))) + .toEqual({ card: 'search', shape: 'matches', files: [], truncated: false, total: 0 }) + expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: false, total: 0 }))) + .toEqual({ card: 'search', shape: 'paths', paths: [], truncated: false, total: 0 }) + }) + it('rejects undefined / non-object / array meta', () => { expect(searchViewFromMeta(undefined)).toBeUndefined() expect(searchViewFromMeta(null)).toBeUndefined() @@ -97,19 +144,19 @@ describe('searchViewFromMeta (defensive narrowing)', () => { }) it('rejects a payload with a missing / mistyped truncated or total field', () => { - expect(searchViewFromMeta(m({ kind: 'paths', paths: [], total: 0 }))).toBeUndefined() - expect(searchViewFromMeta(m({ kind: 'paths', paths: [], truncated: 'no', total: 0 }))).toBeUndefined() - expect(searchViewFromMeta(m({ kind: 'paths', paths: [], truncated: false }))).toBeUndefined() - expect(searchViewFromMeta(m({ kind: 'paths', paths: [], truncated: false, total: '0' }))).toBeUndefined() + expect(searchViewFromMeta(m({ shape: 'paths', paths: [], total: 0 }))).toBeUndefined() + expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: 'no', total: 0 }))).toBeUndefined() + expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: false }))).toBeUndefined() + expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: false, total: '0' }))).toBeUndefined() }) - it('rejects an unknown or missing kind discriminant', () => { - expect(searchViewFromMeta(m({ kind: 'other', truncated: false, total: 0 }))).toBeUndefined() + it('rejects an unknown or missing shape discriminant', () => { + expect(searchViewFromMeta(m({ shape: 'other', truncated: false, total: 0 }))).toBeUndefined() expect(searchViewFromMeta(m({ truncated: false, total: 0 }))).toBeUndefined() }) it('rejects a matches payload with a malformed files array', () => { - const base = { kind: 'matches', truncated: false, total: 1 } + const base = { shape: 'matches', truncated: false, total: 1 } expect(searchViewFromMeta(m({ ...base, files: 'x' }))).toBeUndefined() expect(searchViewFromMeta(m({ ...base, files: [null] }))).toBeUndefined() expect(searchViewFromMeta(m({ ...base, files: ['x'] }))).toBeUndefined() @@ -122,7 +169,7 @@ describe('searchViewFromMeta (defensive narrowing)', () => { }) it('rejects a paths payload with a non-array or non-string-element paths field', () => { - const base = { kind: 'paths', truncated: false, total: 1 } + const base = { shape: 'paths', truncated: false, total: 1 } expect(searchViewFromMeta(m({ ...base, paths: 'x' }))).toBeUndefined() expect(searchViewFromMeta(m({ ...base, paths: [1] }))).toBeUndefined() }) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 6d55395e3a..3e761531ca 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -817,7 +817,7 @@ describe('presentation', () => { if (result.isError) throw new Error('expected grep success') // The presentationMeta projection rides the result meta (a surface call). expect(result.meta).toEqual({ - kind: 'matches', + shape: 'matches', files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }], truncated: true, total: 3, @@ -825,11 +825,10 @@ describe('presentation', () => { const view = presentGrepResult({ pattern: 'e' }, result) expect(view).toEqual({ card: 'search', - kind: 'matches', + shape: 'matches', files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }], truncated: true, total: 3, - content: result.content, }) }) @@ -838,9 +837,9 @@ describe('presentation', () => { bash.handler = () => runResult('a.ts\nb.ts\nc.ts\n') const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') }) if (result.isError) throw new Error('expected glob success') - expect(result.meta).toEqual({ kind: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 }) + expect(result.meta).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 }) const view = presentGlobResult({ pattern: '*.ts' }, result) - expect(view).toEqual({ card: 'search', kind: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3, content: result.content }) + expect(view).toEqual({ card: 'search', shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 }) }) it('nested Code dispatch computes no meta, so presentResult falls back to the generic card', async () => { @@ -860,15 +859,15 @@ describe('presentation', () => { expect(presentGrepResult({ pattern: 'x' }, errorResult)).toBeUndefined() expect(presentGlobResult({ pattern: '*' }, errorResult)).toBeUndefined() // A grep result carrying a paths-shaped meta (and vice versa) is not this - // tool's shape: each presenter narrows to its own kind and otherwise falls back. - const pathsResult = { content: [], isError: false, meta: { kind: 'paths', paths: ['a.ts'], truncated: false, total: 1 } } - const matchesResult = { content: [], isError: false, meta: { kind: 'matches', files: [], truncated: false, total: 0 } } + // tool's shape: each presenter narrows to its own shape and otherwise falls back. + const pathsResult = { content: [], isError: false, meta: { shape: 'paths', paths: ['a.ts'], truncated: false, total: 1 } } + const matchesResult = { content: [], isError: false, meta: { shape: 'matches', files: [], truncated: false, total: 0 } } expect(presentGrepResult({ pattern: 'x' }, pathsResult)).toBeUndefined() expect(presentGlobResult({ pattern: '*' }, matchesResult)).toBeUndefined() }) it('presentResult falls back to the generic card on malformed replayed meta', () => { - const malformed = { content: [], isError: false, meta: { kind: 'matches', files: 'nope', truncated: false, total: 0 } } + const malformed = { content: [], isError: false, meta: { shape: 'matches', files: 'nope', truncated: false, total: 0 } } expect(presentGrepResult({ pattern: 'x' }, malformed)).toBeUndefined() expect(presentGlobResult({ pattern: '*' }, { content: [], isError: false, meta: 42 })).toBeUndefined() }) diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 58d3d6a178..77c1eb36af 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -389,7 +389,15 @@ export class ToolCardComponent implements Component { const glyph = this.result === undefined ? '○' : '●' const rawBody = this.renderBody() const view = this.resultView ?? this.callView - const genericContent = view.card === 'generic' ? view.content ?? this.result?.content : undefined + // A search card (grep/glob results) carries no dedicated TUI rendering and no + // result text of its own: it falls back to the same dim Markdown body as a + // generic card, reading the model-facing text from the raw result content. + // Its structured shape is consumed by capable UIs; the TUI stays + // byte-identical to the pre-search-card generic fallback. Terminal and diff + // cards keep their own body branches. + const genericContent = view.card === 'generic' + ? view.content ?? this.result?.content + : view.card === 'search' ? this.result?.content : undefined const unknownXml = this.definition === undefined && genericContent !== undefined ? renderUnknownXml( displayText(contentText(genericContent)), @@ -502,7 +510,10 @@ export class ToolCardComponent implements Component { // rather than under the dim result-output color. return { prelude: [...hunks, footer], lines: [] } } - const content = view.content ?? this.result?.content + // A search card carries no result text of its own; only a generic view + // supplies `content`. Both fall back to the raw result content below. + const viewContent = view.card === 'generic' ? view.content : undefined + const content = viewContent ?? this.result?.content const prelude: string[] = [] const lines: string[] = [] // The presenter title headlines the body now that the header is a fixed