From 3e22adab2878ec5f78f3253877cd8e1a346a4250 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 17:03:54 +0800 Subject: [PATCH] feat(fs): add a search render-intent card for grep and glob results grep and glob returned only model-facing text; the structured matches/paths never reached the client. Add a card:'search' result view with a kind discriminant ('matches' grouped by file for grep, 'paths' for glob), projected through each tool's output.presentationMeta and read back in presentResult. The projections re-apply the same inline cap and per-line budget as the render text and report total + truncated, so a UI never presents a capped page as complete. A UI without the search card falls back to content; the TUI is unchanged. The web consumer is a follow-up. --- .../2026-07-30-search-render-card.i18n.yaml | 6 + .../feature/2026-07-30-search-render-card.md | 51 ++++++ .../2026-07-30-search-render-card.zh.md | 51 ++++++ docs/cordis-catalog/events.md | 12 +- docs/cordis-catalog/services.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 22 ++- packages/core/tools/src/index.ts | 5 + packages/core/tools/src/presentation.ts | 89 ++++++++++- packages/fs/tool-fs-search/src/glob.ts | 23 ++- packages/fs/tool-fs-search/src/grep.ts | 26 ++- packages/fs/tool-fs-search/src/index.ts | 5 +- .../fs/tool-fs-search/src/presentation.ts | 149 ++++++++++++++++++ .../tool-fs-search/tests/presentation.spec.ts | 129 +++++++++++++++ .../fs/tool-fs-search/tests/tools.spec.ts | 70 ++++++++ 14 files changed, 628 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-search-render-card.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-search-render-card.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-search-render-card.zh.md create mode 100644 packages/fs/tool-fs-search/src/presentation.ts create mode 100644 packages/fs/tool-fs-search/tests/presentation.spec.ts 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 new file mode 100644 index 0000000000..4a00c287a2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-search-render-card.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-search-render-card.md +2026-07-30-search-render-card.md: de59992cebcdf056e3446e4f546f8bff4b10e421 +2026-07-30-search-render-card.zh.md: 8b91255094c24972c05add92ee65f8c76c60a882 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 new file mode 100644 index 0000000000..de59992ceb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-search-render-card.md @@ -0,0 +1,51 @@ +# Agent Note: Search render intent — grep and glob emit a structured search card + +Status: implemented + +English | [中文](2026-07-30-search-render-card.zh.md) + +## Problem + +`grep` and `glob` return structured canonical values — `grep` a flat `{ matches: [{ path, lineNumber, line }] }`, `glob` a `{ paths: string[] }` — but every UI only ever saw their model-facing render text: `grep` groups its matches under file headers with `Line N:` rows, `glob` prints a newline-joined path list, and both append a spill footer when the inline cap ({@link module:@deepseek-ai/dsh-tool-fs-search/grep} `grepMaxMatches`, default 250; {@link module:@deepseek-ai/dsh-tool-fs-search/glob} `globMaxResults`, default 100) drops later results to a spill file. A web frontend that wants to render a search result as an expandable per-file group of matches, or as a selectable path list, had to re-parse that text. Both tools already declared a call-time [render intent](../architecture/2026-07-02-tool-render-intent-union.md) (`GenericCallView`, `kind: 'search'`) but no result-time view, so the completed call fell back to the generic card that renders the raw text. + +The structured canonical value does not cross the wire: only the model-facing render text and, when a tool declares `output.presentationMeta`, a JSON metadata payload reach the client, threaded through the `tool/result` event ([canonical-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md)). A result-time view carrying structured data therefore has to project that data into `presentationMeta` and read it back in `presentResult` — the same path `write`/`edit` use for their diff cards. + +## Decision + +`packages/core/tools/src/presentation.ts` adds `card: 'search'` to the `ToolResultView` union as `SearchResultView`, a `kind`-discriminated view that expresses both tools' shapes: `SearchMatchesResultView` (`kind: 'matches'`) carries `grep`'s matches grouped by file as `files: { path, matches: { lineNumber, line }[] }[]`, and `SearchPathsResultView` (`kind: 'paths'`) carries `glob`'s flat `paths: string[]`. Both carry `truncated: boolean` and `total: number`, and an optional `content?: ContentBlock[]`. + +One view with two shapes rather than two cards, because both tools are the same visual object — a search result — and a web consumer switches on one `card` value, then on `kind` for the row shape. The discriminated `kind` keeps each shape's fields non-optional (a matches view always has `files`, a paths view always has `paths`) instead of a single interface where every shape-specific field is optional. + +The card tag is result-time only. A search call stays a `GenericCallView` (`kind: 'search'`): the pending state has no matches or paths to show, so there is nothing a `SearchCallView` would carry that the generic title does not. This is the asymmetry with the terminal card, whose call view carries the command, cwd, and description that exist before execution; a search's structured content exists only after `execute`. + +`packages/fs/tool-fs-search/src/presentation.ts` owns the projection and the narrowing. `grepSearchMeta`/`globSearchMeta` project the canonical value into a `SearchMeta` payload each tool declares as `output.presentationMeta`; `presentGrepResult`/`presentGlobResult` read `result.meta` back through `searchViewFromMeta` and attach the model-facing `result.content` as the view's `content`. The projections apply the SAME inline cap and per-line preview budget the model-facing render applies, and report `total` as every result the search found (before capping) with `truncated` set when the cap dropped results. This is the truncation-honesty point: the model saw a capped inline result plus a spill footer, so the card must not present the retained page as the complete result — a UI reads `truncated`/`total` to show a capped indicator rather than claiming completeness the model never had. + +`searchViewFromMeta` narrows the opaque `meta` defensively and returns `undefined` on any malformed or absent payload, exactly as `diffsFromMeta` does, so a presenter run on an older or hand-edited replayed log falls back to the generic card instead of throwing. `presentResult` returns `undefined` for a failed result, for absent meta (a nested `run_code` dispatch computes no `presentationMeta`), and for the other tool's meta shape (each presenter narrows to its own `kind`). + +The `SearchMeta` member shapes are object-literal `type` aliases, not the `SearchFileMatches`/`SearchLineMatch` interfaces the view exposes. Only a type alias is assignable to the `JsonValue` index signature `presentationMeta` returns; the two are structurally identical, so the projected value still reads back as a `SearchResultView`. + +The TUI (`packages/ui/tui/src/components/transcript.ts`) needs no dedicated arm: its result-view switch handles `terminal` and `diff` explicitly and falls through to a generic arm that renders `view.content ?? this.result?.content`. Because `SearchResultView` carries the model-facing text as `content`, the TUI renders it as the same text it already showed. The web frontend that renders the structured `files`/`paths` shape is a separate later PR; this PR is the backend contract and its two producers. + +## Alternatives considered + +**A single flat `SearchResultView` interface with optional `files?` and `paths?`.** Rejected: it makes both shape-specific fields optional on every value and lets a malformed view carry both or neither. The `kind` discriminant keeps each shape's fields required and lets a consumer switch exhaustively. + +**A call-time `SearchCallView` mirroring the terminal card's both-sides symmetry.** Rejected: a search call has no matches or paths before `execute`, so the view would carry only the title the `GenericCallView` already carries. The terminal card's call view earns its tag because a command, cwd, and description exist at call time; a search's structured content does not. + +**Carry the structured result in a bespoke channel instead of `presentationMeta`.** Rejected: the canonical value is execution-local and never reaches the client, and `presentationMeta` is the established seam that persists a tool's JSON presentation payload with `tool/result` and threads it back to `presentResult`. Adding a second channel would duplicate that path. + +## Consequences + +`grep` and `glob` now compute `presentationMeta` on every non-nested successful call, a bounded projection over the already-parsed matches or paths. The projection re-applies the retention cap the render already applied, so the retained set is computed twice per call; the input is bounded by the raw-output cap, so this is not a new scaling concern. + +A UI without a search card renders the attached `content` text, so no consumer regresses. The web consumer that renders the structured shape reads `truncated`/`total` and the per-file groups; because the view carries only the retained page, a UI wanting the complete result follows the spill locator in the model-facing text, exactly as the model does. + +## Testing + +`packages/fs/tool-fs-search/tests/presentation.spec.ts` pins the pure layer: `groupMatchesByFile`'s first-seen file order, `grepSearchMeta`/`globSearchMeta` projection with the cap applied and `total` reporting the pre-cap count, the per-line preview budget on a projected match line, and `searchViewFromMeta`'s narrowing of both good shapes plus every malformed case (non-object/array meta, missing or mistyped `truncated`/`total`, unknown `kind`, malformed `files` entries, non-string `paths`). `packages/fs/tool-fs-search/tests/tools.spec.ts` pins the wiring through the real tool registry: a capped `grep`/`glob` execute produces the `SearchMeta` on `result.meta` and `presentResult` builds the search view with `content` attached, a nested `run_code` dispatch computes no meta so `presentResult` falls back, and a failed or cross-shape or malformed result falls back to the generic card. Per-file 100% coverage holds over the search package `src`. + +## Related + +- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this extends with the `search` result tag. +- [Canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) — the value/render/`presentationMeta` split this projection rides; the structured value stays execution-local, the card rides `meta`. +- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors on the backend: a tool projects its result into `presentationMeta` and a `presentResult` view; the search card's web consumer is the analogous follow-up. 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 new file mode 100644 index 0000000000..8b91255094 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-search-render-card.zh.md @@ -0,0 +1,51 @@ +# Agent Note: Search render intent — grep and glob emit a structured search card + +Status: implemented + +[English](2026-07-30-search-render-card.md) | 中文 + +## Problem + +`grep` 与 `glob` 返回结构化的规范值——`grep` 是扁平的 `{ matches: [{ path, lineNumber, line }] }`,`glob` 是 `{ paths: string[] }`——但每一个 UI 见到的只有它们面向模型的渲染文本:`grep` 把匹配按文件分组,文件头下是 `Line N:` 行;`glob` 打印换行连接的路径列表;当内联上限({@link module:@deepseek-ai/dsh-tool-fs-search/grep} `grepMaxMatches`,默认 250;{@link module:@deepseek-ai/dsh-tool-fs-search/glob} `globMaxResults`,默认 100)把后续结果溢出到 spill 文件时,两者都追加一段溢出脚注。想把搜索结果渲染成可展开的按文件分组匹配、或渲染成可选择的路径列表的 web 前端,只能去重新解析这段文本。两个工具都已声明了调用期的[渲染意图](../architecture/2026-07-02-tool-render-intent-union.md)(`GenericCallView`,`kind: 'search'`),但没有结果期视图,于是已完成的调用回退到渲染原始文本的通用卡片。 + +结构化的规范值不过线:只有面向模型的渲染文本、以及当工具声明 `output.presentationMeta` 时的一段 JSON 元数据抵达客户端,二者通过 `tool/result` 事件穿线([规范输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md))。因此携带结构化数据的结果期视图必须把该数据投射进 `presentationMeta`,再在 `presentResult` 里读回——正是 `write`/`edit` 的 diff 卡片所走的路径。 + +## Decision + +`packages/core/tools/src/presentation.ts` 向 `ToolResultView` 联合类型加入 `card: 'search'`,即 `SearchResultView`:一个以 `kind` 区分的视图,表达两个工具的形状。`SearchMatchesResultView`(`kind: 'matches'`)以 `files: { path, matches: { lineNumber, line }[] }[]` 携带 `grep` 按文件分组的匹配;`SearchPathsResultView`(`kind: 'paths'`)携带 `glob` 的扁平 `paths: string[]`。两者都携带 `truncated: boolean` 与 `total: number`,以及可选的 `content?: ContentBlock[]`。 + +一个视图两种形状,而非两张卡片,因为两个工具是同一个视觉对象——一个搜索结果——web 消费方先在一个 `card` 值上分派,再在 `kind` 上分派行的形状。区分性的 `kind` 让每种形状各自的字段保持非可选(matches 视图恒有 `files`,paths 视图恒有 `paths`),而不是让所有形状相关字段都变成可选的单一接口。 + +卡片标签只在结果期。搜索调用仍是 `GenericCallView`(`kind: 'search'`):pending 状态没有匹配或路径可展示,因此 `SearchCallView` 能携带的东西不会超出通用标题。这是与 terminal 卡片的不对称之处——terminal 的调用视图携带执行前就存在的命令、cwd 与描述;而搜索的结构化内容只在 `execute` 之后才存在。 + +`packages/fs/tool-fs-search/src/presentation.ts` 拥有投射与收窄。`grepSearchMeta`/`globSearchMeta` 把规范值投射为一段 `SearchMeta`,各工具将其声明为 `output.presentationMeta`;`presentGrepResult`/`presentGlobResult` 通过 `searchViewFromMeta` 把 `result.meta` 读回,并把面向模型的 `result.content` 作为视图的 `content` 附上。投射施加与面向模型渲染相同的内联上限与每行预览预算,并把 `total` 报告为搜索找到的全部结果(截断之前),当上限丢弃了结果时把 `truncated` 置为真。这就是截断诚实性的要点:模型看到的是被截断的内联结果加一段溢出脚注,因此卡片不得把保留的那一页当作完整结果呈现——UI 读取 `truncated`/`total` 去展示截断指示,而非宣称模型从未拥有的完整性。 + +`searchViewFromMeta` 防御性地收窄不透明的 `meta`,对任何畸形或缺失的 payload 返回 `undefined`,与 `diffsFromMeta` 完全一致,因此在较旧或手工编辑过的回放日志上运行的呈现器会回退到通用卡片而非抛错。`presentResult` 对失败结果、对缺失的 meta(嵌套 `run_code` 分发不计算 `presentationMeta`)、对另一个工具的 meta 形状(每个呈现器只收窄到自己的 `kind`)都返回 `undefined`。 + +`SearchMeta` 的成员形状是对象字面量 `type` 别名,而不是视图对外暴露的 `SearchFileMatches`/`SearchLineMatch` 接口。只有 type 别名可以赋值给 `presentationMeta` 返回的 `JsonValue` 索引签名;二者结构完全相同,因此投射出的值仍能读回为 `SearchResultView`。 + +TUI(`packages/ui/tui/src/components/transcript.ts`)无需专用分支:它的结果视图 switch 显式处理 `terminal` 与 `diff`,并落到一个渲染 `view.content ?? this.result?.content` 的通用分支。因为 `SearchResultView` 以 `content` 携带了面向模型的文本,TUI 渲染出的仍是它此前已展示的同一段文本。渲染结构化 `files`/`paths` 形状的 web 前端是后续独立的 PR;本 PR 是后端契约及其两个生产者。 + +## Alternatives considered + +**单一扁平的 `SearchResultView` 接口,带可选的 `files?` 与 `paths?`。** 否决:它让两种形状相关字段在每个值上都成为可选,并允许一个畸形视图同时携带二者或都不携带。`kind` 区分符让每种形状的字段保持必填,并让消费方能穷尽分派。 + +**一个调用期的 `SearchCallView`,镜像 terminal 卡片两侧对称。** 否决:搜索调用在 `execute` 之前没有匹配或路径,视图只会携带 `GenericCallView` 已携带的标题。terminal 卡片的调用视图之所以配得上其标签,是因为命令、cwd 与描述在调用期就存在;而搜索的结构化内容不存在。 + +**用一个专门的通道而非 `presentationMeta` 携带结构化结果。** 否决:规范值是执行局部的、绝不抵达客户端,而 `presentationMeta` 是既有的接缝,它把工具的 JSON 呈现 payload 随 `tool/result` 持久化并穿线回 `presentResult`。再加一条通道只会重复这条路径。 + +## Consequences + +`grep` 与 `glob` 现在在每次非嵌套的成功调用上计算 `presentationMeta`,这是对已解析的匹配或路径做的一次有界投射。投射重新施加渲染已施加过的保留上限,因此每次调用会计算两遍保留集;输入受原始输出上限约束,故这不是新的伸缩性问题。 + +没有搜索卡片的 UI 渲染附上的 `content` 文本,因此没有消费方回退。渲染结构化形状的 web 消费方读取 `truncated`/`total` 与按文件分组;因为视图只携带保留的那一页,想要完整结果的 UI 沿面向模型文本里的 spill 定位符去取,与模型的做法完全一致。 + +## Testing + +`packages/fs/tool-fs-search/tests/presentation.spec.ts` 钉住纯函数层:`groupMatchesByFile` 的首见文件顺序,`grepSearchMeta`/`globSearchMeta` 施加上限后的投射与把 `total` 报告为截断前计数,投射出的匹配行上的每行预览预算,以及 `searchViewFromMeta` 对两种良态形状的收窄外加所有畸形情形(非对象/数组 meta、缺失或类型错误的 `truncated`/`total`、未知 `kind`、畸形 `files` 条目、非字符串 `paths`)。`packages/fs/tool-fs-search/tests/tools.spec.ts` 通过真实工具注册表钉住穿线:一次被截断的 `grep`/`glob` execute 在 `result.meta` 上产出 `SearchMeta`,且 `presentResult` 构建出附带 `content` 的搜索视图;嵌套 `run_code` 分发不计算 meta 于是 `presentResult` 回退;失败、跨形状或畸形结果回退到通用卡片。搜索包 `src` 上维持逐文件 100% 覆盖。 + +## Related + +- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) —— 本 PR 以 `search` 结果标签扩展的 `card` 标签词汇。 +- [Canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) —— 本投射所乘的 value/render/`presentationMeta` 拆分;结构化值留在执行局部,卡片乘 `meta`。 +- [Web terminal card](2026-07-28-web-terminal-card.md) —— 本 PR 在后端所镜像的先例:工具把结果投射进 `presentationMeta` 与一个 `presentResult` 视图;搜索卡片的 web 消费方是类似的后续工作。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index dafa342d5a..7abb71cb40 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -841,7 +841,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:156`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:161`](../../packages/core/tools/src/index.ts) ### `tools/code-dispatch-log` — waterfall @@ -865,7 +865,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:143`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -887,7 +887,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:118`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -910,7 +910,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:125`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:130`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall @@ -931,7 +931,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:102`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:107`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit @@ -950,7 +950,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:146`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:151`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c655de3a70..aebc61a3b5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2174,7 +2174,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:700`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:705`](../../packages/core/tools/src/index.ts) ## `ctx.tui` — `TuiExtensionService` (abstract seam) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 60f7d330d8..20c534ba36 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2155,6 +2155,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ScopeKey', declaration: 'export type ScopeKey = object;', }, + { + name: 'SearchFileMatches', + declaration: 'export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n}', + }, + { + name: 'SearchLineMatch', + declaration: 'export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n}', + }, + { + 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}', + }, + { + 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}', + }, + { + name: 'SearchResultView', + declaration: 'export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;', + }, { name: 'SendOptions', declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n}', @@ -2697,7 +2717,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolResultView', - declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;', + declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView;', }, { name: 'ToolRunContext', diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 2caaaa8276..4a91808a4c 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -82,6 +82,11 @@ export type { GenericResultView, TerminalResultView, DiffResultView, + SearchResultView, + SearchMatchesResultView, + SearchPathsResultView, + SearchFileMatches, + SearchLineMatch, } from './presentation.ts' declare module 'cordis' { diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts index 17b88b822f..338a73faa1 100644 --- a/packages/core/tools/src/presentation.ts +++ b/packages/core/tools/src/presentation.ts @@ -125,7 +125,7 @@ export interface DiffCallView { * `ToolDefinition.presentResult`; omitting the method keeps the pending * title and renders the raw result content. */ -export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView +export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView /** * The default completed card: an optional replacement title and reformatted @@ -176,3 +176,90 @@ export interface DiffResultView { /** The change to show, in file order — applied contextual hunks, or a whole-file diff when there is no before-image. */ diffs: FileDiff[] } + +/** One matched line inside a {@link SearchFileMatches} group: its 1-based line number and text. */ +export interface SearchLineMatch { + /** 1-based line number of the match within its file. */ + lineNumber: number + /** The matched line text, as the tool surfaced it (the per-line preview budget already applied). */ + line: string +} + +/** One file's grouped content matches for a {@link SearchMatchesResultView}, in first-seen file order. */ +export interface SearchFileMatches { + /** The file the matches belong to (the model-facing display path). */ + path: string + /** The file's matched lines, in output order. */ + matches: SearchLineMatch[] +} + +/** + * 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}. + */ +export interface SearchMatchesResultView { + card: 'search' + kind: '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. */ + files: SearchFileMatches[] + /** + * Whether the tool capped the inline result: `files` carries only the retained + * matches, not every match the search found. A UI shows a capped indicator so it + * never presents a partial group as complete. + */ + 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}. + */ +export interface SearchPathsResultView { + card: 'search' + kind: '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`). */ + paths: string[] + /** + * Whether the tool capped the inline result: `paths` carries only the retained + * page, not every path the search found. A UI shows a capped indicator so it + * never presents a partial list as complete. + */ + 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 + * ({@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`. + */ +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 6d42acee66..8fd2d20ebf 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -11,13 +11,14 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { GenericCallView } 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 { globSearchMeta, searchViewFromMeta } from './presentation.ts' import { singleQuote } from './shell-quote.ts' import { acceptedSurfaceValue } from './surface.ts' @@ -136,6 +137,24 @@ export function presentGlobCall(args: { pattern: string; path?: string }): Gener return { card: 'generic', title: `Glob ${args.pattern}${where}`, kind: 'search', rawInput: args.pattern } } +/** + * 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. + * + * @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. + * @returns the search card view, or `undefined` for the generic fallback. + */ +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 } +} + /** * Register the `glob` tool and its system-prompt guidance. * @@ -169,6 +188,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { }, }, render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults) }], + presentationMeta: (_args, value) => globSearchMeta(value.paths, caps.maxResults), }, async execute(args, exec) { const input = parseGlobArgs(args) @@ -184,6 +204,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { return { paths: all } }, presentCall: presentGlobCall, + presentResult: presentGlobResult, }) ctx.tools.register(tool) diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index aa82749f3f..4f3273f0fb 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -12,13 +12,14 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { GenericCallView } 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 { grepSearchMeta, searchViewFromMeta } from './presentation.ts' import { singleQuote } from './shell-quote.ts' import { acceptedSurfaceValue } from './surface.ts' @@ -268,6 +269,27 @@ export function presentGrepCall(args: { pattern: string; path?: string; include? return { card: 'generic', title: `Grep ${args.pattern}${where}${filter}`, kind: 'search', rawInput: args.pattern } } +/** + * 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. + * + * @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. + * @returns the search card view, or `undefined` for the generic fallback. + */ +export function presentGrepResult( + _args: { pattern: string; path?: string; include?: string }, + result: ToolResult, +): 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 } +} + /** * Register the `grep` tool and its system-prompt guidance. * @@ -317,6 +339,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { type: 'text', text: renderGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes), }], + presentationMeta: (_args, value) => grepSearchMeta(value.matches, caps.maxMatches, caps.maxLineBytes), }, async execute(args, exec) { const input = parseGrepArgs(args) @@ -335,6 +358,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { return { matches: all } }, presentCall: presentGrepCall, + presentResult: presentGrepResult, }) ctx.tools.register(tool) diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index 5930890b7a..0c53776d1e 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -33,7 +33,7 @@ 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' -export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall } from './glob.ts' +export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, presentGlobResult } from './glob.ts' export type { GlobInput, GlobToolCaps } from './glob.ts' export { GREP_MAX_LINE_BYTES, @@ -45,9 +45,12 @@ export { parseGrepArgs, 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 { singleQuote } from './shell-quote.ts' diff --git a/packages/fs/tool-fs-search/src/presentation.ts b/packages/fs/tool-fs-search/src/presentation.ts new file mode 100644 index 0000000000..479a64d7d1 --- /dev/null +++ b/packages/fs/tool-fs-search/src/presentation.ts @@ -0,0 +1,149 @@ +/** + * 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 + * ({@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 + * tool's `presentResult` reads back on replay. + * + * 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. + * + * @module @deepseek-ai/dsh-tool-fs-search/presentation + */ + +import type { + SearchFileMatches, + 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' + +/** + * The `grep`/`glob` tools' private `tool/result` `meta` payload: the capped, + * structured search result. Attached opaquely (as `JsonValue`) on the tool result + * and persisted with the session log, so `presentResult` reproduces the search + * card on replay. The `matches` shape carries the by-file groups; the `paths` + * shape carries the flat list. Both carry the pre-cap `total` and the `truncated` + * flag. The producing tool owns and narrows this opaque shape. + * + * The member shapes use object-literal `type` aliases rather than the + * {@link SearchFileMatches}/{@link SearchLineMatch} interfaces 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 {@link SearchResultView}. + */ +export type SearchMeta = + | { kind: 'matches'; files: MetaFileMatches[]; truncated: boolean; total: number } + | { kind: '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 } + +/** One file's grouped matches in {@link SearchMeta} (the JSON-assignable form of {@link SearchFileMatches}). */ +type MetaFileMatches = { path: string; matches: MetaLineMatch[] } + +/** + * Group flat matches by file (first-seen order) into the structured by-file shape + * a UI renders as expandable per-file groups. The grouping matches the + * model-facing text grouping + * ({@link module:@deepseek-ai/dsh-tool-fs-search/grep} `formatGrepMatches`), so + * card and text agree about file order and membership. + * + * @param matches - the retained matches to group, in output order. + * @returns one entry per file, in first-seen order. + */ +export function groupMatchesByFile(matches: GrepMatch[]): MetaFileMatches[] { + const byFile = new Map() + for (const match of matches) { + const entry: MetaLineMatch = { lineNumber: match.lineNumber, line: match.line } + const group = byFile.get(match.path) + if (group !== undefined) group.push(entry) + else byFile.set(match.path, [entry]) + } + 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 } +} + +/** + * 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`. + * + * @param paths - every path the search discovered (the canonical value's paths). + * @param maxResults - the inline path cap (the `globMaxResults` config). + * @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 } +} + +/** Whether `value` is a valid {@link SearchLineMatch} (defensive narrowing from opaque `meta`). */ +function isSearchLineMatch(value: unknown): value is SearchLineMatch { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const { lineNumber, line } = value as Record + return typeof lineNumber === 'number' && typeof line === 'string' +} + +/** Whether `value` is a valid {@link SearchFileMatches} (defensive narrowing from opaque `meta`). */ +function isSearchFileMatches(value: unknown): value is SearchFileMatches { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const { path, matches } = value as Record + return typeof path === 'string' && Array.isArray(matches) && matches.every(isSearchLineMatch) +} + +/** + * 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. + * + * @param meta - result metadata (the {@link SearchMeta} the tool projected). + * @returns the search view, or `undefined` for absent or malformed metadata. + */ +export function searchViewFromMeta(meta: unknown): SearchResultView | undefined { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined + const record = meta as Record + const { truncated, total } = record + if (typeof truncated !== 'boolean' || typeof total !== 'number') return undefined + if (record.kind === 'matches') { + const { files } = record + if (!Array.isArray(files) || !files.every(isSearchFileMatches)) return undefined + return { card: 'search', kind: 'matches', files: files, truncated, total } + } + if (record.kind === '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 undefined +} diff --git a/packages/fs/tool-fs-search/tests/presentation.spec.ts b/packages/fs/tool-fs-search/tests/presentation.spec.ts new file mode 100644 index 0000000000..7f3131a2ab --- /dev/null +++ b/packages/fs/tool-fs-search/tests/presentation.spec.ts @@ -0,0 +1,129 @@ +/** + * 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. + */ + +import { describe, expect, it } from 'vitest' +import type { JsonValue } from '@deepseek-ai/dsh-session' +import { + globSearchMeta, + grepSearchMeta, + groupMatchesByFile, + searchViewFromMeta, +} from '../src/presentation.ts' +import type { GrepMatch } from '../src/grep.ts' + +const match = (path: string, lineNumber: number, line: string): GrepMatch => ({ path, lineNumber, line }) + +describe('groupMatchesByFile', () => { + it('groups matches by first-seen file order, keeping line/lineNumber only', () => { + expect(groupMatchesByFile([ + match('b.ts', 2, 'x'), + match('a.ts', 1, 'y'), + match('b.ts', 5, 'z'), + ])).toEqual([ + { path: 'b.ts', matches: [{ lineNumber: 2, line: 'x' }, { lineNumber: 5, line: 'z' }] }, + { path: 'a.ts', matches: [{ lineNumber: 1, line: 'y' }] }, + ]) + }) + + it('returns an empty list for no matches', () => { + expect(groupMatchesByFile([])).toEqual([]) + }) +}) + +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) + expect(meta).toEqual({ + kind: '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) + expect(meta).toEqual({ + kind: '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)' }] }] }) + }) +}) + +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 }) + }) + + 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 }) + }) +}) + +describe('searchViewFromMeta (defensive narrowing)', () => { + // The narrowing accepts an opaque JsonValue; a malformed payload is not a + // statically-valid JsonValue, so route every case through one cast helper that + // mirrors how a hand-edited/older session log delivers arbitrary shapes. + 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 } + 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 } + expect(searchViewFromMeta(m(meta))).toEqual({ card: 'search', ...meta }) + }) + + it('rejects undefined / non-object / array meta', () => { + expect(searchViewFromMeta(undefined)).toBeUndefined() + expect(searchViewFromMeta(null)).toBeUndefined() + expect(searchViewFromMeta(m('nope'))).toBeUndefined() + expect(searchViewFromMeta(m([]))).toBeUndefined() + }) + + 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() + }) + + it('rejects an unknown or missing kind discriminant', () => { + expect(searchViewFromMeta(m({ kind: '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 } + expect(searchViewFromMeta(m({ ...base, files: 'x' }))).toBeUndefined() + expect(searchViewFromMeta(m({ ...base, files: [null] }))).toBeUndefined() + expect(searchViewFromMeta(m({ ...base, files: ['x'] }))).toBeUndefined() + expect(searchViewFromMeta(m({ ...base, files: [[]] }))).toBeUndefined() + expect(searchViewFromMeta(m({ ...base, files: [{ path: 1, matches: [] }] }))).toBeUndefined() + expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: 'x' }] }))).toBeUndefined() + expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: [null] }] }))).toBeUndefined() + expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: [{ lineNumber: '1', line: 'x' }] }] }))).toBeUndefined() + expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: [{ lineNumber: 1, line: 2 }] }] }))).toBeUndefined() + }) + + it('rejects a paths payload with a non-array or non-string-element paths field', () => { + const base = { kind: '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 9ec1c374e1..6d55395e3a 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -27,7 +27,9 @@ import { formatGrepMatches, parseGrepMatches, presentGlobCall, + presentGlobResult, presentGrepCall, + presentGrepResult, previewLine, toWorkdirRelative, } from '@deepseek-ai/dsh-tool-fs-search' @@ -802,6 +804,74 @@ describe('presentation', () => { expect(presentGrepCall({ pattern: 'todo' })).toMatchObject({ card: 'generic', title: 'Grep todo', kind: 'search' }) expect(presentGrepCall({ pattern: 'todo', path: 'src', include: '*.ts' }).title).toBe('Grep todo in src (*.ts)') }) + + it('grep projects a search card from a real execute, grouped by file with total and truncation', async () => { + const { ctx, bash } = await setup({ config: { grepMaxMatches: 2 } }) + bash.handler = () => runResult([ + matchLine('a.ts', 1, 'one'), + matchLine('a.ts', 2, 'two'), + matchLine('b.ts', 3, 'three'), + '', + ].join('\n')) + const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') }) + 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', + files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }], + truncated: true, + total: 3, + }) + const view = presentGrepResult({ pattern: 'e' }, result) + expect(view).toEqual({ + card: 'search', + kind: 'matches', + files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }], + truncated: true, + total: 3, + content: result.content, + }) + }) + + it('glob projects a search card from a real execute, a flat path list with total and truncation', async () => { + const { ctx, bash } = await setup({ config: { globMaxResults: 2 } }) + 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 }) + 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 }) + }) + + it('nested Code dispatch computes no meta, so presentResult falls back to the generic card', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n`) + const result = await call(ctx, 'grep', { pattern: 'o' }, { + agent: agent('/w'), + parent: Symbol('run_code') as ToolExecutionToken, + }) + if (result.isError) throw new Error('expected grep success') + expect(result.meta).toBeUndefined() + expect(presentGrepResult({ pattern: 'o' }, result)).toBeUndefined() + }) + + it('presentResult returns undefined for a failed result and for the other tool’s meta shape', () => { + const errorResult = { content: [{ type: 'text' as const, text: 'boom' }], isError: true } + 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 } } + 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 } } + expect(presentGrepResult({ pattern: 'x' }, malformed)).toBeUndefined() + expect(presentGlobResult({ pattern: '*' }, { content: [], isError: false, meta: 42 })).toBeUndefined() + }) }) describe('helpers', () => {