From 3e22adab2878ec5f78f3253877cd8e1a346a4250 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 17:03:54 +0800 Subject: [PATCH 01/10] 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', () => { From 41ce92776ea58af06ed40b967f8ae63982512871 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 17:52:59 +0800 Subject: [PATCH 02/10] docs: regenerate config and event catalogs for the search card tag The re-exports for SearchResultView shift line numbers in packages/core/tools; regenerate the generated docs the static gate checks (cordis catalog was already regenerated with the feature commit). --- docs/config-catalog.md | 4 ++-- docs/event-producer-consumer.md | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 191d96b255..4d47d6ec01 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1650,7 +1650,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-fs-search/src/index.ts:62`](../packages/fs/tool-fs-search/src/index.ts) +Source: [`packages/fs/tool-fs-search/src/index.ts:65`](../packages/fs/tool-fs-search/src/index.ts) ## `@deepseek-ai/dsh-tool-goal` @@ -1890,7 +1890,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:578`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:583`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b9538b89d5..bb6b1197b7 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -44,12 +44,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:156`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:102`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:143`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:118`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:130`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:107`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:151`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | From c2751d41266c18f6b5c35283416f01102f5ada55 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 18:53:09 +0800 Subject: [PATCH 03/10] test(snapshot): re-record cordis-inspect golden for the search card tag The widened ToolResultView (adding SearchResultView and its member types) shows in the tools API type surface that cordis_inspect reports, so the cordis-inspect-jsdoc golden shifts. No other scenario renders a search result body, so no other snapshot changes. Refreshed keyless. --- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index ea8dad9a96..2abca25ea9 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n 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 }\n 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 }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} From 47ee9764e903888190783f12f6640e9ca1084a0f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 18:59:16 +0800 Subject: [PATCH 04/10] test(snapshot): re-apply search card type surface after master merge --- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index c47cb8c89f..6b50bb2ac0 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n 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 }\n 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 }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} From 7b6f33f87258c6d2d68a79df1302511519b2e49e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 21:57:49 +0800 Subject: [PATCH 05/10] 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 From 168ec609ed7b790e5b46c09005046799171b62c0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 21:58:25 +0800 Subject: [PATCH 06/10] docs: regenerate config catalog after master merge --- docs/config-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9cd60493b8..36aea0db08 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1690,7 +1690,7 @@ 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. */ + /** 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 From bfde98d223eea6f2623630e31bb9b081f4057ceb Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 22:32:06 +0800 Subject: [PATCH 07/10] test: cover the TUI search-card fallback and refresh the cordis-inspect golden - Add a search result-view scenario to the TUI tool-card test so the card:'search' fallback branch (no view content -> raw result content) is covered; restores transcript.ts branch coverage to 100%. - Refresh the cordis-inspect-jsdoc golden for the kind -> shape rename in the ToolResultView JSDoc that cordis_inspect echoes. --- .../cordis-inspect-jsdoc/session.jsonl | 2 +- packages/ui/tui/tests/tui.spec.ts | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 6b50bb2ac0..030612bb80 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n 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 }\n 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 }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 445487065f..2915f600b9 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4368,6 +4368,20 @@ describe('tool cards and surface replay', () => { presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }), presentResult: () => ({ card: 'terminal', output: 'converted terminal' }), }, + // A search card carries no result text of its own; the TUI has no dedicated + // search arm and falls back to the raw result content, rendered as the same + // dim generic body a pre-search-card grep/glob result showed. + search: { + name: 'search', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + presentCall: () => ({ card: 'generic', title: 'Grep todo', kind: 'search' }), + presentResult: () => ({ + card: 'search', + shape: 'matches', + files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'todo one' }] }], + truncated: false, + total: 1, + }), + }, symbolic: { name: 'symbolic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }), @@ -4396,6 +4410,7 @@ describe('tool cards and surface replay', () => { ['c11', 'terminalResult', '{}'], ['c12', 'symbolic', '{}'], ['c13', 'knownXml', '{}'], + ['c16', 'search', '{"pattern":"todo"}'], ] as const appendAssistant(result.session, [ { type: 'text', text: 'Calling tools' }, @@ -4489,6 +4504,14 @@ describe('tool cards and surface replay', () => { isError: false, }), }, { surfaceOp: 'append' }) + result.session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: 'c16' as never, + content: [{ type: 'text', text: 'Found 1 match\n\na.ts\nLine 1: todo one' }], + isError: false, + }), + }, { surfaceOp: 'append' }) result.session.append('tool/result', { turn: 1, step: 1, @@ -4520,6 +4543,11 @@ describe('tool cards and surface replay', () => { expect(output).toContain('$ blank desc command') // A card whose title only repeats the name renders header-only (empty body). expect(output).toContain('Tool / emptyBody') + // A search result view carries no `content` of its own, so the card renders + // the raw model-facing result text through the same dim generic body — the + // TUI has no dedicated search arm. + expect(output).toContain('Tool / search') + expect(output).toContain('Line 1: todo one') // A diff card drops its title (the paths + change footer carry the meaning). // The first file's path is head-visible; the second file and the change // footer sit past this card's 4-line budget and appear only when expanded. From 899d25dfb3c94979c2f54e5167db37390eab4e3f Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 10:22:58 +0800 Subject: [PATCH 08/10] fix(tui): defer model-context resolution on the adapter-registration race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loader activation is service-driven, so the TUI can mount before a configured adapter plugin registers its provider routes; every fresh session then printed 'Could not resolve model context: no adapter registered for provider …' for a working configuration. The model controller now treats a NO_ADAPTER rejection of the context-window resolution as transient: it parks the resolution silently and re-resolves on the next llm/adapters-updated commit. A commit that still lacks the route parks the wait again; any target change clears it; all other resolution errors still surface. A wrong provider name keeps failing loudly at dispatch, where it is actionable. --- ...30-tui-adapter-registration-race.i18n.yaml | 6 ++ ...026-07-30-tui-adapter-registration-race.md | 29 ++++++++ ...-07-30-tui-adapter-registration-race.zh.md | 29 ++++++++ docs/event-producer-consumer.md | 2 +- packages/ui/tui/src/chat/model-command.ts | 21 +++++- packages/ui/tui/tests/tui.spec.ts | 66 +++++++++++++++++++ 6 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml new file mode 100644 index 0000000000..93fd2279d1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.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/bug-fix/2026-07-30-tui-adapter-registration-race.md +2026-07-30-tui-adapter-registration-race.md: e77a338d6b23a48dd140bd9160f22746c43f9fae +2026-07-30-tui-adapter-registration-race.zh.md: ce60b88ca7c62e09d7ad1a166932d86e4b240e03 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md new file mode 100644 index 0000000000..e77a338d6b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md @@ -0,0 +1,29 @@ +# Agent Note: TUI model-context resolution defers on the adapter-registration race + +Status: implemented + +English | [中文](2026-07-30-tui-adapter-registration-race.zh.md) + +## Problem + +Cordis activates plugins by service availability, not configuration order, so the TUI (whose `inject` requires only the `llm` service) can mount before a configured adapter plugin such as `dsh-llm-pi-ai` finishes registering its provider routes. The TUI's model controller resolves the selected model's context window immediately on mount; when the agent's route pointed at a not-yet-registered provider, `resolveModelInfo` rejected with `NO_ADAPTER` and every fresh session printed `Could not resolve model context: no adapter registered for provider "…"` — a spurious error for a fully working configuration (the adapter registered milliseconds later, and chatting worked). + +## Decision + +The TUI model controller treats a `NO_ADAPTER` rejection of its context-window resolution as a transient state rather than an error: it parks the resolution silently and re-resolves on the next `llm/adapters-updated` commit — the payload-free registry notification `LlmService` already fires at every route commit point. A commit that still lacks the route parks the wait again, so unrelated topology changes stay silent. Any target change re-enters the resolution and clears the pending wait, so the deferred state can never go stale against the current selection; every other resolution error still prints the notice. + +## Alternatives considered + +**Have the TUI wait for boot to settle before resolving.** The TUI has no Loader dependency (tests and embedders run without one) and "settled" is not observable from inside a plugin; adding a Loader coupling for one cosmetic resolution inverts the dependency direction. + +**Poll or retry with a timer.** A timer guesses at activation latency, still mis-prints on a slow adapter, and adds a tunable with no owner. The registry already announces every commit through `llm/adapters-updated`; subscribing is precise and free. + +**Order the config so adapters load first.** Row order carries no load semantics in the Loader (activation is service-driven by design), so this cannot be expressed in configuration. + +**Suppress NO_ADAPTER errors entirely.** A permanently missing adapter (typo in the provider name) would then never surface in the context-window path. Deferring keeps the signal: a wrong provider name still shows `model unset`-like behavior in the selector and fails loudly at dispatch, while the startup race resolves itself. + +**Resolve the context window per submitted message instead of at mount.** The send path already resolves per step (`prepareCall()`), and the indicator is displayed continuously, not only when sending; per-submit display resolution would leave the indicator blank until the first message and re-run adapter I/O for a value that only changes on route changes. + +## Consequences + +A genuinely misconfigured provider no longer prints the context-resolution error at startup — it surfaces at first dispatch instead, which is where the failure is actionable. The controller subscribes to every `llm/adapters-updated` commit but acts only while a wait is parked. Covered by two TUI tests: the deferred resolution stays silent through an unrelated commit and completes when the route's commit arrives, and a target change drops the stale wait. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md new file mode 100644 index 0000000000..ce60b88ca7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md @@ -0,0 +1,29 @@ +# Agent Note: TUI 模型上下文解析在适配器注册竞争时延后重试 + +Status: implemented + +[English](2026-07-30-tui-adapter-registration-race.md) | 中文 + +## Problem + +Cordis 按服务可用性而非配置顺序激活插件,因此 TUI(其 `inject` 只要求 `llm` 服务)可能在 `dsh-llm-pi-ai` 这类已配置的适配器插件完成提供方路由注册之前就挂载。TUI 的模型控制器在挂载时立即解析所选模型的上下文窗口;当 agent 的路由指向尚未注册的提供方时,`resolveModelInfo` 以 `NO_ADAPTER` 拒绝,于是每个新会话都会打印 `Could not resolve model context: no adapter registered for provider "…"` —— 对一份完全正常的配置报出的虚假错误(适配器几毫秒后就完成注册,对话也一切正常)。 + +## Decision + +TUI 模型控制器把上下文窗口解析中的 `NO_ADAPTER` 拒绝视为瞬态状态而非错误:静默搁置这次解析,并在下一次 `llm/adapters-updated` 提交时重新解析——这是 `LlmService` 本就在每个路由提交点发出的无载荷注册表通知。若某次提交仍缺少该路由,等待会被再次搁置,因此无关的拓扑变化保持沉默。任何目标变更都会重新进入解析并清除挂起的等待,因此延后状态绝不会相对当前选择变陈旧;其他所有解析错误仍照常打印通知。 + +## Alternatives considered + +**让 TUI 等启动结算后再解析。** TUI 不依赖 Loader(测试和嵌入方在没有 Loader 的环境下运行),而且"已结算"在插件内部不可观测;为一次外观性的解析引入 Loader 耦合会颠倒依赖方向。 + +**用定时器轮询或重试。** 定时器只能猜测激活延迟,遇到慢适配器仍会误报,还会引入一个没有归属者的可调参数。注册表本就通过 `llm/adapters-updated` 公告每次提交;订阅它既精确又零成本。 + +**调整配置顺序让适配器先加载。** Loader 中行顺序不承载加载语义(激活按设计由服务驱动),因此这无法用配置表达。 + +**彻底压制 NO_ADAPTER 错误。** 那样的话,永久缺失的适配器(提供方名字拼错)在上下文窗口路径上就永远不会暴露。延后重试保留了信号:错误的提供方名字仍会在选择器中表现出类似 `model unset` 的行为,并在分派时大声失败,而启动竞争则自行化解。 + +**改为在每次提交消息时解析上下文窗口,而不是在挂载时。** 发送路径本就按步解析(`prepareCall()`),且指示器是持续显示的,不只在发送时;按提交解析显示值会让指示器在首条消息之前一直空白,并为一个仅在路由变化时才变的值反复执行适配器 I/O。 + +## Consequences + +真正配置错误的提供方不再在启动时打印上下文解析错误——它改在首次分派时暴露,那才是该失败可以被处理的地方。控制器订阅每次 `llm/adapters-updated` 提交,但只在有等待被搁置时才动作。由两个 TUI 测试覆盖:延后的解析在无关提交中保持沉默、在该路由的提交到来时完成;目标变更丢弃陈旧等待。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e3d2b8dca6..5d32ec6ca2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -32,7 +32,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:70`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | +| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:70`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm), [`tui`](../packages/ui/tui) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:59`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | diff --git a/packages/ui/tui/src/chat/model-command.ts b/packages/ui/tui/src/chat/model-command.ts index 133a3d0d9f..794f293496 100644 --- a/packages/ui/tui/src/chat/model-command.ts +++ b/packages/ui/tui/src/chat/model-command.ts @@ -8,7 +8,7 @@ */ import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent' -import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { errorChain, LlmError, type ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { TuiOverlaySession } from '../extension/types.ts' import { displayText } from '../components/text.ts' import { @@ -55,8 +55,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle let modelOverlay: TuiOverlaySession | undefined let modelCommands = Promise.resolve() + // A route whose adapter has not registered yet. Loader activation order is + // service-driven, so the TUI can mount before a configured adapter plugin + // activates; that transient NO_ADAPTER is not an error — the resolution + // waits for the next `llm/adapters-updated` commit instead of surfacing it. + let awaitingAdapter = false + const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => { contextWindow = undefined + awaitingAdapter = false const resolution: Promise = selected === undefined ? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const) : ctx.llm.resolveModelInfo(selected.provider, selected.model).then( @@ -67,6 +74,10 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle void resolution.then((result) => { if (contextResolution !== resolution) return if (result.kind === 'error') { + if (selected !== undefined && result.error instanceof LlmError && result.error.code === 'NO_ADAPTER') { + awaitingAdapter = true + return + } deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error') return } @@ -74,6 +85,14 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle deps.requestRender() }) } + // The wait cannot go stale against `target.current`: every target change + // re-enters resolveContextWindow, which clears it. A commit that still + // lacks the route parks the resolution again rather than erroring, so + // unrelated topology changes stay silent. + ctx.on('llm/adapters-updated', () => { + if (deps.isDisposed() || !awaitingAdapter) return + resolveContextWindow(target.current) + }) resolveContextWindow(target.current) const selectModel = ( diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 79aa6f4f97..9e93cd49b9 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -10,6 +10,7 @@ import AgentRegistry, { } from '@deepseek-ai/dsh-agent' import { createUserMessage, createToolResultMessage, + LlmError, ReasoningEffortId, type LlmCallConfig, type LlmModelReasoningInfo, @@ -3630,6 +3631,71 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(reasoningFailed) }) + it('defers a NO_ADAPTER context resolution until the provider registers instead of surfacing an error', async () => { + // Loader activation order is service-driven: the TUI can mount before a + // configured adapter plugin activates, so the initial resolveModelInfo + // fails with NO_ADAPTER. That transient state must not print an error; + // the resolution retries on llm/adapters-updated. + const adapters = new Set() + const result = await setup({ + agentOptions: { provider: 'openai-codex', model: 'gpt-x' }, + contextTokens: 50_000, + catalog: { + providers: [], + models: [], + resolveModelInfo: () => adapters.has('openai-codex') + ? Promise.resolve({ context: { contextWindow: 100_000 } }) + : Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')), + }, + }) + await tick() + expect(result.terminal.output).not.toContain('Could not resolve model context') + + // A topology commit that still lacks the route parks the wait again. + result.ctx.emit('llm/adapters-updated') + await tick() + expect(result.terminal.output).not.toContain('% context') + expect(result.terminal.output).not.toContain('Could not resolve model context') + + adapters.add('openai-codex') + result.ctx.emit('llm/adapters-updated') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('% context') + }) + expect(result.terminal.output).not.toContain('Could not resolve model context') + + // A commit after satisfaction is a no-op for the resolved value. + result.ctx.emit('llm/adapters-updated') + await tick() + expect(result.terminal.output).not.toContain('Could not resolve model context') + await dispose(result) + }) + + it('drops a deferred NO_ADAPTER resolution when the target moved before the adapter registered', async () => { + const result = await setup({ + agentOptions: { provider: 'openai-codex', model: 'gpt-x' }, + catalog: { + providers: [{ id: 'alpha', name: 'Alpha' }], + models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }], + resolveModelInfo: provider => provider === 'alpha' + ? Promise.resolve({ context: { contextWindow: 64_000 } }) + : Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')), + }, + }) + await tick() + // Switching the model re-resolves and clears the deferred wait, so the + // stale route's adapter arriving afterwards must be a no-op. + result.terminal.send('/model alpha/a1') + result.terminal.send('\r') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('Model selected: alpha/a1') + }) + result.ctx.emit('llm/adapters-updated') + await tick() + expect(result.terminal.output).not.toContain('Could not resolve model context') + await dispose(result) + }) + it('does not render a model catalog that resolves after TUI disposal', async () => { const deferred = Promise.withResolvers() const result = await setup({ From 17bb02190b9f056ed78d9733a7db6cfc01684986 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 11:50:10 +0800 Subject: [PATCH 09/10] fix(fs): regenerate doc graphs and refresh glob-sampling golden after merge The master merge left docs/event-producer-consumer.md stale (regenerated) and the master-introduced fs-glob-sampling scenario now carries the search-card `meta` this branch projects from glob (refreshed golden). --- docs/event-producer-consumer.md | 12 ++++++------ .../tests/snapshots/fs-glob-sampling/session.jsonl | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e3d2b8dca6..1d319a6699 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -48,12 +48,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:160`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:142`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:117`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:129`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:150`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:165`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:147`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:122`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:134`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:111`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:155`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl index 8579543459..5c741500c1 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1785218400010,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1785218400011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"d8c174b5-2f08-49b3-80d5-a69aabefbd7a"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1785218400012,"data":{"turn":1,"step":1,"callId":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}} -{"type":"tool/result","seq":12,"time":1785218400013,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"glob-sampling-call","content":[{"type":"text","text":"archive/a.ts\nold\\one\nold\\two\nsrc/index.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"e9711775-0ea5-4383-a562-76a6b49a4742"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1785218400013,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"glob-sampling-call","content":[{"type":"text","text":"archive/a.ts\nold\\one\nold\\two\nsrc/index.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"e9711775-0ea5-4383-a562-76a6b49a4742"},"meta":{"shape":"paths","paths":["archive/a.ts","old\\one","old\\two","src/index.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1785218400014,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1785218400015,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1785218400016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} From 732c9e6d954151b38f2a2c74b7c3de3a9c18ecd1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 15:11:24 +0800 Subject: [PATCH 10/10] fix(tui): release the adapter-registration listener on channel detach Review follow-up: the llm/adapters-updated listener's disposer was discarded, leaving it firing (harmlessly, behind isDisposed()) between TUI shutdown and fiber disposal, asymmetric with the sibling channel listeners. The controller now exposes detach(), and the channel's detachListeners() calls it on both the dispose() and startup-failure paths. --- ...30-tui-adapter-registration-race.i18n.yaml | 4 +-- ...026-07-30-tui-adapter-registration-race.md | 2 +- ...-07-30-tui-adapter-registration-race.zh.md | 2 +- packages/ui/tui/src/chat/model-command.ts | 10 ++++++-- packages/ui/tui/src/index.ts | 1 + packages/ui/tui/tests/tui.spec.ts | 25 +++++++++++++++++++ 6 files changed, 38 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml index 93fd2279d1..b3d987b985 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.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/bug-fix/2026-07-30-tui-adapter-registration-race.md -2026-07-30-tui-adapter-registration-race.md: e77a338d6b23a48dd140bd9160f22746c43f9fae -2026-07-30-tui-adapter-registration-race.zh.md: ce60b88ca7c62e09d7ad1a166932d86e4b240e03 +2026-07-30-tui-adapter-registration-race.md: fd08e7b6130bc8f7e3cd5287a9970f5fb47244a8 +2026-07-30-tui-adapter-registration-race.zh.md: 0c6bba4bbc8c3303d9c471c3164faa816438b333 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md index e77a338d6b..fd08e7b613 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md @@ -26,4 +26,4 @@ The TUI model controller treats a `NO_ADAPTER` rejection of its context-window r ## Consequences -A genuinely misconfigured provider no longer prints the context-resolution error at startup — it surfaces at first dispatch instead, which is where the failure is actionable. The controller subscribes to every `llm/adapters-updated` commit but acts only while a wait is parked. Covered by two TUI tests: the deferred resolution stays silent through an unrelated commit and completes when the route's commit arrives, and a target change drops the stale wait. +A genuinely misconfigured provider no longer prints the context-resolution error at startup — it surfaces at first dispatch instead, which is where the failure is actionable. The controller subscribes to every `llm/adapters-updated` commit but acts only while a wait is parked; the listener's disposer is released by the channel's `detachListeners()` through the controller's `detach()`, symmetric with the sibling channel listeners. Covered by three TUI tests: the deferred resolution stays silent through an unrelated commit and completes when the route's commit arrives, a target change drops the stale wait, and after channel detach a registry commit no longer re-enters resolution. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md index ce60b88ca7..0c6bba4bbc 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md @@ -26,4 +26,4 @@ TUI 模型控制器把上下文窗口解析中的 `NO_ADAPTER` 拒绝视为瞬 ## Consequences -真正配置错误的提供方不再在启动时打印上下文解析错误——它改在首次分派时暴露,那才是该失败可以被处理的地方。控制器订阅每次 `llm/adapters-updated` 提交,但只在有等待被搁置时才动作。由两个 TUI 测试覆盖:延后的解析在无关提交中保持沉默、在该路由的提交到来时完成;目标变更丢弃陈旧等待。 +真正配置错误的提供方不再在启动时打印上下文解析错误——它改在首次分派时暴露,那才是该失败可以被处理的地方。控制器订阅每次 `llm/adapters-updated` 提交,但只在有等待被搁置时才动作;监听器的 disposer 经由控制器的 `detach()` 在频道的 `detachListeners()` 中释放,与同级频道监听器保持对称。由三个 TUI 测试覆盖:延后的解析在无关提交中保持沉默、在该路由的提交到来时完成;目标变更丢弃陈旧等待;频道 detach 之后注册表提交不再重新进入解析。 diff --git a/packages/ui/tui/src/chat/model-command.ts b/packages/ui/tui/src/chat/model-command.ts index 794f293496..c86b3b7e3d 100644 --- a/packages/ui/tui/src/chat/model-command.ts +++ b/packages/ui/tui/src/chat/model-command.ts @@ -37,6 +37,8 @@ export interface ModelController { resetContextResolution(): void /** Forget the tracked selector overlay (shutdown). */ clearOverlay(): void + /** Remove the adapter-registration listener (channel detach). */ + detach(): void } type ContextResolution = @@ -88,8 +90,9 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle // The wait cannot go stale against `target.current`: every target change // re-enters resolveContextWindow, which clears it. A commit that still // lacks the route parks the resolution again rather than erroring, so - // unrelated topology changes stay silent. - ctx.on('llm/adapters-updated', () => { + // unrelated topology changes stay silent. The disposer rides the channel's + // detachListeners() through detach(), matching the sibling listeners. + const disposeAdapterListener = ctx.on('llm/adapters-updated', () => { if (deps.isDisposed() || !awaitingAdapter) return resolveContextWindow(target.current) }) @@ -206,5 +209,8 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle clearOverlay(): void { modelOverlay = undefined }, + detach(): void { + disposeAdapterListener() + }, } } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index a1250bb5b3..e2eaf89986 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1563,6 +1563,7 @@ export function createTuiChat( disposeAgent() disposeSchemeListener() disposeTargetListeners() + modelController.detach() } // Sweep reveal of the whole banner: the header wipes in left-to-right over diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 9e93cd49b9..962df7bd2d 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3671,6 +3671,31 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) + it('stops listening for adapter registrations after channel detach', async () => { + // The listener disposer rides detachListeners() through the controller's + // detach(): after dispose, a registry commit must not re-enter resolution + // at all (the isDisposed() guard is a fallback, not the removal). + const calls: string[] = [] + const result = await setup({ + agentOptions: { provider: 'openai-codex', model: 'gpt-x' }, + catalog: { + providers: [], + models: [], + resolveModelInfo: (provider) => { + calls.push(provider) + return Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')) + }, + }, + }) + await tick() + const callsAtDetach = calls.length + await result.controller.dispose() + result.ctx.emit('llm/adapters-updated') + await tick() + expect(calls.length).toBe(callsAtDetach) + await result.ctx.fiber.dispose() + }) + it('drops a deferred NO_ADAPTER resolution when the target moved before the adapter registered', async () => { const result = await setup({ agentOptions: { provider: 'openai-codex', model: 'gpt-x' },