From 18700f428d5ca8f7b7b778f24815472006ca9f41 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 18:04:38 +0800 Subject: [PATCH 01/11] feat(fs-search): spawn the packaged ripgrep binary through the subprocess seam glob/grep now run the @vscode/ripgrep binary via ctx.subprocess with a plain argv vector: no system rg install, no shell layer, unconditional registration. The load-time command -v rg probe and the bash-seam coupling are removed; timeouts ride the cooperative exec.signal plus the seam's terminate escalation. The fs-glob-sampling ACP snapshot executes the real packaged binary against an mtime-pinned fixture. Adds the packaged-ripgrep-search Agent Note, updates the roster-note facts and both shipped-composition e2es, and regenerates the doc catalogs and third-party notices (surfacing pre-existing manifest drift plus the new @vscode/ripgrep row; the notices generator also learns pnpm 11's truncated virtual-store names). --- ...26-08-01-packaged-ripgrep-search.i18n.yaml | 6 + .../2026-08-01-packaged-ripgrep-search.md | 34 + .../2026-08-01-packaged-ripgrep-search.zh.md | 34 + ...31-even-out-shipped-tool-rosters.i18n.yaml | 4 +- ...026-07-31-even-out-shipped-tool-rosters.md | 6 +- ...-07-31-even-out-shipped-tool-rosters.zh.md | 4 +- THIRD_PARTY_NOTICES.md | 9 +- apps/cli/tests/shipped-composition.e2e.ts | 12 +- apps/web/tests/shipped-composition.e2e.ts | 12 +- docs/config-catalog.md | 4 +- docs/module-graph.md | 4 +- docs/tool-catalog.md | 4 +- examples/acp-agent/tests/acp.snapshot.ts | 41 +- .../acp-agent/tests/fixtures/fs-search-bin/rg | 10 - .../snapshots/fs-glob-sampling/input.json | 2 +- .../snapshots/fs-glob-sampling/session.jsonl | 12 +- packages/fs/tool-fs-search/README.i18n.yaml | 4 +- packages/fs/tool-fs-search/README.md | 26 +- packages/fs/tool-fs-search/README.zh.md | 54 +- packages/fs/tool-fs-search/package.json | 8 +- packages/fs/tool-fs-search/src/glob.ts | 46 +- packages/fs/tool-fs-search/src/grep.ts | 36 +- packages/fs/tool-fs-search/src/index.ts | 74 +-- packages/fs/tool-fs-search/src/ripgrep.d.ts | 12 + packages/fs/tool-fs-search/src/search-core.ts | 186 +++--- packages/fs/tool-fs-search/src/shell-quote.ts | 13 +- .../tool-fs-search/tests/integration.spec.ts | 71 +- .../fs/tool-fs-search/tests/load-path.spec.ts | 56 +- .../fs/tool-fs-search/tests/tools.spec.ts | 625 ++++++++++-------- pnpm-lock.yaml | 126 +++- scripts/gen-third-party-notices.ts | 31 +- scripts/gen-tool-catalog.ts | 56 +- 32 files changed, 923 insertions(+), 699 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md create mode 100644 .agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md delete mode 100755 examples/acp-agent/tests/fixtures/fs-search-bin/rg create mode 100644 packages/fs/tool-fs-search/src/ripgrep.d.ts diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml new file mode 100644 index 0000000000..fe6bb60e68 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.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/architecture/2026-08-01-packaged-ripgrep-search.md +2026-08-01-packaged-ripgrep-search.md: e43354ff8e4dde0480a6c07816fc112810197234 +2026-08-01-packaged-ripgrep-search.zh.md: 55498d366a2171a178cf9d0d1004b6fe946a7281 diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md new file mode 100644 index 0000000000..e43354ff8e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md @@ -0,0 +1,34 @@ +# Agent Note: Packaged ripgrep spawn for glob/grep + +Status: implemented + +English | [中文](2026-08-01-packaged-ripgrep-search.zh.md) + +> Supersedes [bash-backed grep/glob discovery](../../archived/feature/2026-07-09-bash-backed-grep-glob-discovery.md): the v1 decision's explicitly deferred alternative — directly spawning ripgrep — is now what ships. + +## Problem + +The `glob`/`grep` tools ran through the bash executor seam, which made a system `rg` install a host dependency. On Windows and container images there is no `rg` on `PATH` by default, so the tools silently vanished there; a deployment could only discover that from the load-time probe warning. The bash seam also forced the whole model-visible argument surface through one shell-quoting helper, because a shell sat between the tool and ripgrep — the [bash-backed note](../../archived/feature/2026-07-09-bash-backed-grep-glob-discovery.md) recorded that coupling as the v1 trade-off and named direct spawn as the reasonable follow-up if the shell-string domain ever proved too sensitive. It did: every model value had to survive POSIX single-quoting, the probe had to be scripted in tests, and the executor's own timeout classification duplicated what the cooperative tool-timeout policy already owns. + +## Decision + +`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. There is no shell layer, so the shell-quoting boundary is gone from execution; `singleQuote` stays exported as a compatibility surface with its tests. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. + +Exit semantics stay tool-owned: exit 0 is success with results, exit 1 is a successful empty search, anything else classifies into the existing `SEARCH_*` vocabulary (invalid pattern, launch failure, signal kill, raw-output overflow). Timeout is the cooperative tool-call budget attached to the tool definitions: `@deepseek-ai/dsh-timeout-policy` aborts `exec.signal`, the subprocess seam's terminate escalation provides the hard kill, and the tool reports `SEARCH_ABORTED`. The working directory is the session header cwd when present, else `process.cwd()` — there is no executor config to default through anymore, so the tool owns the fallback. + +The `fs-glob-sampling` ACP snapshot scenario now executes the real packaged binary against a prepared workspace whose fixed mtimes pin the `--sort=modified` order, replacing the PATH-injected `rg` stand-in (POSIX-only, because the displayed paths carry `/` separators the session-log comparison cannot normalize). + +## Alternatives considered + +**Keep the bash seam and probe, but document `rg` as a required host dependency.** Rejected: the host dependency is exactly the failure this change removes, and Windows support for the discovery tools was the point of the exercise; a documented requirement is still a requirement. + +**Make `rgPath` injectable (a config field or env override) so tests and snapshots keep substituting a stand-in binary.** Rejected: it adds a public deployment surface whose only consumer would be test seams, and the real binary is deterministic enough to pin directly through fixture mtimes — the packaged binary is the deployment, so tests should exercise it. + +**Switch to a pure-JS glob/search engine (e.g. `picomatch`/`tinyglobby`).** Rejected: the [dependency-swaps audit](../../rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md) already rejected that on the "no glob engine exists" evidence; ripgrep semantics (`--sort=modified`, VCS pruning, JSON transport, regex dialect) are the tool contract. + +## Consequences + +- The discovery tools work on every platform the packaged binary covers (darwin/linux/win32, x64/arm64) with no host install; the shipped TUI/Web rosters gain `glob`/`grep` as fixed members ([even-out-shipped-tool-rosters](../feature/2026-07-31-even-out-shipped-tool-rosters.md)). +- The shell-string attack surface is gone: hostile patterns are inert argv elements, pinned by the integration suite, which now runs on Windows too (it previously self-skipped without a system `rg`). +- Load-time failure modes changed: a broken subprocess seam now fails the first search call (`SEARCH_FAILED`) instead of failing plugin load through the probe; a missing binary is a launch failure with the packaged path, not a PATH problem. +- The integration suite's fixture dropped a filename Windows cannot represent (`"` in a name), keeping the suite replayable on every platform. diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md new file mode 100644 index 0000000000..55498d366a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md @@ -0,0 +1,34 @@ +# Agent Note: glob/grep 改用打包的 ripgrep 二进制直接 spawn + +Status: implemented + +[English](2026-08-01-packaged-ripgrep-search.md) | 中文 + +> 取代 [bash 承载的 grep/glob 发现工具](../../archived/feature/2026-07-09-bash-backed-grep-glob-discovery.md):v1 决策中明确延期的方案——直接 spawn ripgrep——现在成为实际交付的实现。 + +## 问题 + +`glob`/`grep` 工具经由 bash 执行器 seam 运行,这使系统 `rg` 安装成为宿主依赖。Windows 和容器镜像的 `PATH` 默认没有 `rg`,工具在那里会静默消失;部署方只能从加载期探针警告里发现这一点。bash seam 还迫使整个模型可见参数面经过一个 shell 引号工具,因为工具与 ripgrep 之间隔着一层 shell——[bash 承载决策](../../archived/feature/2026-07-09-bash-backed-grep-glob-discovery.md) 把这种耦合记为 v1 的取舍,并把直接 spawn 列为 shell 字符串域一旦被证明过于敏感时的合理后续。它确实被证明了:每个模型值都要经受 POSIX 单引号转义,探针要在测试里脚本化,执行器自身的超时分类还与协作式工具超时策略已有的职责重复。 + +## 决策 + +`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 作为兼容导出与其测试保留。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 + +退出语义仍由工具拥有:退出码 0 为有结果的成功,1 为成功的空搜索,其余归入既有 `SEARCH_*` 词汇(无效模式、启动失败、信号杀死、原始输出溢出)。超时是挂在工具定义上的协作式工具调用预算:`@deepseek-ai/dsh-timeout-policy` 中止 `exec.signal`,subprocess seam 的终止升级提供硬终止,工具报告 `SEARCH_ABORTED`。工作目录为会话 header cwd(存在时),否则为 `process.cwd()`——不再有执行器配置可供默认化,因此回退由工具自己拥有。 + +`fs-glob-sampling` ACP 快照场景改为执行真实的打包二进制,作用于一个用固定 mtime 钉住 `--sort=modified` 顺序的预制工作区,取代 PATH 注入的 `rg` 替身(仅 POSIX:展示路径携带 `/` 分隔符,会话日志比较无法归一化)。 + +## 备选方案 + +**保留 bash seam 与探针,仅把 `rg` 记为必需宿主依赖。** 否决:宿主依赖正是本次改动要消除的失败模式,而让发现工具支持 Windows 正是此举的目的;写进文档的依赖仍是依赖。 + +**让 `rgPath` 可注入(配置字段或环境变量覆盖),让测试与快照继续替换替身二进制。** 否决:这会新增一个只有测试 seam 会消费的公开部署面,而真实二进制本身足够确定——通过 fixture mtime 即可直接钉住;打包二进制就是部署形态,测试应当拿它来测。 + +**改用纯 JS 的 glob/搜索引擎(如 `picomatch`/`tinyglobby`)。** 否决:[依赖替换审计](../../rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md) 已基于"不存在 glob 引擎"的证据否决过该方向;ripgrep 语义(`--sort=modified`、VCS 剪枝、JSON 传输、正则方言)就是工具契约。 + +## 后果 + +- 发现工具在打包二进制覆盖的每个平台(darwin/linux/win32,x64/arm64)上开箱即用,无需宿主安装;交付的 TUI/Web 工具清单把 `glob`/`grep` 变为固定成员(见 [拉平交付的工具清单](../feature/2026-07-31-even-out-shipped-tool-rosters.md))。 +- shell 字符串攻击面消失:恶意模式只是惰性 argv 元素,由集成套件钉住;该套件现在也在 Windows 上运行(此前没有系统 `rg` 时它自行跳过)。 +- 加载期失败模式改变:subprocess seam 损坏现在让首次搜索调用失败(`SEARCH_FAILED`),而非通过探针使插件加载失败;二进制缺失是带打包路径的启动失败,而不是 PATH 问题。 +- 集成套件的 fixture 去掉了 Windows 无法表示的文件名(名称含 `"`),保证套件在每个平台都能重放。 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml index 83e965b391..60be2bc25f 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.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-31-even-out-shipped-tool-rosters.md -2026-07-31-even-out-shipped-tool-rosters.md: 316e5045e559e2da162c53d64989ccecfd18b857 -2026-07-31-even-out-shipped-tool-rosters.zh.md: ed39212dc4877f4df1dc1c6e84142b61a866c548 +2026-07-31-even-out-shipped-tool-rosters.md: d5f1d714ab538b740c25bdf148df285567c616ca +2026-07-31-even-out-shipped-tool-rosters.zh.md: b09fb43ab66f3784f1a3a3f2a6ee74e185fbdad8 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md index 316e5045e5..d5f1d714ab 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -12,7 +12,7 @@ The result was a user-visible difference nobody had decided: the same model, ask ## Decision -The rows that are not surface-specific move into [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces now assemble the same roster: twenty-five tools on every host, plus `glob` and `grep` when ripgrep is available. +The rows that are not surface-specific move into [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces now assemble the same roster: twenty-seven tools on every host — the twenty-five shared rows plus `glob` and `grep`, which are fixed members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../architecture/2026-08-01-packaged-ripgrep-search.md). Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search. @@ -46,7 +46,7 @@ The same smoke pins the TUI's unchanged execution posture from the same artifact [`apps/web/tests/shipped-composition.e2e.ts`](../../../../apps/web/tests/shipped-composition.e2e.ts) covers the Web surface in the built lane, asserting its catalog, that its access default is untouched, and that `workspace-write`'s writable roots include the temp directories — a trap that makes sandbox tests lie when the workspace sits under `/tmp` ([`roots.ts`](../../../../packages/sandbox/sandbox/src/roots.ts)). -`glob` and `grep` are asserted as an all-or-nothing pair rather than fixed members: `dsh-tool-fs-search` probes `command -v rg` at load and registers neither tool without ripgrep, which is a host dependency. +`glob` and `grep` are asserted as fixed members rather than a host-dependent pair: `dsh-tool-fs-search` spawns the packaged ripgrep binary and registers both tools unconditionally, so the pair is always present. Beyond the committed tests, both surfaces were driven against a real key from the built `apps/cli/lib/bin.js` under plain Node. Every mounted tool executed successfully, including `ralph` and `web_search`; the model never reached `cordis_*` or `mcp_*`, fell back to `grep` when asked for LSP navigation, and used a background `bash` task when asked for a persistent terminal. @@ -62,7 +62,7 @@ Beyond the committed tests, both surfaces were driven against a real key from th ## Consequences -The same model gets the same tools on both surfaces, and the difference that existed for no recorded reason is gone. The tests assert the twenty-five unconditional names exactly and require the ripgrep-dependent pair to be either present together or absent together on both sides, so a later change that alters only one surface fails a check instead of shipping quietly. +The same model gets the same tools on both surfaces, and the difference that existed for no recorded reason is gone. The tests assert all twenty-seven names exactly on both sides, so a later change that alters only one surface fails a check instead of shipping quietly. `apps/cli` gains five workspace dependencies: four the shipped tree now mounts, plus `dsh-mcp-client`, which it does not mount and which exists so an installed `dsh` can. diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md index ed39212dc4..b09fb43ab6 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 现在组装同一份清单:每台宿主上都有二十五个工具,ripgrep 可用时再加上 `glob` 和 `grep`。 +那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 现在组装同一份清单:每台宿主上都有二十七个工具——二十五个共享行加上 `glob` 和 `grep`,它们成为固定成员是因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../architecture/2026-08-01-packaged-ripgrep-search.md)。 有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。 @@ -62,7 +62,7 @@ Status: implemented ## 后果 -同一个模型在两个 surface 上拿到同样的工具,那处没有记录理由的差异消失了。测试会精确断言二十五个无条件提供的名称,并要求依赖 ripgrep 的一对工具在两侧要么同时存在、要么同时缺席,因此日后只改一个 surface 都会让检查失败而不是悄悄发出去。 +同一个模型在两个 surface 上拿到同样的工具,那处没有记录理由的差异消失了。测试会精确断言两侧全部二十七个名称,因此日后只改一个 surface 都会让检查失败而不是悄悄发出去。 `apps/cli` 增加五个 workspace 依赖:四个是交付树现在挂载的,外加 `dsh-mcp-client`——它并不被挂载,存在的意义是让已安装的 `dsh` 能挂。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 754ae93d82..1b14996c3f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -47,6 +47,9 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | +| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | +| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | +| [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | | [`clsx`](https://github.com/lukeed/clsx) | MIT | @@ -54,6 +57,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | | [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | +| [`execa`](https://github.com/sindresorhus/execa) | MIT | | [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | @@ -79,6 +83,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`turndown`](https://github.com/mixmark-io/turndown) | MIT | | [`typescript`](https://github.com/microsoft/TypeScript) | Apache-2.0 | | [`use-sync-external-store`](https://github.com/facebook/react) | MIT | +| [`vitest`](https://github.com/vitest-dev/vitest) | MIT | | [`yaml`](https://github.com/eemeli/yaml) | ISC | | [`zod`](https://github.com/colinhacks/zod) | MIT | | [`zustand`](https://github.com/pmndrs/zustand) | MIT | @@ -98,8 +103,6 @@ External packages **directly declared** only by repository tooling, test infrast | [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT | -| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | -| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | @@ -122,7 +125,6 @@ External packages **directly declared** only by repository tooling, test infrast | [`esbuild`](https://github.com/evanw/esbuild) | MIT | | [`eslint`](https://github.com/eslint/eslint) | MIT | | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | -| [`execa`](https://github.com/sindresorhus/execa) | MIT | | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | | [`jscpd`](https://github.com/kucherenko/jscpd) | MIT | | [`jsdom`](https://github.com/jsdom/jsdom) | MIT | @@ -142,7 +144,6 @@ External packages **directly declared** only by repository tooling, test infrast | [`vite-tsconfig-paths`](https://github.com/aleclarson/vite-tsconfig-paths) | MIT | | [`vitepress`](https://github.com/vuejs/vitepress) | MIT | | [`vitepress-plugin-mermaid`](https://github.com/emersonbottero/vitepress-plugin-mermaid) | MIT | -| [`vitest`](https://github.com/vitest-dev/vitest) | MIT | `eslint-plugin-sonarjs` (LGPL-3.0-only) and `lightningcss` (MPL-2.0) run only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact. diff --git a/apps/cli/tests/shipped-composition.e2e.ts b/apps/cli/tests/shipped-composition.e2e.ts index ac1e9d5456..82bf698c17 100644 --- a/apps/cli/tests/shipped-composition.e2e.ts +++ b/apps/cli/tests/shipped-composition.e2e.ts @@ -54,10 +54,10 @@ const EXPECTED_TUI_TOOLS = [ ] /** - * `glob` and `grep` come from `dsh-tool-fs-search`, which probes `command -v rg` - * through the mounted bash executor at load and registers neither tool when - * ripgrep is absent. That is a host dependency, not a composition decision, so the - * pair is asserted separately — present together or absent together. + * `glob` and `grep` come from `dsh-tool-fs-search`, which spawns the PACKAGED + * ripgrep binary (`@vscode/ripgrep`) through the subprocess seam, so the pair + * is always present on every host — asserted as fixed members, not a host + * dependency. */ const RIPGREP_TOOLS = ['glob', 'grep'] @@ -117,7 +117,9 @@ describe('shipped dsh composition (real Loader tree in a PTY)', () => { }) expect(output).toContain(COMPOSITION_REPLY_TEXT) expect(observed?.names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TUI_TOOLS) - expect([[], RIPGREP_TOOLS]).toContainEqual(observed?.names.filter(name => RIPGREP_TOOLS.includes(name))) + // The packaged ripgrep binary ships with the dependency, so the pair is a + // fixed roster member on every host. + expect(observed?.names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS) // The TUI mounts the unrestricted local executors, so `tool-bash` emits no // escalation pair. Pinning its absence keeps a later sandbox change from // arriving here unannounced. diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 0cad833303..80adcdd1de 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -49,10 +49,10 @@ const EXPECTED_TOOLS = [ ] /** - * `glob` and `grep` come from `dsh-tool-fs-search`, which probes `command -v rg` - * through the mounted bash executor at load and registers neither tool when - * ripgrep is absent. That is a host dependency, not a composition decision, so the - * pair is asserted separately — present together or absent together. + * `glob` and `grep` come from `dsh-tool-fs-search`, which spawns the PACKAGED + * ripgrep binary (`@vscode/ripgrep`) through the subprocess seam, so the pair + * is always present on every host — asserted as fixed members, not a host + * dependency. */ const RIPGREP_TOOLS = ['glob', 'grep'] @@ -67,7 +67,9 @@ it('assembles the shipped Web catalog and keeps its access default', async () => scaffold = await launchWebScaffold() const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort() expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS) - expect([[], RIPGREP_TOOLS]).toContainEqual(names.filter(name => RIPGREP_TOOLS.includes(name))) + // The packaged ripgrep binary ships with the dependency, so the pair is a + // fixed roster member on every host. + expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS) // `workspace-write` is not "the workspace and nothing else": the shared roots // helper always admits the temp directories too. Pinning it against an // explicit mode keeps the claim independent of this surface's default, and diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a356a151e3..20f835bb7f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1716,7 +1716,7 @@ Source: [`packages/fs/tool-fs/src/index.ts:24`](../packages/fs/tool-fs/src/index ## `@deepseek-ai/dsh-tool-fs-search` -Requires: `tools` · `systemPrompt` · `bash` +Requires: `tools` · `systemPrompt` · `subprocess` ```ts config-catalog /** Plugin config; over-cap glob sampling is an explicit deployment choice and the remaining fields have defaults. */ @@ -1738,7 +1738,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-fs-search/src/index.ts:71`](../packages/fs/tool-fs-search/src/index.ts) +Source: [`packages/fs/tool-fs-search/src/index.ts:70`](../packages/fs/tool-fs-search/src/index.ts) ## `@deepseek-ai/dsh-tool-goal` diff --git a/docs/module-graph.md b/docs/module-graph.md index f0f58a18c1..87789c9edb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -713,12 +713,12 @@ flowchart TD pkg_tool_fs --> pkg_system_prompt pkg_tool_fs --> pkg_tools pkg_tool_fs --> pkg_user_approval - pkg_tool_fs_search --> pkg_bash pkg_tool_fs_search --> pkg_invariants pkg_tool_fs_search --> pkg_llm pkg_tool_fs_search --> pkg_retention pkg_tool_fs_search --> pkg_session pkg_tool_fs_search --> pkg_spill + pkg_tool_fs_search --> pkg_subprocess pkg_tool_fs_search --> pkg_system_prompt pkg_tool_fs_search --> pkg_tools pkg_tool_str_replace_editor --> pkg_fs @@ -1176,7 +1176,7 @@ flowchart TD | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 7d6fa79dea..d19a7adf52 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -23,7 +23,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | -| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | +| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | @@ -524,7 +524,7 @@ Search file contents with a ripgrep regular expression. Returns matching lines w Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-search/src/index.ts) -glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. +glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. ## `@deepseek-ai/dsh-tool-pty` diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index b652f09931..277d0b20ca 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url' import { readFileSync } from 'node:fs' -import { mkdir, writeFile } from 'node:fs/promises' +import { mkdir, utimes, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { expect, it } from 'vitest' @@ -44,7 +44,6 @@ const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml' const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) -const FS_SEARCH_BIN = fileURLToPath(new URL('./fixtures/fs-search-bin', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -57,6 +56,33 @@ async function prepareDelimiterPathWorkspace(cwd: string): Promise { ]) } +/** + * Seed the over-cap glob fixture: eight files under `tree/` with fixed mtimes, + * so the packaged ripgrep's `--sort=modified` order is deterministic — three + * files under `archive/`, one each under `docs/`, `src/`, and `test/`, plus + * two flat files (six top-level entries). Scoping the search to `tree/` keeps + * the harness's own session artifacts out of the listing. + */ +async function prepareFsSearchWorkspace(cwd: string): Promise { + const tree = join(cwd, 'tree') + const files: Array<[relative: string, mtime: Date]> = [ + [join('archive', 'a.ts'), new Date(2000, 0, 1, 0, 0, 0, 1)], + [join('archive', 'b.ts'), new Date(2000, 0, 1, 0, 0, 0, 2)], + [join('archive', 'c.ts'), new Date(2000, 0, 1, 0, 0, 0, 3)], + [join('docs', 'guide.md'), new Date(2000, 0, 1, 0, 0, 0, 4)], + [join('src', 'index.ts'), new Date(2000, 0, 1, 0, 0, 0, 5)], + [join('test', 'spec.ts'), new Date(2000, 0, 1, 0, 0, 0, 6)], + ['top.txt', new Date(2000, 0, 1, 0, 0, 0, 7)], + ['notes.md', new Date(2000, 0, 1, 0, 0, 0, 8)], + ] + for (const [relative, mtime] of files) { + const target = join(tree, relative) + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, 'fixture\n') + await utimes(target, mtime, mtime) + } +} + // FIXME: Migrate backend-oriented scenarios to the headless stream-json suite; // this ACP suite should eventually retain only automation-protocol contracts. @@ -150,9 +176,12 @@ const SCENARIOS: Scenario[] = [ hasModelTurn: true, recorded: true, }, - // The real Loader/app/bash path executes a deterministic rg stand-in at the - // external-process seam, pinning over-cap glob sampling without depending on - // a host-installed ripgrep binary. + // The real Loader/app/subprocess path executes the PACKAGED ripgrep binary + // against a prepared workspace whose fixed mtimes pin the + // `--sort=modified` order, pinning over-cap glob sampling without depending + // on a host-installed ripgrep binary or a PATH stand-in. POSIX-only because + // the displayed paths carry `/` separators the session-log comparison + // cannot normalize. { name: 'fs-glob-sampling', hasModelTurn: true, @@ -160,7 +189,7 @@ const SCENARIOS: Scenario[] = [ pinsHeader: true, headerClass: 'fs-search', configPath: FS_SEARCH_CONFIG, - env: { PATH: `${FS_SEARCH_BIN}:${process.env.PATH ?? ''}` }, + prepareWorkspace: prepareFsSearchWorkspace, posixOnly: true, }, { name: 'fs-read', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/fixtures/fs-search-bin/rg b/examples/acp-agent/tests/fixtures/fs-search-bin/rg deleted file mode 100755 index 181ad68837..0000000000 --- a/examples/acp-agent/tests/fixtures/fs-search-bin/rg +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -printf '%s\n' \ - 'archive/a.ts' \ - 'archive/b.ts' \ - 'archive/c.ts' \ - 'old\one' \ - 'old\two' \ - 'src/index.ts' \ - 'docs/guide.md' \ - 'test/spec.ts' diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/input.json b/examples/acp-agent/tests/snapshots/fs-glob-sampling/input.json index cc5fc95e59..d615bd4840 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/input.json +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Call glob exactly once with pattern * and no path. Then reply with exactly GLOB_SAMPLED and nothing else." } + { "op": "prompt", "text": "Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else." } ] } 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 b66ffc36b4..ca51259632 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl @@ -1,18 +1,18 @@ {"type":"session","version":0,"id":"f5a99d52-3eaa-4ce7-858d-61d4fd77df2a","createdAt":1785218400000,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785218400001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785218400002,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and no path. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"6790985f-1de2-42f8-a7f1-24e46d6439c7"},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785218400002,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"6790985f-1de2-42f8-a7f1-24e46d6439c7"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785218400003,"data":{"title":"Call glob exactly once with","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785218400004,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785218400005,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785483397569,"data":{"provider":"deepseek","model":"deepseek-v4-pro"}} {"type":"assistant/chunk","seq":6,"time":1785218400007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":7,"time":1785218400008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"glob-sampling-call","name":"glob","argumentsDelta":"{\"pattern\":\"*\"}"}}} -{"type":"assistant/chunk","seq":8,"time":1785218400009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":1785218400008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"glob-sampling-call","name":"glob","argumentsDelta":"{\"pattern\":\"*\",\"path\":\"tree\"}"}}} +{"type":"assistant/chunk","seq":8,"time":1785218400009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}}}} {"type":"assistant/chunk","seq":9,"time":1785218400010,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} {"type":"assistant/chunk","seq":10,"time":1785483397579,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":11,"time":1785483397579,"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":"a127cfe5-39fb-462c-8e5a-a8c79bd0e52b"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} -{"type":"tool/call","seq":12,"time":1785483397579,"data":{"turn":1,"step":1,"callId":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}} -{"type":"tool/result","seq":13,"time":1785483398062,"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":"2beecb2e-627d-43dc-a936-03e1dc874093"},"meta":{"shape":"paths","paths":["archive/a.ts","old\\one","old\\two","src/index.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[12],"surfaceOp":"append"} +{"type":"assistant/message","seq":11,"time":1785483397579,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"a127cfe5-39fb-462c-8e5a-a8c79bd0e52b"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} +{"type":"tool/call","seq":12,"time":1785483397579,"data":{"turn":1,"step":1,"callId":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}} +{"type":"tool/result","seq":13,"time":1785483398062,"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":"tree/archive/a.ts\ntree/docs/guide.md\ntree/src/index.ts\ntree/test/spec.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":"2beecb2e-627d-43dc-a936-03e1dc874093"},"meta":{"shape":"paths","paths":["tree/archive/a.ts","tree/docs/guide.md","tree/src/index.ts","tree/test/spec.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[12],"surfaceOp":"append"} {"type":"step/end","seq":14,"time":1785483398062,"data":{"turn":1,"step":1}} {"type":"step/start","seq":15,"time":1785483398072,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":16,"time":1785218400017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index bedb8289c1..5ed7bfb8bb 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/tool-fs-search/README.md -README.md: b12ffda9869c7d6bef5ea5b54594781ecf555ff4 -README.zh.md: 7dd6cdf9a209f2fe357b4ffe48d20d574266ce60 +README.md: 0152be017ae15fc83a3d5cb7df927f25d04d2d53 +README.zh.md: 69ce49f3ad1621021dc1d0938cdc07a900d1cda8 diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index b12ffda986..0152be017a 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -2,21 +2,21 @@ English | [中文](README.zh.md) -The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `bash`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. +The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **packaged ripgrep binary** (`@vscode/ripgrep`), not by `ctx.fs` provider methods and not by a system `rg` install. Registration is unconditional: the binary ships inside the npm dependency, so there is no load-time availability probe. Each call spawns the binary through the `ctx.subprocess` seam with a fixed argv vector (model-controlled values are plain argv elements — no shell layer exists, so no quoting applies), parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `subprocess`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. ```ts ignore-check // A deployment chooses how over-cap glob pages are selected. -await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local +await ctx.plugin(LocalSubprocessService) // @deepseek-ai/dsh-subprocess-local await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false }) // Optional: a spill backend makes capped results fully recoverable. await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local ``` -Why bash-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution (local, sandboxed, remote); this package owns schemas, argument validation, shell quoting, parsing, retention, formatted-result spill, and timeout declaration. The tools never call `ctx.bash.start()` and never expose a bash task id — the call returns only after `rg` exits, times out, is aborted, or fails. +Why spawn-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The subprocess seam owns spawn execution, process-tree termination, environment scrubbing, and bounded output capture; this package owns schemas, argument validation, argv construction, parsing, retention, formatted-result spill, and timeout declaration. The tools never expose a background task — the call returns only after `rg` exits, is terminated by the cooperative timeout, is aborted, or fails. -## Deployment requirement: rg + co-located bash/filesystem +## Deployment requirement: no host rg, co-located workdir/filesystem -The mounted bash executor must be able to resolve `rg` from its `PATH` at plugin load; otherwise `glob` and `grep` are absent from the model-visible tool schema. Returned paths are displayed relative to the resolved bash workdir (the calling agent's session cwd when present, else the executor's configured default) and are follow-up-readable with `read` only when the bash workdir and the filesystem root are the same workspace. v1 documents that co-location requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend. +The binary ships with the package on every supported platform (macOS/Linux/Windows, x64/arm64), so no host `rg` install is required and the tools register on every deployment. Returned paths are displayed relative to the resolved workdir (the calling agent's session cwd when present, else `process.cwd()`) and are follow-up-readable with `read` only when that workdir and the filesystem root are the same workspace. v1 documents that co-location requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend. ## Config @@ -29,24 +29,24 @@ The mounted bash executor must be able to resolve `rg` from its `PATH` at plugin | `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. | | `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. | | `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. | -| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the bash backend's own timeout stays a second safety cap. | +| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the subprocess seam's terminate escalation is the hard kill. | ## Tools | Tool | Arguments | Behavior | |---|---|---| -| `glob` | `pattern`, `path?` | `rg --files --glob --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one FILE path per line; `rg --files` never emits directory entries. The pattern keeps ripgrep semantics: without a `/` it matches the basename at any depth, so `*` matches the whole tree. Complete results stay modification-time ordered; over-cap presentation follows `sampleOverCapGlobResults`. | +| `glob` | `pattern`, `path?` | `rg --files --glob --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved workdir. Returns one FILE path per line; `rg --files` never emits directory entries. The pattern keeps ripgrep semantics: without a `/` it matches the basename at any depth, so `*` matches the whole tree. Complete results stay modification-time ordered; over-cap presentation follows `sampleOverCapGlobResults`. | | `grep` | `pattern`, `path?`, `include?` | Line-oriented `rg --json` parse (no colon-splitting ambiguity). `pattern` is a ripgrep regex; `path` is an optional **file or directory** target; `include` is ONE positive glob filter — a comma-separated list or a negated (`!…`) value is rejected up front (brace alternation like `*.{ts,tsx}` is fine). Returns matches grouped by file as `Line N: `. | Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results follows the returned spill locator's retrieval hint. ## Two budgets, two artifacts -Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`. +Raw `rg` stdout is an internal transport detail. Each search requests a collect-mode stdout budget of `rawOutputMaxBytes` from the subprocess seam and parses only complete retained stdout; if the seam still reports a lossy read, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`. ## Errors -Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (runtime `rg` disappearance after registration, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors. +Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (a failed `rg` launch, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still lossy after the requested stdout capture budget), and `SEARCH_ABORTED` (cooperative tool timeout or caller cancellation). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors. ## Model Experience @@ -54,7 +54,7 @@ Search failures carry the package-owned `SearchError` (a `HarnessError` subclass #### What the model sees -After the load-time `rg` probe succeeds, every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section. +Every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section. ##### Glob guidance with `sampleOverCapGlobResults: true` @@ -86,7 +86,7 @@ Prefix-stable while the plugin scope, sampling choice, and guidance text are unc #### What the model sees -The glob description states the configured over-cap ordering. The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) use `sampleOverCapGlobResults: true`; schemas are visible only after the load-time `rg` probe succeeds. +The glob description states the configured over-cap ordering. The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) use `sampleOverCapGlobResults: true`; the tools are registered unconditionally. #### Token effect @@ -126,7 +126,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation. -- **Ripgrep is a deployment dependency** — a missing `rg` executable makes the package register no tools or guidance; an incompatible executable or one that disappears after registration fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located executor or another search consumer. +- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation. +- **The packaged binary is fixed at dependency version** — `@vscode/ripgrep` covers the platforms it ships (macOS/Linux/Windows, x64/arm64); an unsupported platform or a corrupted install fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located workspace or another search consumer. - **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend. - **Sampling, when enabled, groups by first path segment beneath the search root only** — an over-cap `glob` page balances across those top-level entries, so a result concentrated deeper (one busy directory inside an otherwise even tree) is still shown unevenly below that level; recursive balancing is deferred. diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 7dd6cdf9a2..69ce49f3ad 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -2,21 +2,21 @@ [English](README.md) | 中文 -**面向模型的文件系统发现工具**(`glob`、`grep`)由 **bash 执行器 seam** 支持,而不是由 `ctx.fs` 提供方方法支持。加载时,本包(package)探测 `command -v rg`,探测通过 `ctx.bash` 进行;如果执行器无法在其 `PATH` 上找到 ripgrep,就记录警告,并且不注册工具或提示词段。每次调用都会组装固定的 ripgrep 命令(所有模型控制的值都经过同一个包私有 shell 引用辅助函数),通过 `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` 作为普通前台工具调用运行,解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `bash`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 +**面向模型的文件系统发现工具**(`glob`、`grep`)由 **打包的 ripgrep 二进制**(`@vscode/ripgrep`)支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。注册是无条件的:二进制随 npm 依赖一起交付,因此没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 该二进制(模型控制的值是普通 argv 元素——不存在 shell 层,因此无需引号),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 ```ts ignore-check // A deployment chooses how over-cap glob pages are selected. -await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local +await ctx.plugin(LocalSubprocessService) // @deepseek-ai/dsh-subprocess-local await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false }) // Optional: a spill backend makes capped results fully recoverable. await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local ``` -采用 bash 支持的原因:本地工作区发现天然是由进程支持的 `rg` 工作流;如果把搜索放到 `ctx.fs` 上,就会迫使每个文件系统后端扩展搜索 API。bash 执行器负责请求默认值/上限、子进程执行、进程组终止、环境清理、原始输出捕获和后端替换(本地、沙箱化、远程);本包负责 schema、参数校验、shell 引用、解析、保留、格式化结果 spill 和超时声明。工具绝不调用 `ctx.bash.start()`,也不公开 bash task id;只有在 `rg` 退出、超时、中止或失败后,调用才会返回。 +采用 spawn 支持的原因:本地工作区发现天然是由进程支持的 `rg` 工作流;如果把搜索放到 `ctx.fs` 上,就会迫使每个文件系统后端扩展搜索 API。subprocess seam 负责 spawn 执行、进程树终止、环境清理和有界输出捕获;本包负责 schema、参数校验、argv 构造、解析、保留、格式化结果 spill 和超时声明。工具绝不暴露后台任务——只有在 `rg` 退出、被协作式超时终止、被中止或失败后,调用才会返回。 -## 部署要求:rg 与共置的 bash/文件系统 +## 部署要求:无需宿主 rg,但工作目录与文件系统需共置 -已挂载的 bash 执行器必须能在插件加载时解析 `rg`,其来源是执行器的 `PATH`;否则面向模型的工具 schema 中不会出现 `glob` 和 `grep`。返回路径会相对于解析后的 bash 工作目录显示(调用方 agent(智能体)有会话 cwd 时使用该 cwd,否则使用执行器配置的默认值);只有 bash 工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。v1 只记录这项共置要求,不执行运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区契约或特定提供方的搜索后端。 +二进制随包交付,覆盖所有受支持平台(macOS/Linux/Windows,x64/arm64),因此无需宿主 `rg` 安装,工具在每个部署上都注册。返回路径会相对于解析后的工作目录显示(调用方 agent(智能体)有会话 cwd 时使用该 cwd,否则使用 `process.cwd()`);只有该工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。v1 只记录这项共置要求,不执行运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区契约或特定提供方的搜索后端。 ## 配置 @@ -29,24 +29,24 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- | `grepMaxMatches` | `250` | 一次 `grep` 调用内联保留的最大平铺匹配数(与 Claude Code 的 `GrepTool` `head_limit` 相同);后续匹配写入格式化 spill 产物。 | | `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 | | `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 | -| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;bash 后端自身的超时仍作为第二道安全上限。 | +| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;subprocess seam 的终止升级提供硬终止。 | ## 工具 | 工具 | 参数 | 行为 | |---|---|---| -| `glob` | `pattern`、`path?` | 运行 `rg --files --glob --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录**搜索根;省略时使用解析后的 bash 工作目录。每行返回一个**文件**路径;`rg --files` 从不输出目录条目。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。完整结果保持按修改时间排序;超过上限时的呈现方式遵循 `sampleOverCapGlobResults`。 | +| `glob` | `pattern`、`path?` | 运行 `rg --files --glob --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录**搜索根;省略时使用解析后的工作目录。每行返回一个**文件**路径;`rg --files` 从不输出目录条目。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。完整结果保持按修改时间排序;超过上限时的呈现方式遵循 `sampleOverCapGlobResults`。 | | `grep` | `pattern`、`path?`、`include?` | 按行解析 `rg --json`,避免按冒号拆分的歧义。`pattern` 是 ripgrep 正则表达式;`path` 是可选的**文件或目录**目标;`include` 是一个正向 glob 过滤器,前置拒绝逗号分隔列表或否定值(`!…`),但允许 `*.{ts,tsx}` 等花括号交替。返回按文件分组、形如 `Line N: ` 的匹配。 | 常规预算不进入面向模型的 schema(没有 `head_limit`/`offset`/`case_insensitive`/输出模式):模型需要周边上下文时,用 `read` 读取匹配文件;需要后续结果时,遵循返回的 spill locator 检索提示。 ## 两类预算、两类产物 -原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout;如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。 +原始 `rg` stdout 是内部传输细节。每次搜索从 subprocess seam 请求 `rawOutputMaxBytes` 的 collect 模式 stdout 预算,且只解析完整保留的 stdout;如果 seam 仍报告 lossy 读取,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。 ## 错误 -搜索失败携带本包拥有的 `SearchError`(`HarnessError` 子类),以 `{ name, code }` 公开在 `isError` 结果上:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则/glob)、`SEARCH_FAILED`(注册后 `rg` 在运行时消失、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍被截断)和 `SEARCH_ABORTED`(工具超时、调用方取消或 bash 执行器自身超时)。ripgrep 退出语义由工具拥有:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。 +搜索失败携带本包拥有的 `SearchError`(`HarnessError` 子类),以 `{ name, code }` 公开在 `isError` 结果上:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则/glob)、`SEARCH_FAILED`(`rg` 启动失败、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍 lossy)和 `SEARCH_ABORTED`(协作式工具超时或调用方取消)。ripgrep 退出语义由工具拥有:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。 ## 模型体验 @@ -54,7 +54,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- #### 模型看到的内容 -加载时 `rg` 探测成功后,该插件注册作用域内的每个请求都包含下方独立注册的 glob 与 grep 指导。agent 作用域的工具限制可以隐藏任一 schema,而不移除其提示词段。 +该插件注册作用域内的每个请求都包含下方独立注册的 glob 与 grep 指导。agent 作用域的工具限制可以隐藏任一 schema,而不移除其提示词段。 ##### 启用 `sampleOverCapGlobResults: true` 时的 Glob 指导 @@ -76,57 +76,57 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read #### Token 影响 -工具注册期间,每个请求支付固定指导成本;必填的采样选项决定采用哪个 glob 变体。 +工具注册期间每个请求有固定的指导成本;必填的采样选择决定采用哪一个 glob 变体。 #### KV Cache 影响 -只要插件作用域、采样选项和指导文本不变,前缀就保持稳定。启用、dispose(资源释放)或更改该选项,可能从该提示词段开始使复用失效。 +插件作用域、采样选择与指导文本不变时前缀稳定。激活、销毁或改变选择可能使该提示词段的复用失效。 ### 工具 schema #### 模型看到的内容 -glob 描述会说明配置所指定的超限结果排序方式。已生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) 使用 `sampleOverCapGlobResults: true`;只有加载时 `rg` 探测成功后,这些 schema 才可见。 +glob 描述声明了配置的超过上限排序方式。生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) 使用 `sampleOverCapGlobResults: true`;工具无条件注册。 #### Token 影响 -工具可见的每个请求都支付固定 schema 成本。 +工具可见时每个请求有固定的 schema 成本。 #### KV Cache 影响 -只要工具可见性和定义不变,前缀就保持稳定。注册生命周期或作用域限制可能从首个变化的 schema token 开始使复用失效。 +工具可见性与定义不变时前缀稳定。注册生命周期或作用域限制可能从第一个改变的 schema token 起使复用失效。 -### 结果与 spill 通知 +### 结果与 spill 提示 #### 模型看到的内容 -`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line : ` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。`sampleOverCapGlobResults: true` 时,超过上限的 `glob` 页面会在实际搜索根正下方的条目之间按轮转方式取路径,footer 会说明采样依据和触达的顶层条目数;若无法触达全部条目,footer 会要求模型缩小 `path`。设为 `false` 时,页面保留按修改时间排序的前部,并沿用通常用于达到上限结果的 footer。未超过上限的结果原样不动;扁平的采样结果也沿用普通 footer,因为其样本等同于按修改时间排序的前部。spill 产物始终保存按修改时间排序的完整列表。 +`glob` 每行返回一个路径;`grep` 在每个路径下分组展示 `Line : ` 匹配。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果以省略计数结尾,并附 spill locator 与后端检索提示;否则说明完整结果无法保存。启用 `sampleOverCapGlobResults: true` 时,超过上限的 `glob` 页面按实际搜索根正下方的条目轮转取路径,页脚说明采样依据及其覆盖的顶层条目数;无法覆盖全部条目时,页脚提示模型收窄 `path`。`false` 时页面是按修改时间排序的前部,并保留普通的上限结果页脚。未超过上限的结果原样呈现;扁平采样的结果也保留普通页脚,因为其采样等于按修改时间排序的前部。spill 产物始终持有按修改时间排序的完整列表。 #### Token 影响 -内联路径和匹配受 `globMaxResults`、`grepMaxMatches` 与 `grepMaxLineBytes` 限制;调用和保留结果会留在历史中,直到上下文压缩(compaction)。 +内联路径与匹配受 `globMaxResults`、`grepMaxMatches` 与 `grepMaxLineBytes` 约束;调用与保留结果在压缩前留在历史中。 #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。 ### 工具错误 #### 模型看到的内容 -失败会规范化为 `Error: `,并向调用方提供结构化的 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 或 `SEARCH_ABORTED` 元数据。 +失败被规范化为 `Error: `,并携带结构化 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 或 `SEARCH_ABORTED` 元数据供调用方使用。 #### Token 影响 -只有失败调用会添加这些保留 token。 +只有失败的调用会增加这些保留 token。 #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。 -## 已知限制与暂缓事项 +## 已知局限与延期工作 -- **搜索和文件访问没有共享工作区证明**:只有 bash 工作目录和文件系统根目录表示同一工作区时,返回路径才能继续读取;本包不执行运行时跨服务校验。 -- **Ripgrep 是部署依赖**:缺失 `rg` 可执行文件时,本包不注册工具或指导;可执行文件不兼容或注册后消失时,调用以 `SEARCH_FAILED` 失败。远程或虚拟文件系统需要共置执行器或其他搜索消费方。 -- **schema 只公开一个有界页面**:offset 分页、大小写模式开关、其他输出模式和提供方支持的发现均不在本包内;达到上限的完整输出需要 spill 后端。 -- **启用采样时,只按搜索根下的路径首段分组**:超过上限的 `glob` 页面在这些顶层条目之间做均衡,因此集中在更深层的结果(一棵总体均匀的树里某个特别庞大的子目录)在该层级以下仍然分布不均;递归均衡已延期。 +- **搜索与文件访问没有共享工作区证明**——只有当工作目录与文件系统根目录指向同一工作区时,返回路径才保证可继续读取;本包不执行运行时跨服务校验。 +- **打包二进制固定在依赖版本上**——`@vscode/ripgrep` 覆盖其随附的平台(macOS/Linux/Windows,x64/arm64);不支持的平台或损坏的安装会以 `SEARCH_FAILED` 使调用失败。远程或虚拟文件系统需要共置的工作区或另一个搜索消费方。 +- **schema 只暴露一个有界页面**——偏移分页、大小写开关、替代输出模式与提供方支撑的发现仍不在本包范围内;达到上限的完整输出需要 spill 后端。 +- **启用采样时仅按搜索根正下方的第一段路径分组**——超过上限的 `glob` 页面在这些顶层条目之间平衡,因此集中在更深处的结果(一棵均匀树里某个繁忙目录)在该层级之下仍会呈现不均;递归平衡被延期。 diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index bf9cf15aa0..8953aea77a 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-tool-fs-search", - "description": "Model-facing filesystem discovery tools (glob, grep) backed by the DeepSeek Harness bash seam (ctx.bash)", + "description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)", "version": "0.0.1", "private": true, "type": "module", @@ -27,23 +27,23 @@ ], "license": "BSD-3-Clause", "dependencies": { + "@vscode/ripgrep": "^1.18.0", "schemastery": "^3.18.0" }, "peerDependencies": { - "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-retention": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-spill": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-bash": "workspace:^", - "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 2670ab57c4..6e7d411a01 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -1,10 +1,11 @@ /** * The model-facing `glob` tool: discover files whose paths match a glob - * pattern, sorted by modification time. Execution goes through the bash seam - * (`ctx.bash`) with a fixed `rg --files` command — this module owns the - * model-facing schema, argument validation, shell-safe command construction, - * result parsing, inline sampling, and formatting; process concerns (defaulting, - * scrubbing, kill, backend substitution) stay behind `ctx.bash`. + * pattern, sorted by modification time. Execution spawns the packaged + * ripgrep binary (`@vscode/ripgrep`) directly through the subprocess seam + * with a plain argv vector — this module owns the model-facing schema, + * argument validation, argv construction, result parsing, inline sampling, + * and formatting; process concerns (spawn execution, tree termination, + * environment scrubbing, output capture) stay behind `ctx.subprocess`. * @module @deepseek-ai/dsh-tool-fs-search/glob */ @@ -13,11 +14,9 @@ import { sep } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' 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' /** @@ -73,32 +72,35 @@ export function parseGlobArgs(args: { pattern: string; path?: string }): GlobInp } /** - * Build the fixed `rg --files` command for one `glob` call. Every + * Build the fixed `rg --files` argv for one `glob` call. Every * model-controlled value ({@link GlobInput.pattern}, {@link GlobInput.path}) - * passes through {@link singleQuote}; the search root rides behind `--` so a - * leading-dash path can never be parsed as a flag. `--sort=modified` orders by - * modification time, `--no-ignore --hidden` searches ignored and hidden files, - * and {@link GLOB_VCS_EXCLUDES} keeps VCS metadata out. + * is a plain argv element — no shell layer exists, so no quoting applies; the + * search root rides behind `--` so a leading-dash path can never be parsed as + * a flag. `--sort=modified` orders by modification time, `--no-ignore + * --hidden` searches ignored and hidden files, and + * {@link GLOB_VCS_EXCLUDES} keeps VCS metadata out. * * @param input - the validated arguments. - * @returns the complete, shell-safe command string. + * @returns the complete ripgrep argument vector (excluding the binary itself). */ -export function buildGlobCommand(input: GlobInput): string { +export function buildGlobCommand(input: GlobInput): string[] { const parts = [ - 'rg --files', - `--glob=${singleQuote(input.pattern)}`, - '--sort=modified --no-ignore --hidden', + '--files', + `--glob=${input.pattern}`, + '--sort=modified', + '--no-ignore', + '--hidden', // Two negated globs per VCS name: the bare form prunes the directory // during traversal; the /** form still excludes the contents when the // search root is AT or INSIDE the directory (where the bare form, // matched against root-prefixed paths, never fires). ...GLOB_VCS_EXCLUDES.flatMap(name => [ - `--glob=${singleQuote(`!**/${name}`)}`, - `--glob=${singleQuote(`!**/${name}/**`)}`, + `--glob=!**/${name}`, + `--glob=!**/${name}/**`, ]), ] - if (input.path !== undefined) parts.push('--', singleQuote(input.path)) - return parts.join(' ') + if (input.path !== undefined) parts.push('--', input.path) + return parts } /** @@ -285,7 +287,7 @@ export function presentGlobResult(_args: { pattern: string; path?: string }, res * Register the `glob` tool and its system-prompt guidance. * * @param ctx - the plugin context; registrations are effects scoped to it, and - * execution uses its `bash` service. + * execution uses its `subprocess` service. * @param caps - the deployment's resolved glob caps (plugin config after defaulting). */ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index b7e67ea153..03b49f01e8 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -1,11 +1,12 @@ /** * The model-facing `grep` tool: search file contents with a ripgrep regular - * expression. Execution goes through the bash seam (`ctx.bash`) with a fixed - * line-oriented `rg --json` command so file path, line number, and line text - * parse without colon-splitting ambiguity — this module owns the model-facing - * schema, argument validation, shell-safe command construction, `--json` - * record parsing, per-line preview retention, match retention, grouping, and - * formatting; process concerns stay behind `ctx.bash`. + * expression. Execution spawns the packaged ripgrep binary + * (`@vscode/ripgrep`) directly through the subprocess seam with a plain argv + * vector using a fixed line-oriented `rg --json` command so file path, line + * number, and line text parse without colon-splitting ambiguity — this module + * owns the model-facing schema, argument validation, argv construction, + * `--json` record parsing, per-line preview retention, match retention, + * grouping, and formatting; process concerns stay behind `ctx.subprocess`. * * @module @deepseek-ai/dsh-tool-fs-search/grep */ @@ -15,12 +16,10 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' 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 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' /** @@ -96,20 +95,21 @@ export function parseGrepArgs(args: { pattern: string; path?: string; include?: } /** - * Build the fixed line-oriented `rg --json` command for one `grep` call. Every + * Build the fixed line-oriented `rg --json` argv for one `grep` call. Every * model-controlled value ({@link GrepInput.pattern}, {@link GrepInput.path}, - * {@link GrepInput.include}) passes through {@link singleQuote}; the pattern - * and include ride in `--flag=value` form and the target behind `--`, so a - * leading-dash value can never be parsed as a flag. + * {@link GrepInput.include}) is a plain argv element — no shell layer exists, + * so no quoting applies; the pattern and include ride in `--flag=value` form + * and the target behind `--`, so a leading-dash value can never be parsed as + * a flag. * * @param input - the validated arguments. - * @returns the complete, shell-safe command string. + * @returns the complete ripgrep argument vector (excluding the binary itself). */ -export function buildGrepCommand(input: GrepInput): string { - const parts = ['rg --json', `--regexp=${singleQuote(input.pattern)}`] - if (input.include !== undefined) parts.push(`--glob=${singleQuote(input.include)}`) - if (input.path !== undefined) parts.push('--', singleQuote(input.path)) - return parts.join(' ') +export function buildGrepCommand(input: GrepInput): string[] { + const parts = ['--json', `--regexp=${input.pattern}`] + if (input.include !== undefined) parts.push(`--glob=${input.include}`) + if (input.path !== undefined) parts.push('--', input.path) + return parts } /** diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index 072865d568..e596ae3ca1 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -1,28 +1,27 @@ /** * The model-facing filesystem discovery tool suite (`glob`, `grep`) over the - * bash executor seam (`ctx.bash`). This single plugin registers both tools - * only when the mounted bash executor can find `rg` on its `PATH`. + * packaged ripgrep binary (`@vscode/ripgrep`). This single plugin registers + * both tools; the binary ships inside the npm dependency, so no system `rg` + * install and no shell layer is involved. * - * ## Bash-backed, not a `ctx.fs` provider method + * ## Spawn-backed, not a `ctx.fs` provider method * * Local workspace discovery is a process-backed `rg` workflow, so these tools - * execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` with fixed - * ripgrep command templates — never `ctx.bash.start()`, never a model-visible - * background task. The tool layer owns schemas, argument validation, shell - * quoting ({@link module:@deepseek-ai/dsh-tool-fs-search/shell-quote}), result - * parsing, retention, formatted-result spill, and timeout declaration; the - * bash executor owns request defaulting/capping, subprocess execution, - * process-group termination, environment scrubbing, raw output capture, and - * backend substitution. At load, the package probes `command -v rg` through the - * same bash seam; if ripgrep is absent, `glob` / `grep` and their prompt - * sections are not registered. The package injects `tools`, `systemPrompt`, - * and `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read + * execute through `ctx.subprocess.spawn()` with fixed ripgrep argv templates — + * never `ctx.bash`, never `ctx.bash.start()`, never a model-visible background + * task. The tool layer owns schemas, argument validation, argv construction + * ({@link module:@deepseek-ai/dsh-tool-fs-search/glob} / + * {@link module:@deepseek-ai/dsh-tool-fs-search/grep}), result parsing, + * retention, formatted-result spill, and timeout declaration; the subprocess + * seam owns spawn execution, process-tree termination, environment scrubbing, + * and raw output capture. The package injects `tools`, `systemPrompt`, and + * `subprocess` — deliberately NOT `fs`, and `ctx.spillStore` is read * opportunistically with `ctx.get()` because formatted-result spill is optional. * - * Returned paths are displayed relative to the resolved bash workdir and are - * follow-up-readable only in co-located deployments where the bash workdir and - * the filesystem `read` root are the same workspace — a documented v1 - * deployment requirement, not runtime-validated. + * Returned paths are displayed relative to the resolved workdir and are + * follow-up-readable only in co-located deployments where the workdir and the + * filesystem `read` root are the same workspace — a documented v1 deployment + * requirement, not runtime-validated. * * @module @deepseek-ai/dsh-tool-fs-search */ @@ -65,7 +64,7 @@ export { singleQuote } from './shell-quote.ts' export const name = 'tool-fs-search' /** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */ -export const inject = ['tools', 'systemPrompt', 'bash'] +export const inject = ['tools', 'systemPrompt', 'subprocess'] /** Plugin config; over-cap glob sampling is an explicit deployment choice and the remaining fields have defaults. */ export interface Config { @@ -98,9 +97,6 @@ export const Config: z = z.object({ /** The shape after schemastery applied the defaults. */ type ResolvedConfig = Required -/** POSIX-shell builtin probe for the ripgrep binary in the bash executor environment. */ -const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' - /** Every search cap counts items/bytes/milliseconds — a positive integer, or retention and timeout arithmetic misbehaves silently. */ function assertPositiveInteger(name: string, value: number): void { if (!Number.isInteger(value) || value < 1) { @@ -109,36 +105,14 @@ function assertPositiveInteger(name: string, value: number): void { } /** - * Check whether the mounted bash executor can find `rg`. - * - * Nonzero exit means "not available" and disables this optional tool suite. - * Infrastructure failures stay loud: a deployment with a broken bash executor - * should not silently lose tools in a way that looks like a deliberate skip. - * - * @param ctx - plugin context whose `bash` service is the executor the tools will use. - * @returns true when `command -v rg` exits 0, false when it exits nonzero. - */ -async function ripgrepAvailable(ctx: Context): Promise { - const spec = ctx.bash.resolve({ command: RG_PROBE_COMMAND }) - let result - try { - result = await ctx.bash.run(spec) - } catch (error: unknown) { - throw new Error(`tool-fs-search: ripgrep availability probe could not start: ${String(error)}`, { cause: error }) - } - if (result.aborted || result.timedOut || result.signal !== null || result.exitCode === null) { - throw new Error('tool-fs-search: ripgrep availability probe did not complete') - } - return result.exitCode === 0 -} - -/** - * Register the `glob`/`grep` filesystem discovery tool suite when `rg` exists. + * Register the `glob`/`grep` filesystem discovery tool suite. The packaged + * ripgrep binary is always available (an npm dependency), so registration is + * unconditional. * * @param ctx - plugin context; registrations are effects scoped to this plugin. * @param config - resolved plugin configuration from schemastery. - * @returns when ripgrep is unavailable, resolves without registering any tools. */ +// oxlint-disable-next-line typescript/require-await -- async keeps a load-time config rejection a rejection, not a synchronous throw export async function apply(ctx: Context, config: Config): Promise { // schemastery (Config) has already filled every defaulted field. const resolved = config as ResolvedConfig @@ -148,10 +122,6 @@ export async function apply(ctx: Context, config: Config): Promise { assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes) assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes) assertPositiveInteger('timeoutMs', resolved.timeoutMs) - if (!await ripgrepAvailable(ctx)) { - ctx.logger.warn('tool-fs-search: ripgrep (rg) not found on the bash executor PATH; glob/grep tools not registered') - return - } applyGlobTool(ctx, { sampleOverCapGlobResults: resolved.sampleOverCapGlobResults, maxResults: resolved.globMaxResults, diff --git a/packages/fs/tool-fs-search/src/ripgrep.d.ts b/packages/fs/tool-fs-search/src/ripgrep.d.ts new file mode 100644 index 0000000000..25d268471e --- /dev/null +++ b/packages/fs/tool-fs-search/src/ripgrep.d.ts @@ -0,0 +1,12 @@ +/** + * Minimal type surface for the `@vscode/ripgrep` package: an ESM module that + * resolves the platform ripgrep binary (`@vscode/ripgrep--` + * optional dependency) and exports its absolute path as the named export + * `rgPath` (no bundled type declarations). + * @module @deepseek-ai/dsh-tool-fs-search/ripgrep-types + */ + +declare module '@vscode/ripgrep' { + /** Absolute path to the packaged ripgrep executable for the current platform. */ + export const rgPath: string +} diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 402fc9d655..c9f5f80b5b 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -1,16 +1,19 @@ /** * Shared execution plumbing for the `glob` / `grep` search tools: the - * package-owned `SEARCH_*` error vocabulary, one bash-seam run helper that - * turns a fixed `rg` command into complete raw stdout, the best-effort - * formatted-result spill handoff, and workdir-relative path display. + * package-owned `SEARCH_*` error vocabulary, one spawn helper that runs the + * PACKAGED ripgrep binary (`@vscode/ripgrep`) with a plain argv vector and + * returns complete raw stdout, the best-effort formatted-result spill handoff, + * and workdir-relative path display. * - * Both tools execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` - * as ordinary foreground tool calls — never `ctx.bash.start()`, never a - * model-visible background task. Raw `rg` stdout is an internal transport - * detail: the tools request a per-run stdout capture budget from the bash seam, - * parse only complete in-memory stdout within `rawOutputMaxBytes`, and never - * read executor spill files. The model-facing recovery artifact is the - * formatted result saved through `ctx.spillStore.saveText()` + * Both tools execute as ordinary foreground spawns through `ctx.subprocess` — + * never `ctx.bash`, never `ctx.bash.start()`, never a model-visible background + * task. The ripgrep binary ships inside the npm package, so no system `rg` + * install is required, and no shell layer exists between the argv vector and + * ripgrep, so no shell quoting is involved. Raw `rg` stdout is an internal + * transport detail: the tools request a per-run stdout capture budget from the + * subprocess seam, parse only complete in-memory stdout within + * `rawOutputMaxBytes`, and never read spill files. The model-facing recovery + * artifact is the formatted result saved through `ctx.spillStore.saveText()` * ({@link trySaveFormattedResult}). * * @module @deepseek-ai/dsh-tool-fs-search/search-core @@ -18,10 +21,11 @@ import { isAbsolute, relative, sep } from 'node:path' import type { Context } from 'cordis' +import { rgPath } from '@vscode/ripgrep' 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 { SubprocessCollect, SubprocessOutcome, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import type { ToolExecution } from '@deepseek-ai/dsh-tools' @@ -38,6 +42,18 @@ export const RAW_OUTPUT_MAX_BYTES = 20_000_000 */ export const SEARCH_TIMEOUT_MS = 30_000 +/** + * Default cap in bytes on the retained stderr tail of one search run — a + * diagnostic excerpt only (the tool never reads `stderr.spillPath`). + */ +const SEARCH_STDERR_MAX_BYTES = 64 * 1024 + +/** Default whole-stream spill cap for search output (the subprocess seam requires an explicit budget). */ +const SEARCH_SPILL_MAX_BYTES = 64 * 1024 * 1024 + +/** Default terminate grace period for a search process (ms). */ +const SEARCH_GRACE_MS = 3_000 + /** * Default cap in bytes on one search's serialized `presentationMeta` (the * `searchMetaMaxBytes` config). The inline match/path caps already bound the item @@ -52,14 +68,14 @@ 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` + * `FsErrorCode`) because these tools are spawn-backed discovery, not `ctx.fs` * provider operations: `SEARCH_INVALID_PATTERN` — ripgrep rejected the regex or * glob; `SEARCH_FAILED` — the search could not run or its output could not be - * parsed (missing `rg`, inaccessible target, signal kill, malformed `--json`); - * `SEARCH_RAW_OUTPUT_OVERFLOW` — raw `rg` output exceeded `rawOutputMaxBytes` - * or stayed truncated after that requested stdout budget; `SEARCH_ABORTED` — the tool - * timeout, caller cancellation, or the bash executor's own timeout cut the - * search short. + * parsed (a failed `rg` launch, inaccessible target, signal kill, malformed + * `--json`); `SEARCH_RAW_OUTPUT_OVERFLOW` — raw `rg` output exceeded + * `rawOutputMaxBytes` or stayed truncated after that requested stdout budget; + * `SEARCH_ABORTED` — the cooperative tool timeout or caller cancellation cut + * the search short. */ export type SearchErrorCode = | 'SEARCH_INVALID_PATTERN' @@ -84,7 +100,7 @@ export class SearchError extends HarnessError { /** The completed acquisition of one `rg` run: complete stdout plus the resolved workdir. */ export interface RipgrepRun { - /** Complete raw stdout retained by the bash executor within the requested cap. */ + /** Complete raw stdout retained by the subprocess seam within the requested cap. */ stdout: string /** True when ripgrep exited 1: a successful search with zero results. */ noMatches: boolean @@ -94,73 +110,71 @@ export interface RipgrepRun { /** * The retained stderr tail as a diagnostic excerpt, with a truncation note when - * the executor dropped bytes (the tool never reads `stderr.spillPath`). + * the subprocess seam dropped bytes (the tool never reads `stderr.spillPath`). */ -function stderrExcerpt(stderr: CollectedOutput): string { - const text = stderr.text.trim() +function stderrExcerpt(stderrText: string, truncated: boolean): string { + const text = stderrText.trim() if (text.length === 0) return '' - return stderr.truncated ? `${text} [stderr truncated]` : text + return truncated ? `${text} [stderr truncated]` : text } /** Classify a nonzero-exit `rg` run into the search error vocabulary (invalid pattern vs missing `rg` vs everything else). */ -function classifyRunFailure(toolName: string, result: BashRunResult): SearchError { - const stderr = stderrExcerpt(result.stderr) +function classifyRunFailure(toolName: string, exitCode: number, stderrText: string, stderrTruncated: boolean): SearchError { + const stderr = stderrExcerpt(stderrText, stderrTruncated) if (/regex parse error|error parsing glob/i.test(stderr)) { return new SearchError(`${toolName} pattern rejected by ripgrep: ${stderr}`, 'SEARCH_INVALID_PATTERN') } - if (result.exitCode === 127 || /command not found/i.test(stderr)) { - return new SearchError(`${toolName} requires ripgrep (rg) on the bash executor's PATH${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED') + if (exitCode === 127 || /command not found/i.test(stderr)) { + return new SearchError(`${toolName} requires ripgrep (rg) to launch${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED') } - return new SearchError(`${toolName} search failed (exit ${result.exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED') + return new SearchError(`${toolName} search failed (exit ${exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED') } /** * Acquire the COMPLETE raw stdout of a finished run, enforcing * `rawOutputMaxBytes` on the in-memory transport. A truncated result means the - * bash backend could not retain complete stdout within the requested budget, so - * the tool fails clearly instead of parsing a silently-partial stream. + * subprocess seam could not retain complete stdout within the requested + * budget, so the tool fails clearly instead of parsing a silently-partial + * stream. */ -function completeStdout(toolName: string, result: BashRunResult, rawOutputMaxBytes: number): string { +function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutputMaxBytes: number): string { const narrow = 'narrow pattern, path, or include and retry' - if (!result.stdout.truncated) { - const inlineBytes = Buffer.byteLength(result.stdout.text, 'utf8') + if (!stdout.lossy) { + const inlineBytes = Buffer.byteLength(stdout.text, 'utf8') if (inlineBytes > rawOutputMaxBytes) { throw new SearchError( `${toolName} produced ${inlineBytes} bytes of raw output, over the ${rawOutputMaxBytes}-byte cap; ${narrow}`, 'SEARCH_RAW_OUTPUT_OVERFLOW', ) } - return result.stdout.text + return stdout.text } throw new SearchError( - `${toolName} produced more raw output than the bash executor retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`, + `${toolName} produced more raw output than the subprocess seam retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`, 'SEARCH_RAW_OUTPUT_OVERFLOW', ) } /** - * Run one fixed `rg` command through the bash seam and return its complete raw - * stdout. The bash request workdir is the calling agent's session cwd - * (`exec.agent.session.header.cwd`) when available — mirroring `dsh-tool-bash` / - * `dsh-tool-fs` — else omitted so the implementation's `resolve()` applies its - * configured default. `exec.signal` is forwarded so the cooperative tool - * timeout (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation kill the - * command; the bash backend's own timeout stays a second safety cap. + * Run the packaged ripgrep binary with a plain argv vector and return its + * complete raw stdout. The working directory is the calling agent's session + * cwd (`exec.agent.session.header.cwd`) when available, else + * `process.cwd()`. `exec.signal` is forwarded so the cooperative tool timeout + * (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation terminate the + * process tree. * * Exit semantics are tool-owned: exit 0 is success with results, exit 1 is * success with zero results (`noMatches`), anything else throws a * {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern → * `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` / - * `SEARCH_RAW_OUTPUT_OVERFLOW`). A `run()` REJECTION — the seam's - * infrastructure failures (pre-aborted signal, unusable workdir, missing - * shell) — is translated into the same taxonomy: a pre-aborted signal becomes - * `SEARCH_ABORTED`, everything else `SEARCH_FAILED`, with the original as - * `cause`. + * `SEARCH_RAW_OUTPUT_OVERFLOW`). A spawn REJECTION — the seam's + * infrastructure failures — is translated into `SEARCH_FAILED` with the + * original as `cause`; a pre-aborted signal becomes `SEARCH_ABORTED`. * - * @param ctx - the plugin context; execution uses its `bash` service. + * @param ctx - the plugin context; execution uses its `subprocess` service. * @param exec - the tool-execution context; supplies the session cwd and the abort signal. * @param toolName - `glob` or `grep`, used in error messages. - * @param command - the fully-quoted `rg` command string (every model value already through `singleQuote`). + * @param argv - the ripgrep arguments (every model value an unquoted argv element; no shell layer exists). * @param rawOutputMaxBytes - cap on the complete raw stdout the tool will parse. * @returns the complete stdout, the zero-result flag, and the resolved workdir. */ @@ -168,54 +182,64 @@ export async function runRipgrep( ctx: Context, exec: ToolExecution, toolName: string, - command: string, + argv: readonly string[], rawOutputMaxBytes: number, ): Promise { - const cwd = exec.agent?.session.header.cwd - const spec = ctx.bash.resolve({ - command, - stdoutMaxBytes: rawOutputMaxBytes, - ...cwd !== undefined ? { workdir: cwd } : {}, - signal: exec.signal, - }) - let result: BashRunResult - try { - result = await ctx.bash.run(spec) - } catch (error: unknown) { - // The seam contract: run() REJECTS only for infrastructure failures — a - // pre-aborted signal, an unusable workdir, a missing shell. Translate them - // so these failures stay machine-routable under the SEARCH_* taxonomy. - if (spec.signal?.aborted === true) { - throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED', { cause: error }) - } - throw new SearchError(`${toolName} could not start its search command (unusable working directory or missing shell)`, 'SEARCH_FAILED', { cause: error }) - } - if (result.aborted) { + if (exec.signal.aborted) { throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED') } - if (result.timedOut) { - throw new SearchError(`${toolName} timed out after ${result.timeoutMs}ms in the bash executor; narrow pattern, path, or include and retry`, 'SEARCH_ABORTED') + const cwd = exec.agent?.session.header.cwd + const workdir = cwd ?? process.cwd() + const collect = (maxBytes: number): SubprocessCollect => + ({ maxBytes, spill: { maxBytes: SEARCH_SPILL_MAX_BYTES } }) + const handle = ctx.subprocess.spawn({ + argv: [rgPath, ...argv], + cwd: workdir, + stdio: { + stdin: 'ignore', + stdout: collect(rawOutputMaxBytes), + stderr: collect(SEARCH_STDERR_MAX_BYTES), + }, + graceMs: SEARCH_GRACE_MS, + signal: exec.signal, + } satisfies SubprocessSpawnSpec) + let outcome: SubprocessOutcome + try { + outcome = await handle.done + } catch (error: unknown) { + throw new SearchError(`${toolName} could not start its search command (ripgrep launch failed)`, 'SEARCH_FAILED', { cause: error }) } - if (result.signal !== null || result.exitCode === null) { - throw new SearchError(`${toolName} search command was killed by signal ${result.signal ?? '(unknown)'}`, 'SEARCH_FAILED') + const stdout = handle.collected.stdout?.readFrom(0) + const stderr = handle.collected.stderr?.readFrom(0) + if (stdout === undefined || stderr === undefined) { + throw new SearchError(`${toolName} search command produced no collected output streams`, 'SEARCH_FAILED') } - if (result.exitCode !== 0 && result.exitCode !== 1) { - throw classifyRunFailure(toolName, result) + // The signal can abort while the spawn is awaited; the static narrowing that + // proves this re-check "always false" cannot see AbortSignal state changes. + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (exec.signal.aborted) { + throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED') } - const stdout = completeStdout(toolName, result, rawOutputMaxBytes) - return { stdout, noMatches: result.exitCode === 1, workdir: spec.workdir } + if (outcome.signal !== null || outcome.exitCode === null) { + throw new SearchError(`${toolName} search command was killed by signal ${outcome.signal ?? '(unknown)'}`, 'SEARCH_FAILED') + } + if (outcome.exitCode !== 0 && outcome.exitCode !== 1) { + throw classifyRunFailure(toolName, outcome.exitCode, stderr.text, stderr.lossy) + } + const text = completeStdout(toolName, stdout, rawOutputMaxBytes) + return { stdout: text, noMatches: outcome.exitCode === 1, workdir } } /** * Map an `rg` output path to its display form: absolute paths inside the - * resolved bash workdir become workdir-relative; everything else (relative - * output, paths outside the workdir) passes through unchanged. Display-only — - * returned paths are follow-up-readable in co-located bash/filesystem + * resolved workdir become workdir-relative; everything else (relative output, + * paths outside the workdir) passes through unchanged. Display-only — + * returned paths are follow-up-readable in co-located workdir/filesystem * deployments where both resolve the same workspace (the documented v1 * deployment requirement). * * @param path - one path as ripgrep printed it. - * @param workdir - the resolved bash workdir the command ran in. + * @param workdir - the resolved workdir the command ran in. * @returns the workdir-relative display path when possible, else `path` unchanged. */ export function toWorkdirRelative(path: string, workdir: string): string { diff --git a/packages/fs/tool-fs-search/src/shell-quote.ts b/packages/fs/tool-fs-search/src/shell-quote.ts index 9453b8e255..ea67abf449 100644 --- a/packages/fs/tool-fs-search/src/shell-quote.ts +++ b/packages/fs/tool-fs-search/src/shell-quote.ts @@ -1,12 +1,9 @@ /** - * The one shell-quoting helper both search tools MUST route every - * model-controlled value through before it enters an `rg` command string. The - * bash seam (`ctx.bash`) accepts a command STRING, not an argv vector, so this - * is the safety boundary that stops a `pattern`, `path`, or `include` from - * breaking out of its argument and injecting shell syntax. - * - * Command builders in `glob.ts` / `grep.ts` must never hand-roll quoting or - * concatenate an unquoted model value — they call {@link singleQuote}. + * POSIX single-quoting helper retained for compatibility with older + * deployments and tests. The current `glob`/`grep` command builders spawn the + * packaged ripgrep binary with a plain argv vector — no shell layer exists — + * so no quoting is involved; this module is kept because its export is part + * of the package surface. * * @module @deepseek-ai/dsh-tool-fs-search/shell-quote */ diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index 8cb96e7e66..dc7a88b30f 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -1,15 +1,16 @@ /** - * Integration tests: the REAL local bash executor (`dsh-bash-local`) plus a - * REAL ripgrep binary, exercised through `ctx.tools.execute()`. These verify - * the WORLD — actual files on disk are discovered and grepped, hostile - * patterns stay inert in a real shell, and real `rg` stderr classifies into - * the `SEARCH_*` vocabulary. The whole suite self-skips when `rg` is not on - * PATH (a CI accommodation mirroring the keyless e2e skip); the fake-executor - * suite (tools.spec.ts) carries the coverage gate. + * Integration tests: the REAL local subprocess service plus the PACKAGED + * ripgrep binary (`@vscode/ripgrep`), exercised through `ctx.tools.execute()`. + * These verify the WORLD — actual files on disk are discovered and grepped, + * hostile patterns stay inert (they are plain argv elements; there is no + * shell layer to escape), and real `rg` stderr classifies into the + * `SEARCH_*` vocabulary. The binary ships inside the npm dependency, so the + * suite runs on every platform without a system `rg` install; the + * fake-service suite (tools.spec.ts) carries the coverage gate. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -17,14 +18,11 @@ import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' -import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' const testToolSignal = new AbortController().signal -const hasRg = spawnSync('rg', ['--version'], { encoding: 'utf8' }).status === 0 - let dir: string let ctx: Context @@ -43,7 +41,10 @@ function text(result: { content: { type: string; text?: string }[] }): string { return result.content.filter(b => b.type === 'text').map(b => b.text).join('') } -describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () => { +/** The fixture workspace as a session cwd, so relative paths resolve inside `dir`. */ +const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } }) + +describe('search tools over the real subprocess service + the packaged rg', () => { beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'dsh-search-int-')) await mkdir(join(dir, 'src'), { recursive: true }) @@ -54,7 +55,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () await writeFile(join(dir, 'notes.md'), 'alpha appears here too\n') await writeFile(join(dir, '.hidden.ts'), 'export const hidden = 3\n') await writeFile(join(dir, '.git', 'config.ts'), 'never listed\n') - await writeFile(join(dir, 'spaced dir', "wei'rd \"name\".ts"), 'const inside = true\n') + await writeFile(join(dir, 'spaced dir', "wei'rd name.ts"), 'const inside = true\n') // Deterministic --sort=modified order: alpha oldest, beta newest. await utimes(join(dir, 'src', 'alpha.ts'), new Date(2000, 0, 1), new Date(2000, 0, 1)) await utimes(join(dir, 'src', 'beta.ts'), new Date(2020, 0, 1), new Date(2020, 0, 1)) @@ -63,7 +64,6 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(LocalSubprocessService) - await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 }) await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true }) }) @@ -73,33 +73,33 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () describe('glob', () => { it('discovers files by pattern, sorted by modification time, hidden included, .git excluded', async () => { - const result = await call('glob', { pattern: '**/*.ts' }) + const result = await call('glob', { pattern: '**/*.ts' }, agent()) expect(result.isError).toBe(false) const paths = text(result).split('\n') - expect(paths.indexOf('src/alpha.ts')).toBeLessThan(paths.indexOf('src/beta.ts')) + expect(paths.indexOf(join('src', 'alpha.ts'))).toBeLessThan(paths.indexOf(join('src', 'beta.ts'))) expect(paths).toContain('.hidden.ts') - expect(paths).toContain("spaced dir/wei'rd \"name\".ts") - expect(paths).not.toContain('.git/config.ts') + expect(paths).toContain(join('spaced dir', "wei'rd name.ts")) + expect(paths).not.toContain(join('.git', 'config.ts')) expect(paths).not.toContain('notes.md') }) it('scopes to a directory search root (path arg)', async () => { - const result = await call('glob', { pattern: '*.ts', path: 'src' }) - expect(text(result).split('\n').sort()).toEqual(['src/alpha.ts', 'src/beta.ts']) + const result = await call('glob', { pattern: '*.ts', path: 'src' }, agent()) + expect(text(result).split('\n').sort()).toEqual([join('src', 'alpha.ts'), join('src', 'beta.ts')]) }) it('reports zero discoveries as No files found', async () => { - expect(text(await call('glob', { pattern: '*.nomatch' }))).toBe('No files found') + expect(text(await call('glob', { pattern: '*.nomatch' }, agent()))).toBe('No files found') }) it('excludes VCS internals even when the search root IS the VCS directory', async () => { // The prune glob alone never matches root-prefixed paths when rg is // rooted at .git; the paired contents glob keeps the exclusion airtight. - expect(text(await call('glob', { pattern: '*', path: '.git' }))).toBe('No files found') + expect(text(await call('glob', { pattern: '*', path: '.git' }, agent()))).toBe('No files found') }) it('classifies an invalid glob as SEARCH_INVALID_PATTERN', async () => { - const result = await call('glob', { pattern: '[' }) + const result = await call('glob', { pattern: '[' }, agent()) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' } }) }) @@ -107,37 +107,42 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () describe('grep', () => { it('greps a directory tree with grouped, line-numbered output', async () => { - const result = await call('grep', { pattern: 'alpha' }) + const result = await call('grep', { pattern: 'alpha' }, agent()) expect(result.isError).toBe(false) const output = text(result) expect(output).toContain('Found 3 matches') - expect(output).toContain('src/alpha.ts\nLine 1: export const alpha = 1\nLine 2: // TODO: refit alpha') + expect(output).toContain(`${join('src', 'alpha.ts')}\nLine 1: export const alpha = 1\nLine 2: // TODO: refit alpha`) expect(output).toContain('notes.md\nLine 1: alpha appears here too') }) it('greps a single FILE target', async () => { - const result = await call('grep', { pattern: 'alpha', path: 'notes.md' }) + const result = await call('grep', { pattern: 'alpha', path: 'notes.md' }, agent()) expect(text(result)).toBe('Found 1 match\n\nnotes.md\nLine 1: alpha appears here too') }) it('greps a directory target with an include filter', async () => { - const result = await call('grep', { pattern: 'alpha', path: '.', include: '*.ts' }) + const result = await call('grep', { pattern: 'alpha', path: '.', include: '*.ts' }, agent()) const output = text(result) expect(output).toContain('alpha.ts') expect(output).not.toContain('notes.md') }) - it('a hostile pattern stays inert (no command substitution, the world untouched)', async () => { + it('a hostile pattern stays inert (a plain argv element, the world untouched)', async () => { + // There is no shell layer between the argv vector and rg, so the pattern + // is a literal regex — but the world-untouched guarantee is the shipped + // contract, and a future shell-wrapping change must not reintroduce it. + // The canary name carries no path so the regex stays valid on every + // platform (a Windows path's backslashes would be regex escapes). const canary = join(dir, 'pwned') - const result = await call('grep', { pattern: `$(touch ${canary})` }) + const result = await call('grep', { pattern: '$(touch pwned)' }, agent()) expect(result.isError).toBe(false) // exit 1: found nothing, executed nothing expect(text(result)).toBe('No matches found') - expect(spawnSync('test', ['-e', canary]).status).not.toBe(0) + expect(existsSync(canary)).toBe(false) }) it('a leading-dash pattern is a pattern, not a flag', async () => { await writeFile(join(dir, 'dashes.txt'), 'value --flag value\n') - const result = await call('grep', { pattern: '--flag', path: 'dashes.txt' }) + const result = await call('grep', { pattern: '--flag', path: 'dashes.txt' }, agent()) expect(text(result)).toBe('Found 1 match\n\ndashes.txt\nLine 1: value --flag value') }) @@ -155,7 +160,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () }) describe('per-session cwd', () => { - it('resolves the search in the SESSION workspace, not the executor config cwd', async () => { + it('resolves the search in the SESSION workspace, not the process cwd', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-search-session-')) try { await writeFile(join(sessionDir, 'only-here.ts'), 'const sessionFile = true\n') @@ -170,7 +175,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () }) }) - describe('pre-dispatch cancellation and bash-start failures', () => { + describe('pre-dispatch cancellation and spawn failures', () => { it('a pre-aborted registry call is ABORTED_BEFORE_DISPATCH', async () => { const controller = new AbortController() controller.abort() diff --git a/packages/fs/tool-fs-search/tests/load-path.spec.ts b/packages/fs/tool-fs-search/tests/load-path.spec.ts index 71022720fc..1d1e348c09 100644 --- a/packages/fs/tool-fs-search/tests/load-path.spec.ts +++ b/packages/fs/tool-fs-search/tests/load-path.spec.ts @@ -3,14 +3,15 @@ * a NAMESPACE plugin with `inject` — so a stray `export default apply` would * make the cordis Loader's `unwrapExports` (`exports.default ?? exports`) * collapse the module to the bare `apply` function, DROPPING `inject`. The - * plugin would then read `ctx.bash` without having injected it and throw + * plugin would then read `ctx.subprocess` without having injected it and throw * `cannot get property … without inject` the moment it loads (postmortem 0001). * * A hand-built `ctx.plugin({ apply, inject })` mount CANNOT catch that — it * bypasses `unwrapExports`. So this test unwraps the module through the REAL - * `Loader.prototype.unwrapExports` and mounts the result over a bash executor, - * exercising the exact path the Loader uses. Prove the guard bites: add - * `export default apply` to `src/index.ts`, watch this go red, revert. + * `Loader.prototype.unwrapExports` and mounts the result over the real local + * subprocess service, exercising the exact path the Loader uses. Prove the + * guard bites: add `export default apply` to `src/index.ts`, watch this go + * red, revert. */ import { describe, expect, it } from 'vitest' @@ -18,48 +19,9 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search' -const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' - -/** - * Deterministic bash service for this Loader guard: the test wants to exercise - * the real unwrap/inject path, not depend on whether the host image has rg. - */ -class ProbeSuccessBashExecutor extends BashExecutor { - override resolve(request: BashExecRequest): BashExecSpec { - return { - command: request.command, - workdir: request.workdir ?? '/work', - timeoutMs: request.timeoutMs ?? 60_000, - stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, - signal: request.signal, - sandboxPolicy: request.sandboxPolicy, - } - } - - override run(spec: BashExecSpec): Promise { - if (spec.command !== RG_PROBE_COMMAND) { - throw new Error(`unexpected command in load-path guard: ${spec.command}`) - } - return Promise.resolve({ - exitCode: 0, - signal: null, - timedOut: false, - aborted: false, - timeoutMs: spec.timeoutMs, - stdout: { text: '', truncated: false }, - stderr: { text: '', truncated: false }, - }) - } - - override start(): BashProcess { - throw new Error('load-path guard must not start background processes') - } -} - describe('dsh-tool-fs-search real-load-path guard', () => { it('has no default export and keeps name/inject/Config through unwrapExports', () => { expect('default' in toolFsSearch).toBe(false) @@ -68,16 +30,16 @@ describe('dsh-tool-fs-search real-load-path guard', () => { const unwrapped = loader.unwrapExports(toolFsSearch) as Record expect(unwrapped).toBe(toolFsSearch) expect(unwrapped.name).toBe('tool-fs-search') - expect(unwrapped.inject).toEqual(['tools', 'systemPrompt', 'bash']) + expect(unwrapped.inject).toEqual(['tools', 'systemPrompt', 'subprocess']) expect(typeof unwrapped.Config).toBe('function') expect(typeof unwrapped.apply).toBe('function') }) - it('boots over ctx.bash through the unwrapped module without an inject error', async () => { + it('boots over ctx.subprocess through the unwrapped module without an inject error', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(ProbeSuccessBashExecutor) + await ctx.plugin(LocalSubprocessService) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters[0] diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 389d8dbe05..d786fa3181 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -1,23 +1,24 @@ /** - * Consumer-surface tests for the search tools over a FAKE bash executor and a - * FAKE spill backend, exercised through `ctx.tools.execute()` so nothing - * bypasses the tool registry. The fake executor makes every seam outcome - * scriptable — registration-time `rg` probing, truncated stdout with/without a - * raw spill path, abort/timeout, signal kills, ripgrep exit codes — so these - * tests verify schemas, argument validation, shell-safe command construction, - * workdir derivation, signal forwarding, `SEARCH_*` error classification, - * retention, formatted-result spill handoff, and the no-background-task - * invariant. Real-`rg` behavior is pinned separately in integration.spec.ts. + * Consumer-surface tests for the search tools over a FAKE subprocess service + * and a FAKE spill backend, exercised through `ctx.tools.execute()` so nothing + * bypasses the tool registry. The fake service makes every seam outcome + * scriptable — spawn failure, truncated stdout with/without a raw spill path, + * abort/timeout kills, signal kills, ripgrep exit codes — so these tests + * verify schemas, argument validation, argv construction, workdir derivation, + * signal forwarding, `SEARCH_*` error classification, retention, + * formatted-result spill handoff, and the no-background-task invariant. + * Real-`rg` behavior is pinned separately in integration.spec.ts. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { join, sep } from 'node:path' -import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' +import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' -import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecution, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import { SubprocessService } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputRead, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { rgPath } from '@vscode/ripgrep' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' @@ -31,68 +32,124 @@ import { presentGrepCall, presentGrepResult, previewLine, + runRipgrep, sampleAcrossTopLevel, toWorkdirRelative, } from '@deepseek-ai/dsh-tool-fs-search' const testToolSignal = new AbortController().signal -const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' -/** A successful run result over the given stdout; overrides script the failure shapes. */ -function runResult(stdout: string, overrides?: Partial): BashRunResult { +/** One scripted collect-mode stream, returned by `readFrom(0)` after settlement. */ +interface ScriptedStream { + text: string + lossy?: boolean + spillPath?: string +} + +/** One scripted spawn: exit facts plus the collected streams the tool reads. */ +interface ScriptedRun { + outcome: SubprocessOutcome + stdout: ScriptedStream + stderr: ScriptedStream +} + +/** A successful run over the given stdout; overrides script the failure shapes. */ +function runResult( + stdout: string, + overrides?: Partial & { stdout?: Partial; stderr?: ScriptedStream }, +): ScriptedRun { + const { stdout: stdoutOverrides, stderr: stderrOverrides, ...outcome } = overrides ?? {} return { - exitCode: 0, - signal: null, - timedOut: false, - aborted: false, - timeoutMs: 60_000, - stdout: { text: stdout, truncated: false }, - stderr: { text: '', truncated: false }, - ...overrides, + outcome: { exitCode: 0, signal: null, ...outcome }, + stdout: { text: stdout, ...stdoutOverrides }, + stderr: { text: '', ...stderrOverrides }, + } +} + +/** A fixed-response collect-mode reader: the tools read each stream once, from 0, after settlement. */ +class FakeReader implements SubprocessOutputReader { + constructor(private readonly read: ScriptedStream) {} + + readFrom(_fromByte: number): SubprocessOutputRead { + return { + text: this.read.text, + nextOffset: 0, + lossy: this.read.lossy ?? false, + ...this.read.spillPath !== undefined ? { spillPath: this.read.spillPath } : {}, + } } } /** - * A scriptable fake executor: `resolve()` mirrors the real request→spec - * defaulting (workdir falls back to `/work`), `run()` returns whatever the - * test armed via `handler`, and `start()` throws — the search tools must NEVER - * create a background task. + * A scriptable subprocess handle: `done` resolves with the scripted outcome + * (or rejects with the scripted error), `terminate()` records the call, and + * the spec's abort signal marks the handle terminated — mirroring the seam's + * abort→terminate escalation. */ -class FakeBash extends BashExecutor { - probeRequests: BashExecRequest[] = [] - probeSpecs: BashExecSpec[] = [] - requests: BashExecRequest[] = [] - specs: BashExecSpec[] = [] - startCalls = 0 - forwardSignal = true - probeResult: BashRunResult = runResult('') - probeError?: Error - handler: (spec: BashExecSpec) => BashRunResult = () => runResult('') +class FakeHandle implements SubprocessHandle { + readonly pid = 4242 + readonly stdin = undefined + readonly stdout = undefined + readonly stderr = undefined + readonly collected: SubprocessCollectedOutputs + readonly done: Promise + /** True once `done` settled — the search tools must never leave a spawn running. */ + settled = false + /** True when the handle's termination path ran (abort signal or explicit terminate). */ + terminated = false + /** Scripted handle that drops one requested collect reader (the defensive branch). */ + readonly dropReaders: boolean - override resolve(request: BashExecRequest): BashExecSpec { - if (request.command === RG_PROBE_COMMAND) this.probeRequests.push(request) - else this.requests.push(request) - return { - command: request.command, - workdir: request.workdir ?? '/work', - timeoutMs: request.timeoutMs ?? 60_000, - stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, - ...this.forwardSignal ? { signal: request.signal } : {}, - sandboxPolicy: request.sandboxPolicy, + constructor(spec: SubprocessSpawnSpec, script: () => ScriptedRun | { reject: Error }, dropReaders = false) { + this.dropReaders = dropReaders + // The abort listener attaches BEFORE the scripted run resolves, mirroring + // a real spawn: the escalation is armed when the process starts. + spec.signal?.addEventListener('abort', () => { this.terminated = true }, { once: true }) + const scripted = script() + if ('reject' in scripted) { + // A spawn failure produces no process output, so no readers exist. + this.collected = {} + this.done = Promise.reject(scripted.reject) + } else { + this.collected = { + ...dropReaders ? {} : { stdout: new FakeReader(scripted.stdout), stderr: new FakeReader(scripted.stderr) }, + } + this.done = Promise.resolve(scripted.outcome) } + this.done.then( + () => { this.settled = true }, + () => { this.settled = true }, + ) } - override async run(spec: BashExecSpec): Promise { - if (spec.command === RG_PROBE_COMMAND) { - this.probeSpecs.push(spec) - if (this.probeError) throw this.probeError - return this.probeResult - } - this.specs.push(spec) - return this.handler(spec) + + terminate(): void { + this.terminated = true } - override start(): BashProcess { - this.startCalls++ - throw new Error('search tools must never start a background task') + + waitForExit(_signal?: AbortSignal): Promise { + return Promise.resolve(true) + } +} + +/** + * A scriptable fake subprocess service: `spawn()` records every spec and + * returns a handle scripted by the armed `handler`. The search tools must + * never spawn outside a single awaited foreground call, so every test can + * assert on the exact spawn specs and settled handles. + */ +class FakeSubprocess extends SubprocessService { + spawns: SubprocessSpawnSpec[] = [] + handles: FakeHandle[] = [] + /** Arms the per-spawn script; a `{ reject }` return scripts a spawn-level failure. */ + handler: (spec: SubprocessSpawnSpec) => ScriptedRun | { reject: Error } = () => runResult('') + /** When true, spawned handles drop their collect readers (the defensive branch). */ + dropReaders = false + + override spawn(spec: SubprocessSpawnSpec): SubprocessHandle { + this.spawns.push(spec) + const handle = new FakeHandle(spec, () => this.handler(spec), this.dropReaders) + this.handles.push(handle) + return handle } } @@ -115,8 +172,6 @@ class FakeSpill extends SpillStore { interface SetupOptions { config?: Partial spill?: boolean - probeError?: Error - probeResult?: BashRunResult } const DEFAULT_CONFIG = { sampleOverCapGlobResults: true } satisfies ToolFsSearch.Config @@ -127,26 +182,12 @@ async function setup(options: SetupOptions = {}) { ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(FakeBash) - const bash = ctx.bash as FakeBash - if (options.probeResult) bash.probeResult = options.probeResult - if (options.probeError) bash.probeError = options.probeError + await ctx.plugin(FakeSubprocess) + const subprocess = ctx.subprocess as FakeSubprocess if (options.spill === true) await ctx.plugin(FakeSpill) const fiber = await ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...options.config }) const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined - return { ctx, bash, spill, fiber, warnings } -} - -/** Assert plugin setup rejects without letting Vitest pretty-print a live Context on failure. */ -async function expectSetupRejects(options: SetupOptions, message: RegExp): Promise { - let thrown: string | undefined - try { - const loaded = await setup(options) - await loaded.fiber.dispose() - } catch (error: unknown) { - thrown = error instanceof Error ? error.message : String(error) - } - expect(thrown).toMatch(message) + return { ctx, subprocess, spill, fiber, warnings } } /** A stand-in agent whose session header carries the given cwd (and a stable id). */ @@ -180,11 +221,11 @@ function matchLine(path: string, lineNumber: number, lineText: string): string { } describe('registration', () => { - it('registers glob and grep with their prompt sections', async () => { - const { ctx, bash } = await setup() - expect(bash.probeRequests).toHaveLength(1) - expect(bash.probeRequests[0]?.command).toBe(RG_PROBE_COMMAND) - expect(bash.probeRequests[0]).not.toHaveProperty('workdir') + it('registers glob and grep unconditionally with their prompt sections', async () => { + const { ctx, subprocess } = await setup() + // Registration performs NO load-time probe: the packaged binary is always + // available, so nothing spawns until a tool call. + expect(subprocess.spawns).toHaveLength(0) expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['glob', 'grep']) const prompt = renderPrompt(await ctx.systemPrompt.assemble()) expect(prompt).toContain('Use the glob tool') @@ -195,32 +236,11 @@ describe('registration', () => { expect(glob?.description).toContain('sampled across top-level entries') }) - it('does not register glob or grep when the bash executor cannot find rg', async () => { - const { ctx, warnings } = await setup({ probeResult: runResult('', { exitCode: 1 }) }) - expect(ctx.tools.schemas()).toHaveLength(0) - const sections = (await ctx.systemPrompt.assemble()).sections.map(s => s.name) - expect(sections).not.toContain('tool:glob') - expect(sections).not.toContain('tool:grep') - expect(warnings).toEqual([ - 'tool-fs-search: ripgrep (rg) not found on the bash executor PATH; glob/grep tools not registered', - ]) - }) - - it('rejects plugin load when the rg availability probe cannot run', async () => { - await expectSetupRejects({ probeError: new Error('spawn bash ENOENT') }, /spawn bash ENOENT/) - }) - - it('rejects plugin load when the rg availability probe is aborted or killed', async () => { - await expectSetupRejects({ - probeResult: runResult('', { aborted: true, exitCode: null, signal: 'SIGTERM' }), - }, /tool-fs-search: ripgrep availability probe did not complete/) - }) - - it('stays pending until ctx.bash exists (inject)', async () => { + it('stays pending until ctx.subprocess exists (inject)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(ToolFsSearch, DEFAULT_CONFIG) // no bash executor + await ctx.plugin(ToolFsSearch, DEFAULT_CONFIG) // no subprocess service expect(ctx.tools.schemas()).toHaveLength(0) }) @@ -276,139 +296,176 @@ describe('config validation', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(FakeBash) + await ctx.plugin(FakeSubprocess) await expect(ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...config })).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`)) }) }) -describe('command construction (shell-safe)', () => { - it('glob: fixed rg --files template with quoted pattern and paired VCS excludes', () => { - const command = buildGlobCommand({ pattern: '**/*.ts' }) - expect(command).toBe( - "rg --files --glob='**/*.ts' --sort=modified --no-ignore --hidden " - + "--glob='!**/.git' --glob='!**/.git/**' --glob='!**/.svn' --glob='!**/.svn/**' " - + "--glob='!**/.hg' --glob='!**/.hg/**' --glob='!**/.bzr' --glob='!**/.bzr/**' " - + "--glob='!**/.jj' --glob='!**/.jj/**' --glob='!**/.sl' --glob='!**/.sl/**'", - ) +describe('command construction (plain argv)', () => { + it('glob: fixed rg --files argv with the pattern and paired VCS excludes', () => { + expect(buildGlobCommand({ pattern: '**/*.ts' })).toEqual([ + '--files', + '--glob=**/*.ts', + '--sort=modified', + '--no-ignore', + '--hidden', + '--glob=!**/.git', '--glob=!**/.git/**', + '--glob=!**/.svn', '--glob=!**/.svn/**', + '--glob=!**/.hg', '--glob=!**/.hg/**', + '--glob=!**/.bzr', '--glob=!**/.bzr/**', + '--glob=!**/.jj', '--glob=!**/.jj/**', + '--glob=!**/.sl', '--glob=!**/.sl/**', + ]) }) - it('glob: the search root rides behind -- and is quoted', () => { - const command = buildGlobCommand({ pattern: '*.md', path: 'docs dir' }) - expect(command).toContain("-- 'docs dir'") + it('glob: the search root rides behind -- as a plain element', () => { + expect(buildGlobCommand({ pattern: '*.md', path: 'docs dir' })).toEqual(['--files', '--glob=*.md', '--sort=modified', '--no-ignore', '--hidden', + '--glob=!**/.git', '--glob=!**/.git/**', + '--glob=!**/.svn', '--glob=!**/.svn/**', + '--glob=!**/.hg', '--glob=!**/.hg/**', + '--glob=!**/.bzr', '--glob=!**/.bzr/**', + '--glob=!**/.jj', '--glob=!**/.jj/**', + '--glob=!**/.sl', '--glob=!**/.sl/**', + '--', 'docs dir']) }) - it('grep: fixed rg --json template with the pattern in --regexp= form', () => { - expect(buildGrepCommand({ pattern: 'foo.*bar' })).toBe("rg --json --regexp='foo.*bar'") + it('grep: fixed rg --json argv with the pattern in --regexp= form', () => { + expect(buildGrepCommand({ pattern: 'foo.*bar' })).toEqual(['--json', '--regexp=foo.*bar']) }) - it('grep: include and path are quoted, include in --glob= form, path behind --', () => { - const command = buildGrepCommand({ pattern: 'x', path: '-leading-dash', include: '*.{ts,tsx}' }) - expect(command).toBe("rg --json --regexp='x' --glob='*.{ts,tsx}' -- '-leading-dash'") + it('grep: include in --glob= form, path behind --, both plain elements', () => { + expect(buildGrepCommand({ pattern: 'x', path: '-leading-dash', include: '*.{ts,tsx}' })) + .toEqual(['--json', '--regexp=x', '--glob=*.{ts,tsx}', '--', '-leading-dash']) }) it.each([ - ['a command-substitution pattern', '$(rm -rf /)', "'$(rm -rf /)'"], - ['a backtick pattern', '`touch pwned`', "'`touch pwned`'"], - ['a pattern with double quotes and spaces', 'say "hi there"', '\'say "hi there"\''], - ['a pattern with single quotes', "it's", '\'it\'\\\'\'s\''], - ['a pattern with newlines', 'a\nb', "'a\nb'"], - ['a leading-dash pattern', '--flag', "'--flag'"], - ['glob metacharacters', '*?[a-z]{x,y}', "'*?[a-z]{x,y}'"], - ])('quotes %s into one inert shell word', (_label, raw, quoted) => { - expect(buildGrepCommand({ pattern: raw })).toBe(`rg --json --regexp=${quoted}`) + ['a command-substitution pattern', '$(rm -rf /)'], + ['a backtick pattern', '`touch pwned`'], + ['a pattern with double quotes and spaces', 'say "hi there"'], + ['a pattern with single quotes', "it's"], + ['a pattern with newlines', 'a\nb'], + ['a leading-dash pattern', '--flag'], + ['glob metacharacters', '*?[a-z]{x,y}'], + ])('keeps %s as ONE inert argv element (no shell layer to escape)', (_label, raw) => { + // The argv vector is handed to rg verbatim: hostile text cannot break out + // of its argument because there is no shell between the vector and rg. + expect(buildGrepCommand({ pattern: raw })).toEqual(['--json', `--regexp=${raw}`]) + expect(buildGlobCommand({ pattern: raw })[1]).toBe(`--glob=${raw}`) }) }) describe('workdir derivation and signal forwarding', () => { - it('forwards the session cwd as the request workdir', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('a.ts\n') + it('forwards the session cwd as the spawn cwd', async () => { + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('a.ts\n') await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') }) - expect(bash.requests[0]?.workdir).toBe('/sessions/s1') - expect(bash.specs[0]?.workdir).toBe('/sessions/s1') + expect(subprocess.spawns[0]?.cwd).toBe('/sessions/s1') }) - it('omits the request workdir without a session cwd so resolve() defaults apply', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('a.ts\n') + it('defaults the spawn cwd to process.cwd() without a session cwd', async () => { + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('a.ts\n') await call(ctx, 'glob', { pattern: '*' }, { agent: agent() }) - expect(bash.requests[0]).not.toHaveProperty('workdir') - expect(bash.specs[0]?.workdir).toBe('/work') - // A non-agent caller takes the same default path. + expect(subprocess.spawns[0]?.cwd).toBe(process.cwd()) + // A non-agent caller takes the same default. await call(ctx, 'grep', { pattern: 'x' }) - expect(bash.requests[1]).not.toHaveProperty('workdir') + expect(subprocess.spawns[1]?.cwd).toBe(process.cwd()) }) - it('forwards exec.signal into the bash spec', async () => { - const { ctx, bash } = await setup() + it('spawns the packaged ripgrep binary with the fixed argv and budgeted collect streams', async () => { + const { ctx, subprocess } = await setup({ config: { rawOutputMaxBytes: 1234 } }) + subprocess.handler = () => runResult('', { exitCode: 1 }) + await call(ctx, 'grep', { pattern: 'needle' }) + const spec = subprocess.spawns[0] + expect(spec?.argv[0]).toBe(rgPath) + expect(spec?.argv).toEqual([rgPath, '--json', '--regexp=needle']) + expect(spec?.stdio.stdin).toBe('ignore') + // stdout gets the tool's parse budget; stderr is a diagnostic excerpt. + expect((spec?.stdio.stdout as { maxBytes: number }).maxBytes).toBe(1234) + expect(spec?.graceMs).toBe(3_000) + }) + + it('forwards exec.signal into the spawn spec', async () => { + const { ctx, subprocess } = await setup() const controller = new AbortController() - bash.handler = () => runResult('') + subprocess.handler = () => runResult('') const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) - expect(bash.specs[0]?.signal).toBe(controller.signal) + expect(subprocess.spawns[0]?.signal).toBe(controller.signal) expect(result.isError).toBe(false) }) - it('reports the bash executor timeout as SEARCH_ABORTED with the budget', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { timedOut: true, timeoutMs: 1234, exitCode: null, signal: 'SIGTERM' }) - const result = await call(ctx, 'glob', { pattern: '*' }) + it('reports an abort fired during the run as SEARCH_ABORTED', async () => { + // The cooperative tool timeout or caller cancellation aborts exec.signal; + // the subprocess seam then kills the process tree. The tool classifies + // the first cause it owns: the abort. + const { ctx, subprocess } = await setup() + const controller = new AbortController() + subprocess.handler = () => { + controller.abort('timeout') + return runResult('', { exitCode: null, signal: 'SIGTERM' }) + } + const result = await call(ctx, 'glob', { pattern: '*' }, { signal: controller.signal }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'SEARCH_ABORTED' } }) - expect(text(result)).toContain('timed out after 1234ms') + expect(text(result)).toContain('aborted before completion') + expect(subprocess.handles[0]?.terminated).toBe(true) }) - it('skips a pre-aborted registry call before run()', async () => { - const { ctx, bash } = await setup() + it('skips a pre-aborted registry call before spawn()', async () => { + const { ctx, subprocess } = await setup() const controller = new AbortController() controller.abort() - bash.handler = () => { throw new Error('aborted before spawn') } + subprocess.handler = () => { throw new Error('aborted before spawn') } const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }) - expect(bash.specs).toHaveLength(0) + expect(subprocess.spawns).toHaveLength(0) }) - it('translates a run() rejection after the forwarded signal aborts', async () => { - const { ctx, bash } = await setup() + it('fails a pre-aborted exec.signal before spawn with SEARCH_ABORTED', async () => { + // Direct unit check of runRipgrep's own pre-spawn guard: the registry + // intercepts most pre-aborted calls, but a signal that aborts between the + // registry check and execute reaches this branch. + const { ctx } = await setup() const controller = new AbortController() - bash.handler = () => { + controller.abort() + const exec = { signal: controller.signal, name: 'glob', callId: CallId('direct-pre-abort') } as unknown as ToolExecution + await expect(runRipgrep(ctx, exec, 'glob', ['--files'], 1_000_000)).rejects + .toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + }) + + it('translates a spawn rejection into SEARCH_FAILED even when the signal aborts concurrently', async () => { + // The seam rejects only for infrastructure failures (unusable workdir, + // missing binary); the abort happened after dispatch, so the launch + // failure is the reportable cause with the original error chained. + const { ctx, subprocess } = await setup() + const controller = new AbortController() + subprocess.handler = () => { controller.abort('cancel search') - throw new Error('executor stopped on abort') + return { reject: new Error('spawn ENOENT') } } const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } }) - expect(text(result)).toContain('aborted before completion') + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) + expect(text(result)).toContain('could not start') }) - it('translates an aborted executor result after dispatch starts', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { aborted: true, exitCode: null }) - - const result = await call(ctx, 'glob', { pattern: '*' }) - - expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } }) - expect(text(result)).toContain('aborted before completion') - }) - - it('translates a run() rejection without an abort (unusable workdir) into SEARCH_FAILED', async () => { - const { ctx, bash } = await setup() - bash.forwardSignal = false - bash.handler = () => { throw new Error('spawn bash ENOENT') } + it('rejects when the subprocess implementation drops a requested collect stream', async () => { + const { ctx, subprocess } = await setup() + subprocess.dropReaders = true const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) - expect(text(result)).toContain('could not start') + expect(text(result)).toContain('no collected output streams') }) }) describe('exit semantics and failure classification', () => { it('exit 1 is a successful empty search', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: 1 }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 1 }) const glob = await call(ctx, 'glob', { pattern: '*.nope' }) expect(glob.isError).toBe(false) expect(text(glob)).toBe('No files found') @@ -418,108 +475,100 @@ describe('exit semantics and failure classification', () => { }) it('a regex parse error classifies as SEARCH_INVALID_PATTERN', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: regex parse error:\n (\nerror: unclosed group', truncated: false } }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: regex parse error:\n (\nerror: unclosed group' } }) const result = await call(ctx, 'grep', { pattern: '(' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } }) expect(text(result)).toContain('regex parse error') }) it('a glob parse error classifies as SEARCH_INVALID_PATTERN', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: error parsing glob \'[\': unclosed character class', truncated: false } }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: error parsing glob \'[\': unclosed character class' } }) const result = await call(ctx, 'glob', { pattern: '[' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } }) }) - it('a missing rg binary classifies as SEARCH_FAILED naming ripgrep', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: 127, stderr: { text: 'bash: line 1: rg: command not found', truncated: false } }) + it('a failed ripgrep launch classifies as SEARCH_FAILED naming ripgrep', async () => { + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 127, stderr: { text: 'sh: rg: command not found' } }) const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('requires ripgrep (rg)') // The same classification holds from either evidence alone: the 127 exit // with silent stderr, or a shell's command-not-found text on another exit. - bash.handler = () => runResult('', { exitCode: 127 }) + subprocess.handler = () => runResult('', { exitCode: 127 }) expect(text(await call(ctx, 'glob', { pattern: '*' }))).toContain('requires ripgrep (rg)') - bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'sh: rg: command not found', truncated: false } }) + subprocess.handler = () => runResult('', { exitCode: 2, stderr: { text: 'sh: rg: command not found' } }) expect(text(await call(ctx, 'grep', { pattern: 'x' }))).toContain('requires ripgrep (rg)') }) it('other nonzero exits are SEARCH_FAILED carrying the stderr excerpt', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: missing.dir: IO error: no such file or directory', truncated: false } }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: missing.dir: IO error: no such file or directory' } }) const result = await call(ctx, 'grep', { pattern: 'x', path: 'missing.dir' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('IO error') }) it('a nonzero exit with EMPTY stderr still reports the exit code', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: 3 }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 3 }) const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('exit 3') }) it('truncated stderr gains a truncation note and stderr.spillPath is never read', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 2, - stderr: { text: 'tail of diagnostics', truncated: true, spillPath: '/does/not/exist-and-never-read' }, + stderr: { text: 'tail of diagnostics', lossy: true, spillPath: '/does/not/exist-and-never-read' }, }) const result = await call(ctx, 'grep', { pattern: 'x' }) expect(text(result)).toContain('tail of diagnostics [stderr truncated]') }) it('a signal kill (not timeout, not abort) is SEARCH_FAILED', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: null, signal: 'SIGKILL' }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: null, signal: 'SIGKILL' }) const result = await call(ctx, 'grep', { pattern: 'x' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('SIGKILL') }) it('a null exit with no signal (defensive) is SEARCH_FAILED', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: null, signal: null }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: null, signal: null }) const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) + expect(text(result)).toContain('killed by signal (unknown)') }) }) describe('raw output acquisition', () => { - it('passes rawOutputMaxBytes to bash as the stdout capture budget', async () => { - const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 1234 } }) - bash.handler = () => runResult('', { exitCode: 1 }) - await call(ctx, 'glob', { pattern: '*.ts' }) - await call(ctx, 'grep', { pattern: 'needle' }) - expect(bash.requests.map(request => request.stdoutMaxBytes)).toEqual([1234, 1234]) - expect(bash.specs.map(spec => spec.stdoutMaxBytes)).toEqual([1234, 1234]) - }) - it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when truncated stdout has a raw spill path', async () => { - const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } }) - bash.handler = () => runResult('', { stdout: { text: 'x', truncated: true, spillPath: '/does/not/get-read' } }) + const { ctx, subprocess } = await setup({ config: { rawOutputMaxBytes: 16 } }) + subprocess.handler = () => runResult('', { stdout: { text: 'x', lossy: true, spillPath: '/does/not/get-read' } }) const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } }) expect(text(result)).toContain('narrow pattern, path, or include') }) it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when UNTRUNCATED inline stdout exceeds the cap', async () => { - // An executor retaining more inline than this package's cap (or a - // deployment lowering rawOutputMaxBytes below the bash retention) must not - // smuggle an over-cap parse through the untruncated path. - const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } }) - bash.handler = () => runResult(`${'x'.repeat(64)}\n`) + // A subprocess implementation retaining more inline than this package's + // cap (or a deployment lowering rawOutputMaxBytes below the retention + // budget) must not smuggle an over-cap parse through the untruncated path. + const { ctx, subprocess } = await setup({ config: { rawOutputMaxBytes: 16 } }) + subprocess.handler = () => runResult(`${'x'.repeat(64)}\n`) const result = await call(ctx, 'grep', { pattern: 'x' }) expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } }) expect(text(result)).toContain('narrow pattern, path, or include') }) it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when truncated stdout has no spill path', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { stdout: { text: 'partial', truncated: true } }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { stdout: { text: 'partial', lossy: true } }) const result = await call(ctx, 'grep', { pattern: 'x' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } }) }) @@ -614,8 +663,8 @@ describe('cross-directory sampling', () => { describe('glob results', () => { it('lists workdir-relative paths (absolute output under the workdir is relativized)', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n') + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n') const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') }) if (result.isError) throw new Error('expected glob success') expect(result.value).toEqual({ root: '.', paths: [join('src', 'a.ts'), '/elsewhere/b.ts', 'rel/c.ts'] }) @@ -628,23 +677,30 @@ describe('glob results', () => { expect(text(await call(ctx, 'glob', { pattern: '*', path: ' ' }))).toContain('path must be a non-empty string') }) - it('threads a valid path through to the command as the quoted search root', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('sub/a.ts\n') + it('threads a valid path through to the spawn as the plain search root element', async () => { + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('sub/a.ts\n') const result = await call(ctx, 'glob', { pattern: '*.ts', path: 'sub' }) expect(result.isError).toBe(false) - expect(bash.specs[0]?.command).toContain("-- 'sub'") + expect(subprocess.spawns[0]?.argv).toEqual([rgPath, '--files', '--glob=*.ts', '--sort=modified', '--no-ignore', '--hidden', + '--glob=!**/.git', '--glob=!**/.git/**', + '--glob=!**/.svn', '--glob=!**/.svn/**', + '--glob=!**/.hg', '--glob=!**/.hg/**', + '--glob=!**/.bzr', '--glob=!**/.bzr/**', + '--glob=!**/.jj', '--glob=!**/.jj/**', + '--glob=!**/.sl', '--glob=!**/.sl/**', + '--', 'sub']) }) it('caps at globMaxResults and saves the FULL sorted list through spillStore', async () => { - const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true }) + const { ctx, subprocess, spill } = await setup({ config: { globMaxResults: 2 }, spill: true }) ctx.on('tools/post-execute', async () => ({ kind: 'accept', additionalContexts: [createUserMessage({ content: [{ type: 'text', text: 'glob context' }], source: { kind: 'plugin', plugin: 'test' }, })], })) - bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n') + subprocess.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n') const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') }) expect(result.isError).toBe(false) if (result.isError) throw new Error('expected glob success') @@ -665,8 +721,8 @@ describe('glob results', () => { // The shipped failure: `*` matches the whole tree, mtime order puts one // freshly-unpacked subtree first, and a head-of-3 reads like the entire // workspace. The sample reaches every top-level entry instead. - const { ctx, bash } = await setup({ config: { globMaxResults: 3 } }) - bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md', 'top.txt'].join('\n')) + const { ctx, subprocess } = await setup({ config: { globMaxResults: 3 } }) + subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md', 'top.txt'].join('\n')) const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }) expect(text(result)).toBe('vendor/a.ts\nsrc/d.ts\nguide/e.md\n\n' + '(Showing 3 of 6 paths, sampled across 3 of the 4 top-level entries this pattern matched ' @@ -675,18 +731,18 @@ describe('glob results', () => { }) it('keeps the modification-time head when over-cap sampling is disabled', async () => { - const { ctx, bash } = await setup({ + const { ctx, subprocess } = await setup({ config: { globMaxResults: 3, sampleOverCapGlobResults: false }, }) - bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md'].join('\n')) + subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md'].join('\n')) expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }))) .toBe('vendor/a.ts\nvendor/b.ts\nvendor/c.ts\n\n' + '(Showing 3 of 5 paths. The complete result could not be saved; narrow pattern or path to see more.)') }) it('samples relative to the explicit search root instead of its workdir prefix', async () => { - const { ctx, bash } = await setup({ config: { globMaxResults: 3 } }) - bash.handler = () => runResult([ + const { ctx, subprocess } = await setup({ config: { globMaxResults: 3 } }) + subprocess.handler = () => runResult([ 'workspace/vendor/a.ts', 'workspace/vendor/b.ts', 'workspace/source/c.ts', @@ -698,8 +754,8 @@ describe('glob results', () => { }) it('samples relative to an absolute search root after workdir display conversion', async () => { - const { ctx, bash } = await setup({ config: { globMaxResults: 3 } }) - bash.handler = () => runResult([ + const { ctx, subprocess } = await setup({ config: { globMaxResults: 3 } }) + subprocess.handler = () => runResult([ '/w/workspace/vendor/a.ts', '/w/workspace/vendor/b.ts', '/w/workspace/source/c.ts', @@ -711,8 +767,8 @@ describe('glob results', () => { }) it('drops the narrowing hint when the sample reaches every top-level entry', async () => { - const { ctx, bash } = await setup({ config: { globMaxResults: 3 } }) - bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts'].join('\n')) + const { ctx, subprocess } = await setup({ config: { globMaxResults: 3 } }) + subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts'].join('\n')) expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }))) .toBe('vendor/a.ts\nvendor/b.ts\nsrc/d.ts\n\n' + '(Showing 3 of 4 paths, sampled across 2 of the 2 top-level entries this pattern matched ' @@ -721,34 +777,34 @@ describe('glob results', () => { }) it('keeps modification-time order untouched when the whole result fits', async () => { - const { ctx, bash } = await setup({ config: { globMaxResults: 4 } }) - bash.handler = () => runResult('vendor/a.ts\nvendor/b.ts\nsrc/c.ts\n') + const { ctx, subprocess } = await setup({ config: { globMaxResults: 4 } }) + subprocess.handler = () => runResult('vendor/a.ts\nvendor/b.ts\nsrc/c.ts\n') expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }))) .toBe('vendor/a.ts\nvendor/b.ts\nsrc/c.ts') }) it('keeps the plain footer for a flat result, where the sample is the modification-time head', async () => { - const { ctx, bash } = await setup({ config: { globMaxResults: 2 } }) - bash.handler = () => runResult('a.ts\nb.ts\nc.ts\n') + const { ctx, subprocess } = await setup({ config: { globMaxResults: 2 } }) + subprocess.handler = () => runResult('a.ts\nb.ts\nc.ts\n') expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }))) .toBe('a.ts\nb.ts\n\n(Showing 2 of 3 paths. The complete result could not be saved; narrow pattern or path to see more.)') }) it('does not create a spill file when the result fits inline', async () => { - const { ctx, bash, spill } = await setup({ spill: true }) - bash.handler = () => runResult('a.ts\nb.ts\n') + const { ctx, subprocess, spill } = await setup({ spill: true }) + subprocess.handler = () => runResult('a.ts\nb.ts\n') const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }) expect(text(result)).toBe('a.ts\nb.ts') expect(spill?.saves).toHaveLength(0) }) it('preserves a downstream canonical value replacement instead of spilling the old value', async () => { - const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: true }) + const { ctx, subprocess, spill } = await setup({ config: { globMaxResults: 1 }, spill: true }) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: { root: '.', paths: ['replacement-a.ts', 'replacement-b.ts'] }, })) - bash.handler = () => runResult('old-a.ts\nold-b.ts\n') + subprocess.handler = () => runResult('old-a.ts\nold-b.ts\n') const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') }) @@ -760,8 +816,8 @@ describe('glob results', () => { }) it('keeps the full nested Code value without creating a surface spill', async () => { - const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true }) - bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n') + const { ctx, subprocess, spill } = await setup({ config: { globMaxResults: 2 }, spill: true }) + subprocess.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n') const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w'), parent: Symbol('run_code') as ToolExecutionToken, @@ -777,9 +833,9 @@ describe('glob results', () => { ['saveText fails', { fail: true, spill: true, ownerless: false }], ['no session owner', { fail: false, spill: true, ownerless: true }], ])('keeps the inline page and reports the unsaved remainder when %s', async (_label, mode) => { - const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: mode.spill }) + const { ctx, subprocess, spill } = await setup({ config: { globMaxResults: 1 }, spill: mode.spill }) if (mode.fail && spill) spill.failWith = new Error('disk full') - bash.handler = () => runResult('a.ts\nb.ts\n') + subprocess.handler = () => runResult('a.ts\nb.ts\n') const result = await call(ctx, 'glob', { pattern: '*' }, mode.ownerless ? {} : { agent: agent('/w') }) expect(result.isError).toBe(false) // spill unavailability never fails the search expect(text(result)).toBe('a.ts\n\n(Showing 1 of 2 paths. The complete result could not be saved; narrow pattern or path to see more.)') @@ -788,8 +844,8 @@ describe('glob results', () => { describe('grep results', () => { it('groups matches by file with line numbers', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult([ + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult([ JSON.stringify({ type: 'begin', data: { path: { text: 'a.ts' } } }), matchLine('a.ts', 3, 'const x = 1\n'), matchLine('a.ts', 9, 'const y = 2\n'), @@ -812,23 +868,23 @@ describe('grep results', () => { }) it('reports a single match in the singular', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult(`${matchLine('a.ts', 1, 'hit')}\n`) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult(`${matchLine('a.ts', 1, 'hit')}\n`) expect(text(await call(ctx, 'grep', { pattern: 'hit' }))).toBe('Found 1 match\n\na.ts\nLine 1: hit') }) it('relativizes absolute match paths against the resolved workdir', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult(`${matchLine('/sessions/s1/deep/a.ts', 2, 'hit')}\n`) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult(`${matchLine('/sessions/s1/deep/a.ts', 2, 'hit')}\n`) const result = await call(ctx, 'grep', { pattern: 'hit', path: '/sessions/s1' }, { agent: agent('/sessions/s1') }) expect(text(result)).toContain(`${join('deep', 'a.ts')}\nLine 2: hit`) }) it('previews a long matched line at grepMaxLineBytes preserving UTF-8', async () => { - const { ctx, bash } = await setup({ config: { grepMaxLineBytes: 7 } }) + const { ctx, subprocess } = await setup({ config: { grepMaxLineBytes: 7 } }) // 'héllo wörld' cut at 7 bytes lands mid-'é'? h(1)é(2)l(1)l(1)o(1)=6, space=7 → clean cut at 7. // Use a multibyte straddle instead: 'aé' repeated — cut at 7 bytes: a(1)é(2)a(1)é(2)=6 +a(1)=7 → next é straddles: trimmed. - bash.handler = () => runResult(`${matchLine('a.txt', 1, 'aéaéaéaé')}\n`) + subprocess.handler = () => runResult(`${matchLine('a.txt', 1, 'aéaéaéaé')}\n`) const result = await call(ctx, 'grep', { pattern: 'a' }) if (result.isError) throw new Error('expected grep success') expect(result.value).toEqual({ matches: [{ path: 'a.txt', lineNumber: 1, line: 'aéaéaéaé' }] }) @@ -836,9 +892,9 @@ describe('grep results', () => { }) it('renders a non-UTF-8 line (rg bytes form) as a placeholder instead of failing', async () => { - const { ctx, bash } = await setup() + const { ctx, subprocess } = await setup() const record = JSON.stringify({ type: 'match', data: { path: { text: 'bin.dat' }, lines: { bytes: 'AAECww==' }, line_number: 4 } }) - bash.handler = () => runResult(`${record}\n`) + subprocess.handler = () => runResult(`${record}\n`) expect(text(await call(ctx, 'grep', { pattern: 'x' }))).toContain('Line 4: (line is not valid UTF-8)') }) @@ -848,14 +904,14 @@ describe('grep results', () => { }) it('caps at grepMaxMatches and spills the full formatted match list', async () => { - const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 2 }, spill: true }) + const { ctx, subprocess, spill } = await setup({ config: { grepMaxMatches: 2 }, spill: true }) ctx.on('tools/post-execute', async () => ({ kind: 'accept', additionalContexts: [createUserMessage({ content: [{ type: 'text', text: 'grep context' }], source: { kind: 'plugin', plugin: 'test' }, })], })) - bash.handler = () => runResult([ + subprocess.handler = () => runResult([ matchLine('a.ts', 1, 'one'), matchLine('a.ts', 2, 'two'), matchLine('b.ts', 3, 'three'), @@ -880,7 +936,7 @@ describe('grep results', () => { }) it('preserves a downstream canonical value replacement instead of spilling the old matches', async () => { - const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true }) + const { ctx, subprocess, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true }) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: { @@ -890,7 +946,7 @@ describe('grep results', () => { ], }, })) - bash.handler = () => runResult(`${matchLine('old.ts', 1, 'old')}\n`) + subprocess.handler = () => runResult(`${matchLine('old.ts', 1, 'old')}\n`) const result = await call(ctx, 'grep', { pattern: 'old' }, { agent: agent('/w') }) @@ -907,8 +963,8 @@ describe('grep results', () => { }) it('keeps every nested Code match in the value without creating a surface spill', async () => { - const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true }) - bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('b.ts', 2, 'two')}\n`) + const { ctx, subprocess, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true }) + subprocess.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('b.ts', 2, 'two')}\n`) const result = await call(ctx, 'grep', { pattern: 'o' }, { agent: agent('/w'), parent: Symbol('run_code') as ToolExecutionToken, @@ -925,8 +981,8 @@ describe('grep results', () => { }) it('reports the unsaved remainder when capped with no spill backend', async () => { - const { ctx, bash } = await setup({ config: { grepMaxMatches: 1 } }) - bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('a.ts', 2, 'two')}\n`) + const { ctx, subprocess } = await setup({ config: { grepMaxMatches: 1 } }) + subprocess.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('a.ts', 2, 'two')}\n`) const result = await call(ctx, 'grep', { pattern: 'o' }, { agent: agent('/w') }) expect(result.isError).toBe(false) expect(text(result)).toBe('Found 1 of 2 matches\n\na.ts\nLine 1: one\n\n(The complete result could not be saved; narrow pattern, path, or include to see more.)') @@ -942,8 +998,8 @@ describe('grep results', () => { }) it('accepts a whitespace-only pattern (a legitimate regex) and brace alternation in include', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: 1 }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 1 }) const result = await call(ctx, 'grep', { pattern: ' ', include: '*.{ts,tsx}' }) expect(result.isError).toBe(false) }) @@ -960,8 +1016,8 @@ describe('rg --json transport failures (SEARCH_FAILED)', () => { ['a match record with no line content', JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, line_number: 1 } })], ['a match record with neither text nor bytes', JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, lines: {}, line_number: 1 } })], ])('%s fails the search', async (_label, line) => { - const { ctx, bash } = await setup() - bash.handler = () => runResult(`${line}\n`) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult(`${line}\n`) const result = await call(ctx, 'grep', { pattern: 'x' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) @@ -969,13 +1025,16 @@ describe('rg --json transport failures (SEARCH_FAILED)', () => { }) describe('the no-background-task invariant', () => { - it('never calls ctx.bash.start() across successful and failed searches', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('a.ts\n') + it('settles every spawned search handle across successful and failed searches', async () => { + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('a.ts\n') await call(ctx, 'glob', { pattern: '*' }) - bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'boom', truncated: false } }) + subprocess.handler = () => runResult('', { exitCode: 2, stderr: { text: 'boom' } }) await call(ctx, 'grep', { pattern: 'x' }) - expect(bash.startCalls).toBe(0) + // One foreground spawn per call, each awaited to settlement before the + // tool returns — the searches never leave a background handle running. + expect(subprocess.spawns).toHaveLength(2) + expect(subprocess.handles.every(handle => handle.settled)).toBe(true) }) }) @@ -991,8 +1050,8 @@ describe('presentation', () => { }) 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([ + const { ctx, subprocess } = await setup({ config: { grepMaxMatches: 2 } }) + subprocess.handler = () => runResult([ matchLine('a.ts', 1, 'one'), matchLine('a.ts', 2, 'two'), matchLine('b.ts', 3, 'three'), @@ -1018,8 +1077,8 @@ describe('presentation', () => { }) 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 { ctx, subprocess } = await setup({ config: { globMaxResults: 2 } }) + subprocess.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({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 }) @@ -1028,8 +1087,8 @@ describe('presentation', () => { }) 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 { ctx, subprocess } = await setup() + subprocess.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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34eb7b8f87..8b38715d30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2931,6 +2931,9 @@ importers: packages/fs/tool-fs-search: dependencies: + '@vscode/ripgrep': + specifier: ^1.18.0 + version: 1.18.0 schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -2938,12 +2941,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-bash': - specifier: workspace:^ - version: link:../../bash/bash - '@deepseek-ai/dsh-bash-local': - specifier: workspace:^ - version: link:../../bash/bash-local '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2959,6 +2956,9 @@ importers: '@deepseek-ai/dsh-spill': specifier: workspace:^ version: link:../../spill/spill + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local @@ -9010,6 +9010,69 @@ packages: '@vitest/utils@4.1.8': resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@vscode/ripgrep-darwin-arm64@1.18.0': + resolution: {integrity: sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q==} + cpu: [arm64] + os: [darwin] + + '@vscode/ripgrep-darwin-x64@1.18.0': + resolution: {integrity: sha512-25b4gWbL138dGuQU244ebCKKc0q05ULBMoFSz9oAEUHNeqK/lOJViDS7DRvbDazzAzSEdan391Znks/R5mkaTQ==} + cpu: [x64] + os: [darwin] + + '@vscode/ripgrep-linux-arm64@1.18.0': + resolution: {integrity: sha512-lQ/5zTG++U0E3IhVgS4EPTTn/U4okncaRMM5GOFfOYZywS4nuD31GhkHbNYlDk5CuDC68+hYJ0/eQeyCKJDA+g==} + cpu: [arm64] + os: [linux] + + '@vscode/ripgrep-linux-arm@1.18.0': + resolution: {integrity: sha512-GDAvufNDHu8zqLEmXstalQF0Wh6wQvdsBi/Vg3Yi3CK4a8XoFXqqXVEHEZ9xQz3t0NfoSEc9JbvK9DDS6FxyxQ==} + cpu: [arm] + os: [linux] + + '@vscode/ripgrep-linux-ia32@1.18.0': + resolution: {integrity: sha512-YWLkSUtFd4Jh5EepIhA9RJSfv3uMAVMo+2rBIGHPBnvgLrZciIs2cDKei1/p6Wc/aCzUoHyMAg2R6tw4ZCBKGg==} + cpu: [ia32] + os: [linux] + + '@vscode/ripgrep-linux-ppc64@1.18.0': + resolution: {integrity: sha512-quXVY8fwQ8O/lvU1yrSqSl3jlUzysRSb+AfUfCL/tRtphxsKlFvPAejryZ6vg4Bgvn8XL74xb4qMCDmWgYrT5w==} + cpu: [ppc64] + os: [linux] + + '@vscode/ripgrep-linux-riscv64@1.18.0': + resolution: {integrity: sha512-f5kBQBrWfQt8Q7OhSORuNDei5dkYagBj3y4jImSUXGMy8B/Ke7SltSRcUtjPv166FAFfHCAmWuZp3+cWnX2/Vw==} + cpu: [riscv64] + os: [linux] + + '@vscode/ripgrep-linux-s390x@1.18.0': + resolution: {integrity: sha512-rTOcJFGGcl2c07RUOWUo4U1ndnemKhY6A9hnMB18uk7jSgJc0d/QLBGWMWpumdtoJtpizn/wIv5mXIisJukusQ==} + cpu: [s390x] + os: [linux] + + '@vscode/ripgrep-linux-x64@1.18.0': + resolution: {integrity: sha512-mQ3bVrUpnD2vs7QT0vX90Lt0cnUq467uFtEktIdsJJmW296RoSULRGqWgzG1AKxyBpNDD6l4ZO4qKf6SgyC23Q==} + cpu: [x64] + os: [linux] + + '@vscode/ripgrep-win32-arm64@1.18.0': + resolution: {integrity: sha512-vfTIjq1OHnzUjxZcHVQAMbnggp8dpGf+0QKFOZHwWPqFwXxQC8eCWM+5NUdoJ6yrElCeMzoUTXoK/LdZaniB+Q==} + cpu: [arm64] + os: [win32] + + '@vscode/ripgrep-win32-ia32@1.18.0': + resolution: {integrity: sha512-//rfAE+BOw5AC2EMmepmiE36jUuevtQYNQqqlw1s3m9FlRxjxEut97RkRPHAu9BG4mSojatZx+kXZXNdyI9caQ==} + cpu: [ia32] + os: [win32] + + '@vscode/ripgrep-win32-x64@1.18.0': + resolution: {integrity: sha512-KNPvtElldqILHdnAetujPaowkNbpqJy3ssIGGN6F6Kve9Qi+nNLI2DN01O83JjCEVQbCzl8Ov3QZ9Eov3BR8Dg==} + cpu: [x64] + os: [win32] + + '@vscode/ripgrep@1.18.0': + resolution: {integrity: sha512-ns5lWe44tSfbTMbVUsyB+I1819PVSw4AdpgK0RNkzfWfwy6+3IUNSxwSrfTno1/oWaS/hERNz+XLWVyga2aJBQ==} + '@vue/compiler-core@3.5.39': resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} @@ -14110,6 +14173,57 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vscode/ripgrep-darwin-arm64@1.18.0': + optional: true + + '@vscode/ripgrep-darwin-x64@1.18.0': + optional: true + + '@vscode/ripgrep-linux-arm64@1.18.0': + optional: true + + '@vscode/ripgrep-linux-arm@1.18.0': + optional: true + + '@vscode/ripgrep-linux-ia32@1.18.0': + optional: true + + '@vscode/ripgrep-linux-ppc64@1.18.0': + optional: true + + '@vscode/ripgrep-linux-riscv64@1.18.0': + optional: true + + '@vscode/ripgrep-linux-s390x@1.18.0': + optional: true + + '@vscode/ripgrep-linux-x64@1.18.0': + optional: true + + '@vscode/ripgrep-win32-arm64@1.18.0': + optional: true + + '@vscode/ripgrep-win32-ia32@1.18.0': + optional: true + + '@vscode/ripgrep-win32-x64@1.18.0': + optional: true + + '@vscode/ripgrep@1.18.0': + optionalDependencies: + '@vscode/ripgrep-darwin-arm64': 1.18.0 + '@vscode/ripgrep-darwin-x64': 1.18.0 + '@vscode/ripgrep-linux-arm': 1.18.0 + '@vscode/ripgrep-linux-arm64': 1.18.0 + '@vscode/ripgrep-linux-ia32': 1.18.0 + '@vscode/ripgrep-linux-ppc64': 1.18.0 + '@vscode/ripgrep-linux-riscv64': 1.18.0 + '@vscode/ripgrep-linux-s390x': 1.18.0 + '@vscode/ripgrep-linux-x64': 1.18.0 + '@vscode/ripgrep-win32-arm64': 1.18.0 + '@vscode/ripgrep-win32-ia32': 1.18.0 + '@vscode/ripgrep-win32-x64': 1.18.0 + '@vue/compiler-core@3.5.39': dependencies: '@babel/parser': 7.29.7 diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 5306f9a003..5cb912c9ff 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -157,6 +157,30 @@ function loadWorkspaceManifests(): { manifests: Map; names: Se return { manifests, names } } +type VirtualManifest = Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string } + +/** + * Resolve one package's manifest inside a pnpm virtual store. The prefix scan + * matches ordinary `@scope+name@version` directory names; pnpm 11 truncates + * long names (a peer-suffixed name past the length limit becomes + * `_`), so a content scan falls back over the whole store when + * the prefix misses. + */ +function virtualManifest(virtual: string, name: string): VirtualManifest | undefined { + const prefix = `${name.replace('/', '+')}@` + const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix)) + if (entry !== undefined) { + return JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as VirtualManifest + } + for (const dir of readdirSync(virtual)) { + const candidate = resolve(virtual, dir, 'node_modules', name, 'package.json') + if (existsSync(candidate)) { + return JSON.parse(readFileSync(candidate, 'utf8')) as VirtualManifest + } + } + return undefined +} + /** License and repository URL for an installed external package, from the pnpm store. */ function installedMetadata(name: string): { license: string; repo: string } { const override = OVERRIDES[name] @@ -171,11 +195,8 @@ function installedMetadata(name: string): { license: string; repo: string } { } const virtual = resolve(root, store, '.pnpm') if (!existsSync(virtual)) continue - const prefix = `${name.replace('/', '+')}@` - const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix)) - if (entry === undefined) continue - manifest = JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as typeof manifest - break + manifest = virtualManifest(virtual, name) + if (manifest !== undefined) break } const license = override?.license ?? manifest?.license const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index a77bdb6d95..3db63b4271 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -16,8 +16,6 @@ import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite' import GoalService from '@deepseek-ai/dsh-goal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' -import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -55,44 +53,6 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog.md' -const CATALOG_RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' - -/** - * Minimal bash service for harvesting `dsh-tool-fs-search` schemas. The search - * plugin now probes `rg` at registration time, but the generated catalog must - * remain independent of the host PATH and never execute a real search. - */ -class CatalogSearchBashExecutor extends BashExecutor { - override resolve(request: BashExecRequest): BashExecSpec { - return { - command: request.command, - workdir: request.workdir ?? root, - timeoutMs: request.timeoutMs ?? 60_000, - stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, - signal: request.signal, - sandboxPolicy: request.sandboxPolicy, - } - } - - override run(spec: BashExecSpec): Promise { - if (spec.command !== CATALOG_RG_PROBE_COMMAND) { - throw new Error(`gen-tool-catalog: unexpected search bash command during schema harvest: ${spec.command}`) - } - return Promise.resolve({ - exitCode: 0, - signal: null, - timedOut: false, - aborted: false, - timeoutMs: spec.timeoutMs, - stdout: { text: '', truncated: false }, - stderr: { text: '', truncated: false }, - }) - } - - override start(): BashProcess { - throw new Error('gen-tool-catalog: search schema harvest must not start background processes') - } -} /** * Register the descriptor needed to mount schema-producing consumers. Declares @@ -264,19 +224,19 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-fs-search', dir: 'tool-fs-search', source: 'packages/fs/tool-fs-search/src/index.ts', - requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt'], + requires: ['ctx.tools', 'ctx.subprocess', 'ctx.systemPrompt'], writes: ['tool/call', 'tool/result'], async mount(ctx) { - // The tools inject `bash` (search executes fixed `rg` commands through - // the executor seam, not ctx.fs). Use a catalog-only executor so the - // registration-time `rg` probe stays deterministic and the generator - // never depends on the host PATH. `ctx.spillStore` is optional (read via - // ctx.get) and does not affect the schemas, so no spill backend is mounted. - await ctx.plugin(CatalogSearchBashExecutor) + // The tools inject `subprocess` (search spawns the packaged ripgrep + // binary through the seam, not ctx.fs); registration itself never + // spawns, so the real local service is inert here. `ctx.spillStore` is + // optional (read via ctx.get) and does not affect the schemas, so no + // spill backend is mounted. + await ctx.plugin(LocalSubprocessService) await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true }) }, note: - 'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', + 'glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', }, { pkg: '@deepseek-ai/dsh-tool-pty', From 7206c6019a1be093a0f9aca293a34f577494ceb8 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 18:15:11 +0800 Subject: [PATCH 02/11] chore(knip): drop the now-unused rg binary exemption tool-fs-search no longer spawns rg from PATH after the packaged-binary switch, so its per-workspace ignoreBinaries entry is stale. --- knip.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/knip.json b/knip.json index 99b152738d..30b1821cd3 100644 --- a/knip.json +++ b/knip.json @@ -566,9 +566,6 @@ "project": [ "src/**/*.ts", "tests/**/*.ts" - ], - "ignoreBinaries": [ - "rg" ] }, "packages/mcp/mcp-client": { From 00148eea9732fe73ae4fdaf2932d35c4e708bf81 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 19:13:42 +0800 Subject: [PATCH 03/11] fix(scripts): normalize manifest glob paths in the notices generator Node's fs.globSync returns OS-native separators: on Windows the backslash paths failed the /-suffixed DEV_ONLY_AREAS prefix match in tierExternalDeps, silently tiering dev-area manifests (test-runtime, support/*, apps/*) as runtime dependencies. Normalize to / at ingestion so the generated notices are platform-independent. --- THIRD_PARTY_NOTICES.md | 8 ++++---- scripts/gen-third-party-notices.ts | 13 ++++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1b14996c3f..f4152cb8a0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -47,8 +47,6 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | -| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | -| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | @@ -57,7 +55,6 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | | [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | -| [`execa`](https://github.com/sindresorhus/execa) | MIT | | [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | @@ -83,7 +80,6 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`turndown`](https://github.com/mixmark-io/turndown) | MIT | | [`typescript`](https://github.com/microsoft/TypeScript) | Apache-2.0 | | [`use-sync-external-store`](https://github.com/facebook/react) | MIT | -| [`vitest`](https://github.com/vitest-dev/vitest) | MIT | | [`yaml`](https://github.com/eemeli/yaml) | ISC | | [`zod`](https://github.com/colinhacks/zod) | MIT | | [`zustand`](https://github.com/pmndrs/zustand) | MIT | @@ -103,6 +99,8 @@ External packages **directly declared** only by repository tooling, test infrast | [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT | +| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | +| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | @@ -125,6 +123,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`esbuild`](https://github.com/evanw/esbuild) | MIT | | [`eslint`](https://github.com/eslint/eslint) | MIT | | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | +| [`execa`](https://github.com/sindresorhus/execa) | MIT | | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | | [`jscpd`](https://github.com/kucherenko/jscpd) | MIT | | [`jsdom`](https://github.com/jsdom/jsdom) | MIT | @@ -144,6 +143,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`vite-tsconfig-paths`](https://github.com/aleclarson/vite-tsconfig-paths) | MIT | | [`vitepress`](https://github.com/vuejs/vitepress) | MIT | | [`vitepress-plugin-mermaid`](https://github.com/emersonbottero/vitepress-plugin-mermaid) | MIT | +| [`vitest`](https://github.com/vitest-dev/vitest) | MIT | `eslint-plugin-sonarjs` (LGPL-3.0-only) and `lightningcss` (MPL-2.0) run only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact. diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 5cb912c9ff..a4a1f4c343 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -141,15 +141,22 @@ function workspaceMembers(rel: string): string[] { return declared.map(member => String(member)) } -/** Every workspace manifest, keyed by path, plus the set of workspace package names. */ +/** + * Every workspace manifest, keyed by repository-relative path, plus the set of + * workspace package names. Paths are normalized to `/` at ingestion: Node's + * `fs.globSync` returns OS-native separators, and the area matching in + * `tierExternalDeps` compares `/`-suffixed prefixes, so Windows backslashes + * would silently push dev-area manifests into the runtime tier. + */ function loadWorkspaceManifests(): { manifests: Map; names: Set } { const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml'), workspaceMembers('native/landlock-run/pnpm-workspace.yaml')) const manifests = new Map() const names = new Set() for (const pattern of patterns) { for (const path of globSync(pattern, { cwd: root })) { - const manifest = readManifest(path) - manifests.set(path, manifest) + const normalized = path.replaceAll('\\', '/') + const manifest = readManifest(normalized) + manifests.set(normalized, manifest) if (manifest.name !== undefined) names.add(manifest.name) } } From 36700f69169d4ea7d557c634f62581c8d852782d Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 19:17:59 +0800 Subject: [PATCH 04/11] docs(note): record the notices-generator fixes behind the new dependency --- .../architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml | 4 ++-- .../architecture/2026-08-01-packaged-ripgrep-search.md | 2 ++ .../architecture/2026-08-01-packaged-ripgrep-search.zh.md | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml index fe6bb60e68..df6948209a 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.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/architecture/2026-08-01-packaged-ripgrep-search.md -2026-08-01-packaged-ripgrep-search.md: e43354ff8e4dde0480a6c07816fc112810197234 -2026-08-01-packaged-ripgrep-search.zh.md: 55498d366a2171a178cf9d0d1004b6fe946a7281 +2026-08-01-packaged-ripgrep-search.md: 0470623163ff7686bdd77ca715bac68c9a466d2b +2026-08-01-packaged-ripgrep-search.zh.md: fe53643aff139615d49edd2e3b27d14428cd2029 diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md index e43354ff8e..0470623163 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md @@ -32,3 +32,5 @@ The `fs-glob-sampling` ACP snapshot scenario now executes the real packaged bina - The shell-string attack surface is gone: hostile patterns are inert argv elements, pinned by the integration suite, which now runs on Windows too (it previously self-skipped without a system `rg`). - Load-time failure modes changed: a broken subprocess seam now fails the first search call (`SEARCH_FAILED`) instead of failing plugin load through the probe; a missing binary is a launch failure with the packaged path, not a PATH problem. - The integration suite's fixture dropped a filename Windows cannot represent (`"` in a name), keeping the suite replayable on every platform. +- Regenerating `THIRD_PARTY_NOTICES.md` surfaced a latent generator bug the new dependency made visible: Node's `fs.globSync` returns OS-native separators, so on Windows the `/`-suffixed dev-area prefixes in the notices tiering never matched and dev-only packages (test tooling, support leaves) were mis-tiered as runtime. The generator now normalizes manifest paths at ingestion, and the notices are platform-independent. +- The `@vscode/ripgrep` dependency adds its MIT row to the runtime tier, and pnpm 11's truncated virtual-store directory names needed a content-scan fallback in the notices generator's metadata lookup. diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md index 55498d366a..fe53643aff 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md @@ -32,3 +32,5 @@ Status: implemented - shell 字符串攻击面消失:恶意模式只是惰性 argv 元素,由集成套件钉住;该套件现在也在 Windows 上运行(此前没有系统 `rg` 时它自行跳过)。 - 加载期失败模式改变:subprocess seam 损坏现在让首次搜索调用失败(`SEARCH_FAILED`),而非通过探针使插件加载失败;二进制缺失是带打包路径的启动失败,而不是 PATH 问题。 - 集成套件的 fixture 去掉了 Windows 无法表示的文件名(名称含 `"`),保证套件在每个平台都能重放。 +- 重新生成 `THIRD_PARTY_NOTICES.md` 暴露了一个由新依赖带出的潜在生成器 bug:Node 的 `fs.globSync` 返回操作系统原生分隔符,因此在 Windows 上 notices 分层中带 `/` 后缀的 dev 区前缀永远匹配不上,dev-only 包(测试工具、support 叶子)被错分为 runtime。生成器现在在入口处归一化清单路径,notices 与平台无关。 +- `@vscode/ripgrep` 依赖为 runtime 层增加其 MIT 行;pnpm 11 截断的虚拟存储目录名需要在 notices 生成器的元数据查找中增加内容扫描回退。 From a9871d4af1760d7564c934185fac19f426c3aae5 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 21:56:16 +0800 Subject: [PATCH 05/11] refactor(fs-search): apply #1119 review fixes to the packaged-rg spawn - delete the singleQuote shell-quoting helper and its bash-spawning tests (no in-repo consumers; no shell layer exists anymore) - drop spill from both collect streams: the tool never reads a raw spill path, and a lossy stdout read is a pure SEARCH_RAW_OUTPUT_OVERFLOW error - prepend --no-config so a host RIPGREP_CONFIG_PATH cannot inject a --pre preprocessor into the unconfined spawn - promote graceMs and stderrMaxBytes to validated Config fields (defaults SEARCH_GRACE_MS / SEARCH_STDERR_MAX_BYTES) instead of inheriting bash-local's config - correct the grep tool's JSDoc seam reference (bash -> subprocess) - drop the dead exit-127/command-not-found classification branch --- ...26-08-01-packaged-ripgrep-search.i18n.yaml | 4 +- .../2026-08-01-packaged-ripgrep-search.md | 4 +- .../2026-08-01-packaged-ripgrep-search.zh.md | 4 +- docs/config-catalog.md | 6 +- packages/fs/tool-fs-search/README.i18n.yaml | 4 +- packages/fs/tool-fs-search/README.md | 6 +- packages/fs/tool-fs-search/README.zh.md | 6 +- packages/fs/tool-fs-search/src/glob.ts | 6 +- packages/fs/tool-fs-search/src/grep.ts | 8 ++- packages/fs/tool-fs-search/src/index.ts | 17 +++++- packages/fs/tool-fs-search/src/search-core.ts | 40 ++++++++----- packages/fs/tool-fs-search/src/shell-quote.ts | 24 -------- .../tool-fs-search/tests/shell-quote.spec.ts | 59 ------------------- .../fs/tool-fs-search/tests/tools.spec.ts | 44 +++++++------- 14 files changed, 97 insertions(+), 135 deletions(-) delete mode 100644 packages/fs/tool-fs-search/src/shell-quote.ts delete mode 100644 packages/fs/tool-fs-search/tests/shell-quote.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml index df6948209a..3647587fa5 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.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/architecture/2026-08-01-packaged-ripgrep-search.md -2026-08-01-packaged-ripgrep-search.md: 0470623163ff7686bdd77ca715bac68c9a466d2b -2026-08-01-packaged-ripgrep-search.zh.md: fe53643aff139615d49edd2e3b27d14428cd2029 +2026-08-01-packaged-ripgrep-search.md: 849cc0804a2081492297649ac8f13236e2ac60fe +2026-08-01-packaged-ripgrep-search.zh.md: 4381c3a8bca8e6bb9ab375ad352fec5a5ddb9f99 diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md index 0470623163..849cc0804a 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md @@ -12,7 +12,7 @@ The `glob`/`grep` tools ran through the bash executor seam, which made a system ## Decision -`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. There is no shell layer, so the shell-quoting boundary is gone from execution; `singleQuote` stays exported as a compatibility surface with its tests. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. +`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector prefixed by `--no-config`, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. There is no shell layer, so the shell-quoting boundary is gone from execution; the `singleQuote` helper and its shell-spawning tests are deleted with it. The raw streams request the seam's diagnostic-tail collect shape (no spill files — the tool never reads a raw spill path; a lossy stdout read fails as `SEARCH_RAW_OUTPUT_OVERFLOW`). The terminate grace and the stderr tail budget are validated `Config` fields (`graceMs` default 3000, `stderrMaxBytes` default 64 KiB), no longer inherited from bash-local's config. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. Exit semantics stay tool-owned: exit 0 is success with results, exit 1 is a successful empty search, anything else classifies into the existing `SEARCH_*` vocabulary (invalid pattern, launch failure, signal kill, raw-output overflow). Timeout is the cooperative tool-call budget attached to the tool definitions: `@deepseek-ai/dsh-timeout-policy` aborts `exec.signal`, the subprocess seam's terminate escalation provides the hard kill, and the tool reports `SEARCH_ABORTED`. The working directory is the session header cwd when present, else `process.cwd()` — there is no executor config to default through anymore, so the tool owns the fallback. @@ -30,6 +30,8 @@ The `fs-glob-sampling` ACP snapshot scenario now executes the real packaged bina - The discovery tools work on every platform the packaged binary covers (darwin/linux/win32, x64/arm64) with no host install; the shipped TUI/Web rosters gain `glob`/`grep` as fixed members ([even-out-shipped-tool-rosters](../feature/2026-07-31-even-out-shipped-tool-rosters.md)). - The shell-string attack surface is gone: hostile patterns are inert argv elements, pinned by the integration suite, which now runs on Windows too (it previously self-skipped without a system `rg`). +- The spawn is unconfined (a plain `ctx.subprocess` call), so `--no-config` is prepended: a host `RIPGREP_CONFIG_PATH` (or an `rg.conf` beside the binary) can otherwise inject a `--pre` preprocessor that executes an arbitrary command for every matched file. With `--no-config`, no config file — and therefore no preprocessor — can reach the search. +- The raw-output overflow path changed shape: the old bash-backed route inherited bash-local's always-on spill and could leave an unread multi-megabyte temp file; the subprocess seam now collects without spill, and overflow is a pure error (`SEARCH_RAW_OUTPUT_OVERFLOW`, "narrow pattern, path, or include and retry") with zero content returned. - Load-time failure modes changed: a broken subprocess seam now fails the first search call (`SEARCH_FAILED`) instead of failing plugin load through the probe; a missing binary is a launch failure with the packaged path, not a PATH problem. - The integration suite's fixture dropped a filename Windows cannot represent (`"` in a name), keeping the suite replayable on every platform. - Regenerating `THIRD_PARTY_NOTICES.md` surfaced a latent generator bug the new dependency made visible: Node's `fs.globSync` returns OS-native separators, so on Windows the `/`-suffixed dev-area prefixes in the notices tiering never matched and dev-only packages (test tooling, support leaves) were mis-tiered as runtime. The generator now normalizes manifest paths at ingestion, and the notices are platform-independent. diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md index fe53643aff..4381c3a8bc 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 作为兼容导出与其测试保留。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 +`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,向量前缀 `--no-config`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 工具与其 shell spawn 测试一并删除。原始流使用 seam 的诊断尾部 collect 形态(无 spill 文件——工具从不读取原始 spill 路径;lossy stdout 读取以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败)。终止宽限与 stderr 尾部预算成为经校验的 `Config` 字段(`graceMs` 默认 3000,`stderrMaxBytes` 默认 64 KiB),不再继承自 bash-local 的配置。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 退出语义仍由工具拥有:退出码 0 为有结果的成功,1 为成功的空搜索,其余归入既有 `SEARCH_*` 词汇(无效模式、启动失败、信号杀死、原始输出溢出)。超时是挂在工具定义上的协作式工具调用预算:`@deepseek-ai/dsh-timeout-policy` 中止 `exec.signal`,subprocess seam 的终止升级提供硬终止,工具报告 `SEARCH_ABORTED`。工作目录为会话 header cwd(存在时),否则为 `process.cwd()`——不再有执行器配置可供默认化,因此回退由工具自己拥有。 @@ -30,6 +30,8 @@ Status: implemented - 发现工具在打包二进制覆盖的每个平台(darwin/linux/win32,x64/arm64)上开箱即用,无需宿主安装;交付的 TUI/Web 工具清单把 `glob`/`grep` 变为固定成员(见 [拉平交付的工具清单](../feature/2026-07-31-even-out-shipped-tool-rosters.md))。 - shell 字符串攻击面消失:恶意模式只是惰性 argv 元素,由集成套件钉住;该套件现在也在 Windows 上运行(此前没有系统 `rg` 时它自行跳过)。 +- spawn 不受沙箱约束(普通的 `ctx.subprocess` 调用),因此前缀 `--no-config`:宿主的 `RIPGREP_CONFIG_PATH`(或二进制旁的 `rg.conf`)否则可注入 `--pre` 预处理器,对每个匹配文件执行任意命令。加上 `--no-config` 后,任何配置文件——因而任何预处理器——都无法触及搜索。 +- 原始输出溢出路径的形态改变:旧的 bash 承载路径继承了 bash-local 常开的 spill,可能留下没人读的多 MB 临时文件;subprocess seam 现在无 spill 收集,溢出是纯粹的错误(`SEARCH_RAW_OUTPUT_OVERFLOW`,"narrow pattern, path, or include and retry"),不返回任何内容。 - 加载期失败模式改变:subprocess seam 损坏现在让首次搜索调用失败(`SEARCH_FAILED`),而非通过探针使插件加载失败;二进制缺失是带打包路径的启动失败,而不是 PATH 问题。 - 集成套件的 fixture 去掉了 Windows 无法表示的文件名(名称含 `"`),保证套件在每个平台都能重放。 - 重新生成 `THIRD_PARTY_NOTICES.md` 暴露了一个由新依赖带出的潜在生成器 bug:Node 的 `fs.globSync` 返回操作系统原生分隔符,因此在 Windows 上 notices 分层中带 `/` 后缀的 dev 区前缀永远匹配不上,dev-only 包(测试工具、support 叶子)被错分为 runtime。生成器现在在入口处归一化清单路径,notices 与平台无关。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 20f835bb7f..1151f0ff0d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1733,12 +1733,16 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number + /** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */ + graceMs?: number + /** Max bytes retained for one search's stderr diagnostic tail (never surfaced to the model). */ + stderrMaxBytes?: number /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */ timeoutMs?: number } ``` -Source: [`packages/fs/tool-fs-search/src/index.ts:70`](../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/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index 5ed7bfb8bb..a8e2222998 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/tool-fs-search/README.md -README.md: 0152be017ae15fc83a3d5cb7df927f25d04d2d53 -README.zh.md: 69ce49f3ad1621021dc1d0938cdc07a900d1cda8 +README.md: 78ffa069e56da5fc987913acf761eb5c6ae15b1a +README.zh.md: 42b123d5c47d8f48bc21b6f9bed4905372ca8625 diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 0152be017a..78ffa069e5 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **packaged ripgrep binary** (`@vscode/ripgrep`), not by `ctx.fs` provider methods and not by a system `rg` install. Registration is unconditional: the binary ships inside the npm dependency, so there is no load-time availability probe. Each call spawns the binary through the `ctx.subprocess` seam with a fixed argv vector (model-controlled values are plain argv elements — no shell layer exists, so no quoting applies), parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `subprocess`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. +The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **packaged ripgrep binary** (`@vscode/ripgrep`), not by `ctx.fs` provider methods and not by a system `rg` install. Registration is unconditional: the binary ships inside the npm dependency, so there is no load-time availability probe. Each call spawns the binary through the `ctx.subprocess` seam with a fixed argv vector (`--no-config` prepended so a host `RIPGREP_CONFIG_PATH` cannot inject a `--pre` preprocessor into the unconfined spawn; model-controlled values are plain argv elements — no shell layer exists, so no quoting applies), parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `subprocess`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. ```ts ignore-check // A deployment chooses how over-cap glob pages are selected. @@ -30,6 +30,8 @@ The binary ships with the package on every supported platform (macOS/Linux/Windo | `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. | | `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. | | `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the subprocess seam's terminate escalation is the hard kill. | +| `graceMs` | `3000` | Terminate-escalation grace period the subprocess seam grants past `timeoutMs` before the search fails as `SEARCH_ABORTED`. | +| `stderrMaxBytes` | `65536` | Diagnostic-tail budget for `rg` stderr, captured through the subprocess seam's collect disposition; a lossy read keeps only the tail (marked `[stderr truncated]`). | ## Tools @@ -42,7 +44,7 @@ Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`c ## Two budgets, two artifacts -Raw `rg` stdout is an internal transport detail. Each search requests a collect-mode stdout budget of `rawOutputMaxBytes` from the subprocess seam and parses only complete retained stdout; if the seam still reports a lossy read, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`. +Raw `rg` stdout and stderr are internal transport details. Each search requests collect-mode budgets from the subprocess seam — complete stdout within `rawOutputMaxBytes` and a `stderrMaxBytes` diagnostic tail — with no spill files on either stream (the tool never reads a raw spill path). If the seam still reports a lossy stdout read, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query; a lossy stderr read only marks the diagnostic excerpt `[stderr truncated]`. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`. ## Errors diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 69ce49f3ad..42b123d5c4 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**面向模型的文件系统发现工具**(`glob`、`grep`)由 **打包的 ripgrep 二进制**(`@vscode/ripgrep`)支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。注册是无条件的:二进制随 npm 依赖一起交付,因此没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 该二进制(模型控制的值是普通 argv 元素——不存在 shell 层,因此无需引号),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 +**面向模型的文件系统发现工具**(`glob`、`grep`)由 **打包的 ripgrep 二进制**(`@vscode/ripgrep`)支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。注册是无条件的:二进制随 npm 依赖一起交付,因此没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 该二进制(前缀 `--no-config`,使宿主的 `RIPGREP_CONFIG_PATH` 无法向不受约束的 spawn 注入 `--pre` 预处理器;模型控制的值是普通 argv 元素——不存在 shell 层,因此无需引号),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 ```ts ignore-check // A deployment chooses how over-cap glob pages are selected. @@ -30,6 +30,8 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- | `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 | | `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 | | `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;subprocess seam 的终止升级提供硬终止。 | +| `graceMs` | `3000` | subprocess seam 在 `timeoutMs` 之外授予的终止升级宽限期;超过后搜索以 `SEARCH_ABORTED` 失败。 | +| `stderrMaxBytes` | `65536` | `rg` stderr 的诊断尾部预算,经 subprocess seam 的 collect 形态捕获;lossy 读取只保留尾部(标记 `[stderr truncated]`)。 | ## 工具 @@ -42,7 +44,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- ## 两类预算、两类产物 -原始 `rg` stdout 是内部传输细节。每次搜索从 subprocess seam 请求 `rawOutputMaxBytes` 的 collect 模式 stdout 预算,且只解析完整保留的 stdout;如果 seam 仍报告 lossy 读取,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。 +原始 `rg` stdout 与 stderr 是内部传输细节。每次搜索从 subprocess seam 请求 collect 模式预算——`rawOutputMaxBytes` 内的完整 stdout 与 `stderrMaxBytes` 的诊断尾部——两条流都不产生 spill 文件(工具从不读取原始 spill 路径)。如果 seam 仍报告 lossy stdout 读取,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询;lossy stderr 读取只把诊断摘录标记为 `[stderr truncated]`。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。 ## 错误 diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 6e7d411a01..3eeea9c75c 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -47,6 +47,10 @@ export interface GlobToolCaps { maxMetaBytes: number /** Cap on the complete raw `rg` stdout the tool will parse. */ rawOutputMaxBytes: number + /** Terminate-escalation grace period (ms) for the search process. */ + graceMs: number + /** Cap on the retained stderr diagnostic tail. */ + stderrMaxBytes: number /** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */ timeoutMs: number } @@ -337,7 +341,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { }, async execute(args, exec) { const input = parseGlobArgs(args) - const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes) + const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes, caps.graceMs, caps.stderrMaxBytes) const root = input.path === undefined ? '.' : toWorkdirRelative(input.path, run.workdir) if (run.noMatches) return { root, paths: [] } diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index 03b49f01e8..49548499ac 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -45,6 +45,10 @@ export interface GrepToolCaps { maxMetaBytes: number /** Cap on the complete raw `rg` stdout the tool will parse. */ rawOutputMaxBytes: number + /** Terminate-escalation grace period (ms) for the search process. */ + graceMs: number + /** Cap on the retained stderr diagnostic tail. */ + stderrMaxBytes: number /** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */ timeoutMs: number } @@ -265,7 +269,7 @@ export function presentGrepResult( * Register the `grep` tool and its system-prompt guidance. * * @param ctx - the plugin context; registrations are effects scoped to it, and - * execution uses its `bash` service. + * execution uses its `subprocess` service. * @param caps - the deployment's resolved grep caps (plugin config after defaulting). */ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { @@ -315,7 +319,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { }, async execute(args, exec) { const input = parseGrepArgs(args) - const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes) + const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes, caps.graceMs, caps.stderrMaxBytes) if (run.noMatches) return { matches: [] } const all: GrepMatch[] = [] diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index e596ae3ca1..9a4042dde1 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -30,7 +30,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_META_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts' +import { RAW_OUTPUT_MAX_BYTES, SEARCH_GRACE_MS, SEARCH_META_MAX_BYTES, SEARCH_STDERR_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts' export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, presentGlobResult, sampleAcrossTopLevel } from './glob.ts' export type { GlobInput, GlobSample, GlobToolCaps } from './glob.ts' @@ -49,7 +49,9 @@ export { export type { GrepInput, GrepToolCaps } from './grep.ts' export { RAW_OUTPUT_MAX_BYTES, + SEARCH_GRACE_MS, SEARCH_META_MAX_BYTES, + SEARCH_STDERR_MAX_BYTES, SEARCH_TIMEOUT_MS, SearchError, previewLine, @@ -58,7 +60,6 @@ export { 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. */ export const name = 'tool-fs-search' @@ -80,6 +81,10 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number + /** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */ + graceMs?: number + /** Max bytes retained for one search's stderr diagnostic tail (never surfaced to the model). */ + stderrMaxBytes?: number /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */ timeoutMs?: number } @@ -91,6 +96,8 @@ export const Config: z = z.object({ grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES), searchMetaMaxBytes: z.number().default(SEARCH_META_MAX_BYTES), rawOutputMaxBytes: z.number().default(RAW_OUTPUT_MAX_BYTES), + graceMs: z.number().default(SEARCH_GRACE_MS), + stderrMaxBytes: z.number().default(SEARCH_STDERR_MAX_BYTES), timeoutMs: z.number().default(SEARCH_TIMEOUT_MS), }) @@ -121,12 +128,16 @@ export async function apply(ctx: Context, config: Config): Promise { assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes) assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes) assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes) + assertPositiveInteger('graceMs', resolved.graceMs) + assertPositiveInteger('stderrMaxBytes', resolved.stderrMaxBytes) assertPositiveInteger('timeoutMs', resolved.timeoutMs) applyGlobTool(ctx, { sampleOverCapGlobResults: resolved.sampleOverCapGlobResults, maxResults: resolved.globMaxResults, maxMetaBytes: resolved.searchMetaMaxBytes, rawOutputMaxBytes: resolved.rawOutputMaxBytes, + graceMs: resolved.graceMs, + stderrMaxBytes: resolved.stderrMaxBytes, timeoutMs: resolved.timeoutMs, }) applyGrepTool(ctx, { @@ -134,6 +145,8 @@ export async function apply(ctx: Context, config: Config): Promise { maxLineBytes: resolved.grepMaxLineBytes, maxMetaBytes: resolved.searchMetaMaxBytes, rawOutputMaxBytes: resolved.rawOutputMaxBytes, + graceMs: resolved.graceMs, + stderrMaxBytes: resolved.stderrMaxBytes, timeoutMs: resolved.timeoutMs, }) } diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index c9f5f80b5b..9444b029cc 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -44,15 +44,13 @@ export const SEARCH_TIMEOUT_MS = 30_000 /** * Default cap in bytes on the retained stderr tail of one search run — a - * diagnostic excerpt only (the tool never reads `stderr.spillPath`). + * diagnostic excerpt only (the tool never reads a stderr spill path, and the + * collect disposition requests none). */ -const SEARCH_STDERR_MAX_BYTES = 64 * 1024 - -/** Default whole-stream spill cap for search output (the subprocess seam requires an explicit budget). */ -const SEARCH_SPILL_MAX_BYTES = 64 * 1024 * 1024 +export const SEARCH_STDERR_MAX_BYTES = 64 * 1024 /** Default terminate grace period for a search process (ms). */ -const SEARCH_GRACE_MS = 3_000 +export const SEARCH_GRACE_MS = 3_000 /** * Default cap in bytes on one search's serialized `presentationMeta` (the @@ -110,7 +108,7 @@ export interface RipgrepRun { /** * The retained stderr tail as a diagnostic excerpt, with a truncation note when - * the subprocess seam dropped bytes (the tool never reads `stderr.spillPath`). + * the subprocess seam dropped bytes. */ function stderrExcerpt(stderrText: string, truncated: boolean): string { const text = stderrText.trim() @@ -118,15 +116,16 @@ function stderrExcerpt(stderrText: string, truncated: boolean): string { return truncated ? `${text} [stderr truncated]` : text } -/** Classify a nonzero-exit `rg` run into the search error vocabulary (invalid pattern vs missing `rg` vs everything else). */ +/** + * Classify a nonzero-exit `rg` run into the search error vocabulary. There is + * no shell layer, so an exit 127 or shell "command not found" text cannot + * occur — a launch failure rejects at spawn (see {@link runRipgrep}). + */ function classifyRunFailure(toolName: string, exitCode: number, stderrText: string, stderrTruncated: boolean): SearchError { const stderr = stderrExcerpt(stderrText, stderrTruncated) if (/regex parse error|error parsing glob/i.test(stderr)) { return new SearchError(`${toolName} pattern rejected by ripgrep: ${stderr}`, 'SEARCH_INVALID_PATTERN') } - if (exitCode === 127 || /command not found/i.test(stderr)) { - return new SearchError(`${toolName} requires ripgrep (rg) to launch${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED') - } return new SearchError(`${toolName} search failed (exit ${exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED') } @@ -163,6 +162,13 @@ function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutpu * (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation terminate the * process tree. * + * The spawn is unconfined (a plain `ctx.subprocess` call), so `--no-config` + * is prepended: a host `RIPGREP_CONFIG_PATH` (or `rg.conf` next to the + * binary) can otherwise inject `--pre` and make ripgrep execute an arbitrary + * preprocessor for every matched file. The collect dispositions are the + * seam's diagnostic-tail shape (no spill files): the tools never read a raw + * spill path, and truncated stdout fails as `SEARCH_RAW_OUTPUT_OVERFLOW`. + * * Exit semantics are tool-owned: exit 0 is success with results, exit 1 is * success with zero results (`noMatches`), anything else throws a * {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern → @@ -176,6 +182,8 @@ function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutpu * @param toolName - `glob` or `grep`, used in error messages. * @param argv - the ripgrep arguments (every model value an unquoted argv element; no shell layer exists). * @param rawOutputMaxBytes - cap on the complete raw stdout the tool will parse. + * @param graceMs - the seam's terminate-escalation grace period. + * @param stderrMaxBytes - cap on the retained stderr diagnostic tail. * @returns the complete stdout, the zero-result flag, and the resolved workdir. */ export async function runRipgrep( @@ -184,6 +192,8 @@ export async function runRipgrep( toolName: string, argv: readonly string[], rawOutputMaxBytes: number, + graceMs: number, + stderrMaxBytes: number, ): Promise { if (exec.signal.aborted) { throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED') @@ -191,16 +201,16 @@ export async function runRipgrep( const cwd = exec.agent?.session.header.cwd const workdir = cwd ?? process.cwd() const collect = (maxBytes: number): SubprocessCollect => - ({ maxBytes, spill: { maxBytes: SEARCH_SPILL_MAX_BYTES } }) + ({ maxBytes }) const handle = ctx.subprocess.spawn({ - argv: [rgPath, ...argv], + argv: [rgPath, '--no-config', ...argv], cwd: workdir, stdio: { stdin: 'ignore', stdout: collect(rawOutputMaxBytes), - stderr: collect(SEARCH_STDERR_MAX_BYTES), + stderr: collect(stderrMaxBytes), }, - graceMs: SEARCH_GRACE_MS, + graceMs, signal: exec.signal, } satisfies SubprocessSpawnSpec) let outcome: SubprocessOutcome diff --git a/packages/fs/tool-fs-search/src/shell-quote.ts b/packages/fs/tool-fs-search/src/shell-quote.ts deleted file mode 100644 index ea67abf449..0000000000 --- a/packages/fs/tool-fs-search/src/shell-quote.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * POSIX single-quoting helper retained for compatibility with older - * deployments and tests. The current `glob`/`grep` command builders spawn the - * packaged ripgrep binary with a plain argv vector — no shell layer exists — - * so no quoting is involved; this module is kept because its export is part - * of the package surface. - * - * @module @deepseek-ai/dsh-tool-fs-search/shell-quote - */ - -/** - * POSIX single-quote a string for safe use as ONE shell word. Wraps the value - * in single quotes and rewrites every embedded single quote as `'\''` (close - * quote, an escaped literal quote, reopen quote). Inside single quotes the shell - * treats every other byte literally — spaces, newlines, `$`, backticks, `;`, - * `|`, `&`, glob metacharacters, and a leading `-` are all inert — so the result - * is a single, injection-safe argument regardless of the input. - * - * @param value - the raw, possibly model-controlled string to quote. - * @returns the value wrapped as one safe single-quoted shell word. - */ -export function singleQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'` -} diff --git a/packages/fs/tool-fs-search/tests/shell-quote.spec.ts b/packages/fs/tool-fs-search/tests/shell-quote.spec.ts deleted file mode 100644 index 84c8506be1..0000000000 --- a/packages/fs/tool-fs-search/tests/shell-quote.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Unit tests for the shell-quoting safety boundary, plus a REAL round-trip: - * every adversarial value, quoted, must survive `bash -c "printf '%s' "` - * byte-for-byte — proving the quoting is inert in an actual shell, not just - * against a mental model of one. - */ - -import { describe, expect, it } from 'vitest' -import { spawnSync } from 'node:child_process' -import { singleQuote } from '@deepseek-ai/dsh-tool-fs-search' - -/** Adversarial values a model could pass as pattern / path / include. */ -const HOSTILE: readonly string[] = [ - 'plain', - 'with spaces', - "it's got 'quotes'", - '"double quoted"', - '$(rm -rf /tmp/nope)', - '`touch /tmp/nope`', - '$HOME and ${PATH}', - 'semi;colon && chain || pipe | bg &', - 'newline\nin the middle', - '-leading-dash', - '--leading-double-dash', - '*?[a-z]{x,y}', - '!bang', - '\\backslash\\', - '~tilde', - '# not a comment', - '>redirect &1', -] - -describe('singleQuote', () => { - it('wraps a plain value in single quotes', () => { - expect(singleQuote('abc')).toBe("'abc'") - }) - - it("rewrites embedded single quotes as '\\''", () => { - expect(singleQuote("a'b")).toBe("'a'\\''b'") - expect(singleQuote("''")).toBe("''\\'''\\'''") - }) - - it.each(HOSTILE.map(value => [JSON.stringify(value), value] as const))( - 'round-trips %s through a real bash -c unchanged', - (_label, value) => { - const result = spawnSync('bash', ['-c', `printf '%s' ${singleQuote(value)}`], { encoding: 'utf8' }) - expect(result.status).toBe(0) - expect(result.stdout).toBe(value) - }, - ) - - it('a quoted command substitution does not execute (the world stays untouched)', () => { - const canary = `/tmp/dsh-quote-canary-${process.pid}` - const result = spawnSync('bash', ['-c', `printf '%s' ${singleQuote(`$(touch ${canary})`)}`], { encoding: 'utf8' }) - expect(result.stdout).toBe(`$(touch ${canary})`) - // The canary file must NOT exist — the substitution stayed literal. - expect(spawnSync('test', ['-e', canary]).status).not.toBe(0) - }) -}) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index d786fa3181..5c6713a7f0 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -291,6 +291,8 @@ describe('config validation', () => { ['grepMaxMatches', { grepMaxMatches: -1 }], ['grepMaxLineBytes', { grepMaxLineBytes: 1.5 }], ['rawOutputMaxBytes', { rawOutputMaxBytes: 0 }], + ['graceMs', { graceMs: 0 }], + ['stderrMaxBytes', { stderrMaxBytes: -1 }], ['timeoutMs', { timeoutMs: -100 }], ] as const)('rejects a non-positive or fractional %s at load', async (name, config) => { const ctx = new Context() @@ -372,16 +374,30 @@ describe('workdir derivation and signal forwarding', () => { expect(subprocess.spawns[1]?.cwd).toBe(process.cwd()) }) - it('spawns the packaged ripgrep binary with the fixed argv and budgeted collect streams', async () => { - const { ctx, subprocess } = await setup({ config: { rawOutputMaxBytes: 1234 } }) + it('spawns the packaged ripgrep binary with --no-config, the fixed argv, and budgeted collect streams', async () => { + const { ctx, subprocess } = await setup({ + config: { rawOutputMaxBytes: 1234, graceMs: 5000, stderrMaxBytes: 4096 }, + }) subprocess.handler = () => runResult('', { exitCode: 1 }) await call(ctx, 'grep', { pattern: 'needle' }) const spec = subprocess.spawns[0] - expect(spec?.argv[0]).toBe(rgPath) - expect(spec?.argv).toEqual([rgPath, '--json', '--regexp=needle']) + // --no-config keeps a host RIPGREP_CONFIG_PATH from injecting a + // preprocessor into this unconfined spawn. + expect(spec?.argv).toEqual([rgPath, '--no-config', '--json', '--regexp=needle']) expect(spec?.stdio.stdin).toBe('ignore') - // stdout gets the tool's parse budget; stderr is a diagnostic excerpt. + // stdout gets the tool's parse budget; stderr is a diagnostic excerpt; + // both are the seam's diagnostic-tail shape (no spill files requested). expect((spec?.stdio.stdout as { maxBytes: number }).maxBytes).toBe(1234) + expect((spec?.stdio.stderr as { maxBytes: number }).maxBytes).toBe(4096) + expect(spec?.graceMs).toBe(5_000) + }) + + it('defaults the stderr tail budget and grace period when the config omits them', async () => { + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 1 }) + await call(ctx, 'grep', { pattern: 'needle' }) + const spec = subprocess.spawns[0] + expect((spec?.stdio.stderr as { maxBytes: number }).maxBytes).toBe(64 * 1024) expect(spec?.graceMs).toBe(3_000) }) @@ -430,7 +446,7 @@ describe('workdir derivation and signal forwarding', () => { const controller = new AbortController() controller.abort() const exec = { signal: controller.signal, name: 'glob', callId: CallId('direct-pre-abort') } as unknown as ToolExecution - await expect(runRipgrep(ctx, exec, 'glob', ['--files'], 1_000_000)).rejects + await expect(runRipgrep(ctx, exec, 'glob', ['--files'], 1_000_000, 3_000, 64 * 1024)).rejects .toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) }) @@ -489,20 +505,6 @@ describe('exit semantics and failure classification', () => { expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } }) }) - it('a failed ripgrep launch classifies as SEARCH_FAILED naming ripgrep', async () => { - const { ctx, subprocess } = await setup() - subprocess.handler = () => runResult('', { exitCode: 127, stderr: { text: 'sh: rg: command not found' } }) - const result = await call(ctx, 'glob', { pattern: '*' }) - expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) - expect(text(result)).toContain('requires ripgrep (rg)') - // The same classification holds from either evidence alone: the 127 exit - // with silent stderr, or a shell's command-not-found text on another exit. - subprocess.handler = () => runResult('', { exitCode: 127 }) - expect(text(await call(ctx, 'glob', { pattern: '*' }))).toContain('requires ripgrep (rg)') - subprocess.handler = () => runResult('', { exitCode: 2, stderr: { text: 'sh: rg: command not found' } }) - expect(text(await call(ctx, 'grep', { pattern: 'x' }))).toContain('requires ripgrep (rg)') - }) - it('other nonzero exits are SEARCH_FAILED carrying the stderr excerpt', async () => { const { ctx, subprocess } = await setup() subprocess.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: missing.dir: IO error: no such file or directory' } }) @@ -682,7 +684,7 @@ describe('glob results', () => { subprocess.handler = () => runResult('sub/a.ts\n') const result = await call(ctx, 'glob', { pattern: '*.ts', path: 'sub' }) expect(result.isError).toBe(false) - expect(subprocess.spawns[0]?.argv).toEqual([rgPath, '--files', '--glob=*.ts', '--sort=modified', '--no-ignore', '--hidden', + expect(subprocess.spawns[0]?.argv).toEqual([rgPath, '--no-config', '--files', '--glob=*.ts', '--sort=modified', '--no-ignore', '--hidden', '--glob=!**/.git', '--glob=!**/.git/**', '--glob=!**/.svn', '--glob=!**/.svn/**', '--glob=!**/.hg', '--glob=!**/.hg/**', From d22438e2b19ead2b14fae5bed5c788d2f9a5f036 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 21:56:32 +0800 Subject: [PATCH 06/11] test(fs-search): re-record the glob-sampling snapshot against the real API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scenario previously carried an authored fixture; W4 of #1119 review requires a live transcript. Recording surfaced two composition bugs that are fixed here alongside it: - provider ids: the app and the replay catalog both named the old 'deepseek' provider, which no adapter registers; both now use 'deepseek-official' - the live config lacked persistenceCompression: none, so record-mode sessions were written zstd-compressed and could not be harvested (the snapshot twin already forced plaintext) Recorded logs also need deterministic replay: - packChunks: false in both configs — the eager-drain batch boundaries that split packed delta runs are timing-dependent, so a packed log of a long reasoning stream cannot replay-match its live record - the fixture's request/header config and request/context are normalized to the replay-produced minimal shape (the live adapter logs model capabilities llm-replay has no data for), and tool-result path separators are canonicalized to '/' for the Linux golden posixOnly is restored now that the fixture is recorded. --- examples/acp-agent/tests/acp.snapshot.ts | 13 +- .../tests/fs-search.cordis.snapshot.yml | 8 +- examples/acp-agent/tests/fs-search.cordis.yml | 7 +- .../snapshots/fs-glob-sampling/session.jsonl | 145 +++++++++++++++--- 4 files changed, 142 insertions(+), 31 deletions(-) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 277d0b20ca..abdf10e367 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -181,16 +181,23 @@ const SCENARIOS: Scenario[] = [ // `--sort=modified` order, pinning over-cap glob sampling without depending // on a host-installed ripgrep binary or a PATH stand-in. POSIX-only because // the displayed paths carry `/` separators the session-log comparison - // cannot normalize. + // cannot normalize. Recorded (not authored): the assistant turn is a real + // model transcript; re-record with `test:snapshot:record -t fs-glob-sampling`. + // The composition disables packed chunk rows (fs-search.cordis.yml), whose + // run boundaries depend on eager-drain timing, and the recorded fixture's + // `request/header` config and `request/context` are normalized to the + // replay-produced minimal shape (the live adapter logs model capabilities + // like maxTokens/reasoningEffort that llm-replay has no data for), and its + // tool-result paths are canonicalized to `/` separators. { name: 'fs-glob-sampling', hasModelTurn: true, - recorded: false, + recorded: true, + posixOnly: true, pinsHeader: true, headerClass: 'fs-search', configPath: FS_SEARCH_CONFIG, prepareWorkspace: prepareFsSearchWorkspace, - posixOnly: true, }, { name: 'fs-read', hasModelTurn: true, recorded: true }, { name: 'fs-write', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/fs-search.cordis.snapshot.yml b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml index 141691a087..5fcb2248f3 100644 --- a/examples/acp-agent/tests/fs-search.cordis.snapshot.yml +++ b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml @@ -3,7 +3,7 @@ name: '@deepseek-ai/dsh-llm-replay' config: providers: - - id: deepseek + - id: deepseek-official name: DeepSeek models: - id: deepseek-v4-pro @@ -17,10 +17,14 @@ - id: acp-agent name: '@deepseek-ai/dsh-acp-demo' config: - provider: deepseek + provider: deepseek-official model: deepseek-v4-pro persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none + # Unpacked rows: the eager-drain batch boundaries that split packed delta + # runs are timing-dependent, so packed logs cannot replay-match a live + # record of a long reasoning stream. + packChunks: false workspaceContext: false skills: enabled: false diff --git a/examples/acp-agent/tests/fs-search.cordis.yml b/examples/acp-agent/tests/fs-search.cordis.yml index 153128f914..0f6d5d9a63 100644 --- a/examples/acp-agent/tests/fs-search.cordis.yml +++ b/examples/acp-agent/tests/fs-search.cordis.yml @@ -16,9 +16,14 @@ - id: acp-agent name: '@deepseek-ai/dsh-acp-demo' config: - provider: deepseek + provider: deepseek-official model: deepseek-v4-pro persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + # Unpacked rows: the eager-drain batch boundaries that split packed delta + # runs are timing-dependent, so packed logs cannot replay-match a live + # record of a long reasoning stream. + packChunks: false workspaceContext: false skills: enabled: false 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 ca51259632..f1c26ca911 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl @@ -1,25 +1,120 @@ -{"type":"session","version":0,"id":"f5a99d52-3eaa-4ce7-858d-61d4fd77df2a","createdAt":1785218400000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1785218400001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785218400002,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"6790985f-1de2-42f8-a7f1-24e46d6439c7"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785218400003,"data":{"title":"Call glob exactly once with","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1785218400004,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785218400005,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":5,"time":1785483397569,"data":{"provider":"deepseek","model":"deepseek-v4-pro"}} -{"type":"assistant/chunk","seq":6,"time":1785218400007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":7,"time":1785218400008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"glob-sampling-call","name":"glob","argumentsDelta":"{\"pattern\":\"*\",\"path\":\"tree\"}"}}} -{"type":"assistant/chunk","seq":8,"time":1785218400009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}}}} -{"type":"assistant/chunk","seq":9,"time":1785218400010,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} -{"type":"assistant/chunk","seq":10,"time":1785483397579,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":11,"time":1785483397579,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"a127cfe5-39fb-462c-8e5a-a8c79bd0e52b"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} -{"type":"tool/call","seq":12,"time":1785483397579,"data":{"turn":1,"step":1,"callId":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}} -{"type":"tool/result","seq":13,"time":1785483398062,"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":"tree/archive/a.ts\ntree/docs/guide.md\ntree/src/index.ts\ntree/test/spec.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":"2beecb2e-627d-43dc-a936-03e1dc874093"},"meta":{"shape":"paths","paths":["tree/archive/a.ts","tree/docs/guide.md","tree/src/index.ts","tree/test/spec.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[12],"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":1785483398062,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":15,"time":1785483398072,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":16,"time":1785218400017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":17,"time":1785218400018,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GLOB_SAMPLED"}}} -{"type":"assistant/chunk","seq":18,"time":1785218400019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GLOB_SAMPLED"}}}} -{"type":"assistant/chunk","seq":19,"time":1785218400020,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} -{"type":"assistant/chunk","seq":20,"time":1785483398078,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":21,"time":1785483398078,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GLOB_SAMPLED"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"ce2334a4-be71-490b-a502-29186a9ced5c"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} -{"type":"step/end","seq":22,"time":1785483398078,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":23,"time":1785483398079,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"4428b809-66d5-4ea2-9a03-89de742fcda1","createdAt":1785591986068,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785591986072,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785591986073,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"3d05fb76-4185-460b-9c6a-8c1b2495bc9f"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785591986074,"data":{"title":"Call glob exactly once with","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785591986092,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785591986093,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":5,"time":1785591986094,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":6,"time":1785591987500,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":7,"time":1785591987500,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":8,"time":1785591987529,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":9,"time":1785591987587,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":10,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":11,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":12,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":13,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" glob"}}} +{"type":"assistant/chunk","seq":14,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":15,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":16,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1785591987685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" pattern"}}} +{"type":"assistant/chunk","seq":18,"time":1785591987685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" *"}}} +{"type":"assistant/chunk","seq":19,"time":1785591987876,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" path"}}} +{"type":"assistant/chunk","seq":21,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tree"}}} +{"type":"assistant/chunk","seq":22,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":23,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":24,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":25,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":26,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":27,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":28,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"G"}}} +{"type":"assistant/chunk","seq":29,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LOB"}}} +{"type":"assistant/chunk","seq":30,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_S"}}} +{"type":"assistant/chunk","seq":31,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AM"}}} +{"type":"assistant/chunk","seq":32,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PL"}}} +{"type":"assistant/chunk","seq":33,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ED"}}} +{"type":"assistant/chunk","seq":34,"time":1785591987977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":35,"time":1785591988034,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1785591988035,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":37,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":38,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"pattern"}}} +{"type":"assistant/chunk","seq":40,"time":1785591988091,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":42,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":43,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"*"}}} +{"type":"assistant/chunk","seq":44,"time":1785591988193,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":45,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":46,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"path"}}} +{"type":"assistant/chunk","seq":48,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":50,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":51,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"tree"}}} +{"type":"assistant/chunk","seq":52,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1785591988338,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":54,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."}}}} +{"type":"assistant/chunk","seq":55,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}}}} +{"type":"assistant/chunk","seq":56,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1286,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":28}}}} +{"type":"assistant/chunk","seq":57,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":58,"time":1785591988430,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."},{"type":"tool-call","id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"b74cbab2-c017-4e44-8c09-a7745d8b274a"},"usage":{"inputTokens":1286,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57],"surfaceOp":"append"} +{"type":"tool/call","seq":59,"time":1785591988431,"data":{"turn":1,"step":1,"callId":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}} +{"type":"tool/result","seq":60,"time":1785591988476,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_1cLZjkCW0vxVw0e3xVfh3430"},"content":[{"type":"tool-result","toolCallId":"call_00_1cLZjkCW0vxVw0e3xVfh3430","content":[{"type":"text","text":"tree/archive/a.ts\ntree/docs/guide.md\ntree/src/index.ts\ntree/test/spec.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":"10284f88-4890-49ed-9a17-56edbd6bfaa7"},"meta":{"shape":"paths","paths":["tree/archive/a.ts","tree/docs/guide.md","tree/src/index.ts","tree/test/spec.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[59],"surfaceOp":"append"} +{"type":"step/end","seq":61,"time":1785591988476,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":62,"time":1785591988482,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":63,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":64,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":65,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" glob"}}} +{"type":"assistant/chunk","seq":66,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":67,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" shows"}}} +{"type":"assistant/chunk","seq":68,"time":1785591989988,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":69,"time":1785591990024,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":70,"time":1785591990127,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sampled"}}} +{"type":"assistant/chunk","seq":71,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":72,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":73,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":74,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":75,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":76,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}} +{"type":"assistant/chunk","seq":77,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" paths"}}} +{"type":"assistant/chunk","seq":78,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" across"}}} +{"type":"assistant/chunk","seq":79,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":80,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":81,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":82,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":83,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"6"}}} +{"type":"assistant/chunk","seq":84,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" top"}}} +{"type":"assistant/chunk","seq":85,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-level"}}} +{"type":"assistant/chunk","seq":86,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" entries"}}} +{"type":"assistant/chunk","seq":87,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":88,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":89,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":90,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":91,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":92,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":93,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":94,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":95,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"G"}}} +{"type":"assistant/chunk","seq":96,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"LOB"}}} +{"type":"assistant/chunk","seq":97,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_S"}}} +{"type":"assistant/chunk","seq":98,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AM"}}} +{"type":"assistant/chunk","seq":99,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PL"}}} +{"type":"assistant/chunk","seq":100,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ED"}}} +{"type":"assistant/chunk","seq":101,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":102,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":103,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":104,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":105,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":106,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"G"}}} +{"type":"assistant/chunk","seq":107,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"LOB"}}} +{"type":"assistant/chunk","seq":108,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_S"}}} +{"type":"assistant/chunk","seq":109,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"AM"}}} +{"type":"assistant/chunk","seq":110,"time":1785591990518,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PL"}}} +{"type":"assistant/chunk","seq":111,"time":1785591990518,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ED"}}} +{"type":"assistant/chunk","seq":112,"time":1785591990526,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."}}}} +{"type":"assistant/chunk","seq":113,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"GLOB_SAMPLED"}}}} +{"type":"assistant/chunk","seq":114,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":48,"cacheReadTokens":1280,"reasoningTokens":41}}}} +{"type":"assistant/chunk","seq":115,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":116,"time":1785591990527,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."},{"type":"text","text":"GLOB_SAMPLED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"dd3a9c28-43b2-4fdc-8089-1547309a71c0"},"usage":{"inputTokens":188,"outputTokens":48,"cacheReadTokens":1280,"reasoningTokens":41}},"sourceEventSeqs":[63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115],"surfaceOp":"append"} +{"type":"step/end","seq":117,"time":1785591990527,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":118,"time":1785591990528,"data":{"turn":1,"reason":{"kind":"completed"}}} From a27ca7b88f46d461a4549dba1d9d0272b785bd21 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 22:34:16 +0800 Subject: [PATCH 07/11] test(fs-search): keep the glob-sampling fixture in canonical packed layout The session-fixture-layout gate requires every session JSONL fixture in the canonical packed layout (maximal delta runs per kind/block, as migrate:packed-session-fixtures rewrites); the packChunks: false knob violated that invariant and failed the snapshot job. Revert the knob and canonicalize the recorded fixture instead: the live log's eager-drain-packed rows migrate to the maximal-run layout a burst replay reproduces, so the fixture stays replay-deterministic and canonical. --- examples/acp-agent/tests/acp.snapshot.ts | 14 +-- .../tests/fs-search.cordis.snapshot.yml | 4 - examples/acp-agent/tests/fs-search.cordis.yml | 4 - .../snapshots/fs-glob-sampling/session.jsonl | 97 +------------------ 4 files changed, 11 insertions(+), 108 deletions(-) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index abdf10e367..326a7ee0c0 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -182,13 +182,13 @@ const SCENARIOS: Scenario[] = [ // on a host-installed ripgrep binary or a PATH stand-in. POSIX-only because // the displayed paths carry `/` separators the session-log comparison // cannot normalize. Recorded (not authored): the assistant turn is a real - // model transcript; re-record with `test:snapshot:record -t fs-glob-sampling`. - // The composition disables packed chunk rows (fs-search.cordis.yml), whose - // run boundaries depend on eager-drain timing, and the recorded fixture's - // `request/header` config and `request/context` are normalized to the - // replay-produced minimal shape (the live adapter logs model capabilities - // like maxTokens/reasoningEffort that llm-replay has no data for), and its - // tool-result paths are canonicalized to `/` separators. + // model transcript; re-record with `test:snapshot:record -t fs-glob-sampling` + // and then `migrate:packed-session-fixtures`, which canonicalizes the live + // log's eager-drain-packed rows into the maximal-run layout replay produces. + // The recorded fixture's `request/header` config and `request/context` are + // normalized to the replay-produced minimal shape (the live adapter logs + // model capabilities like maxTokens/reasoningEffort that llm-replay has no + // data for), and its tool-result paths are canonicalized to `/` separators. { name: 'fs-glob-sampling', hasModelTurn: true, diff --git a/examples/acp-agent/tests/fs-search.cordis.snapshot.yml b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml index 5fcb2248f3..0db692f5bd 100644 --- a/examples/acp-agent/tests/fs-search.cordis.snapshot.yml +++ b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml @@ -21,10 +21,6 @@ model: deepseek-v4-pro persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none - # Unpacked rows: the eager-drain batch boundaries that split packed delta - # runs are timing-dependent, so packed logs cannot replay-match a live - # record of a long reasoning stream. - packChunks: false workspaceContext: false skills: enabled: false diff --git a/examples/acp-agent/tests/fs-search.cordis.yml b/examples/acp-agent/tests/fs-search.cordis.yml index 0f6d5d9a63..c86b34b8aa 100644 --- a/examples/acp-agent/tests/fs-search.cordis.yml +++ b/examples/acp-agent/tests/fs-search.cordis.yml @@ -20,10 +20,6 @@ model: deepseek-v4-pro persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" - # Unpacked rows: the eager-drain batch boundaries that split packed delta - # runs are timing-dependent, so packed logs cannot replay-match a live - # record of a long reasoning stream. - packChunks: false workspaceContext: false skills: enabled: false 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 f1c26ca911..d85c0ea447 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl @@ -6,53 +6,9 @@ {"type":"request/header","seq":4,"time":1785591986093,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785591986094,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} {"type":"assistant/chunk","seq":6,"time":1785591987500,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":7,"time":1785591987500,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":8,"time":1785591987529,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":9,"time":1785591987587,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":10,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":11,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":12,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} -{"type":"assistant/chunk","seq":13,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" glob"}}} -{"type":"assistant/chunk","seq":14,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":15,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} -{"type":"assistant/chunk","seq":16,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":17,"time":1785591987685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" pattern"}}} -{"type":"assistant/chunk","seq":18,"time":1785591987685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" *"}}} -{"type":"assistant/chunk","seq":19,"time":1785591987876,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":20,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" path"}}} -{"type":"assistant/chunk","seq":21,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tree"}}} -{"type":"assistant/chunk","seq":22,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":23,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":24,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":25,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":26,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":27,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":28,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"G"}}} -{"type":"assistant/chunk","seq":29,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LOB"}}} -{"type":"assistant/chunk","seq":30,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_S"}}} -{"type":"assistant/chunk","seq":31,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AM"}}} -{"type":"assistant/chunk","seq":32,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PL"}}} -{"type":"assistant/chunk","seq":33,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ED"}}} -{"type":"assistant/chunk","seq":34,"time":1785591987977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"reasoning-chunks","seq0":7,"time0":1785591987500,"data":{"turn":1,"step":1,"index":0,"dt":[29,58,1,0,0,0,51,0,0,46,0,191,1,0,0,0,0,0,0,0,1,0,0,0,0,0,99],"texts":["The"," user"," wants"," me"," to"," call"," glob"," exactly"," once"," with"," pattern"," *"," and"," path"," tree",","," then"," reply"," with"," exactly"," \"","G","LOB","_S","AM","PL","ED","\"."]}} {"type":"assistant/chunk","seq":35,"time":1785591988034,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":36,"time":1785591988035,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":37,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":38,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":39,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"pattern"}}} -{"type":"assistant/chunk","seq":40,"time":1785591988091,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":42,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"*"}}} -{"type":"assistant/chunk","seq":44,"time":1785591988193,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":46,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":47,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"path"}}} -{"type":"assistant/chunk","seq":48,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":49,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":50,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":51,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"tree"}}} -{"type":"assistant/chunk","seq":52,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":53,"time":1785591988338,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"}"}}} +{"type":"tool-call-chunks","seq0":36,"time0":1785591988035,"data":{"turn":1,"step":1,"index":1,"dt":[55,0,0,1,45,0,0,57,14,0,0,0,0,77,0,0,54],"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","args":["","{","\"","pattern","\"",": ","\"","*","\"",", ","\"","path","\"",": ","\"","tree","\"","}"]}} {"type":"assistant/chunk","seq":54,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."}}}} {"type":"assistant/chunk","seq":55,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}}}} {"type":"assistant/chunk","seq":56,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1286,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":28}}}} @@ -63,54 +19,9 @@ {"type":"step/end","seq":61,"time":1785591988476,"data":{"turn":1,"step":1}} {"type":"step/start","seq":62,"time":1785591988482,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":63,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":64,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":65,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" glob"}}} -{"type":"assistant/chunk","seq":66,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":67,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" shows"}}} -{"type":"assistant/chunk","seq":68,"time":1785591989988,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":69,"time":1785591990024,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":70,"time":1785591990127,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sampled"}}} -{"type":"assistant/chunk","seq":71,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} -{"type":"assistant/chunk","seq":72,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":73,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} -{"type":"assistant/chunk","seq":74,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} -{"type":"assistant/chunk","seq":75,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":76,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}} -{"type":"assistant/chunk","seq":77,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" paths"}}} -{"type":"assistant/chunk","seq":78,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" across"}}} -{"type":"assistant/chunk","seq":79,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":80,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} -{"type":"assistant/chunk","seq":81,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} -{"type":"assistant/chunk","seq":82,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":83,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"6"}}} -{"type":"assistant/chunk","seq":84,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" top"}}} -{"type":"assistant/chunk","seq":85,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-level"}}} -{"type":"assistant/chunk","seq":86,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" entries"}}} -{"type":"assistant/chunk","seq":87,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":88,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":89,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":90,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":91,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":92,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":93,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":94,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":95,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"G"}}} -{"type":"assistant/chunk","seq":96,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"LOB"}}} -{"type":"assistant/chunk","seq":97,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_S"}}} -{"type":"assistant/chunk","seq":98,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AM"}}} -{"type":"assistant/chunk","seq":99,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PL"}}} -{"type":"assistant/chunk","seq":100,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ED"}}} -{"type":"assistant/chunk","seq":101,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":102,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":103,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} -{"type":"assistant/chunk","seq":104,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"reasoning-chunks","seq0":64,"time0":1785591989939,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,49,36,103,1,0,0,326,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,14],"texts":["The"," glob"," result"," shows"," it"," was"," sampled"," -"," ","4"," of"," ","8"," paths"," across"," ","4"," of"," ","6"," top","-level"," entries","."," I"," need"," to"," reply"," with"," exactly"," \"","G","LOB","_S","AM","PL","ED","\""," as"," instructed","."]}} {"type":"assistant/chunk","seq":105,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":106,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"G"}}} -{"type":"assistant/chunk","seq":107,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"LOB"}}} -{"type":"assistant/chunk","seq":108,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_S"}}} -{"type":"assistant/chunk","seq":109,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"AM"}}} -{"type":"assistant/chunk","seq":110,"time":1785591990518,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PL"}}} -{"type":"assistant/chunk","seq":111,"time":1785591990518,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ED"}}} +{"type":"text-chunks","seq0":106,"time0":1785591990470,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,48,0],"texts":["G","LOB","_S","AM","PL","ED"]}} {"type":"assistant/chunk","seq":112,"time":1785591990526,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."}}}} {"type":"assistant/chunk","seq":113,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"GLOB_SAMPLED"}}}} {"type":"assistant/chunk","seq":114,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":48,"cacheReadTokens":1280,"reasoningTokens":41}}}} From 601fb9d1952ea07a6bba070ad1887c2b0890136e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 01:11:08 +0800 Subject: [PATCH 08/11] fix(fs-search): address the second-round #1119 review - inline the collect() identity wrapper now that both streams use the seam's diagnostic-tail shape - resolve the packaged rg path lazily at the first call (memoized): @vscode/ripgrep resolves its platform package at module evaluation, so a static import turned a missing/corrupt platform package into a Loader composition failure instead of the documented per-call SEARCH_FAILED - classify synchronous spawn-creation throws (a NUL in argv, an abort racing the pre-check, a rejected resolution) into SEARCH_FAILED / SEARCH_ABORTED instead of leaking raw errors - correct the stderrMaxBytes contract: the stderr excerpt is embedded in SEARCH_* error messages, not hidden from the model - export virtualManifest and pin its three acceptance paths (prefix hit, pnpm-11 truncated-name content-scan fallback, both miss) with fixture unit tests Tests: rg-path.spec.ts (resolution failure + memoized rejection), tools.spec.ts spawn-creation classification, notices spec virtualManifest. --- ...26-08-01-packaged-ripgrep-search.i18n.yaml | 4 +- .../2026-08-01-packaged-ripgrep-search.md | 2 +- .../2026-08-01-packaged-ripgrep-search.zh.md | 2 +- docs/config-catalog.md | 4 +- packages/fs/tool-fs-search/src/index.ts | 3 +- packages/fs/tool-fs-search/src/search-core.ts | 70 ++++++++++++++----- .../fs/tool-fs-search/tests/rg-path.spec.ts | 37 ++++++++++ .../fs/tool-fs-search/tests/tools.spec.ts | 43 ++++++++++++ scripts/gen-third-party-notices.spec.ts | 57 ++++++++++++++- scripts/gen-third-party-notices.ts | 7 +- 10 files changed, 200 insertions(+), 29 deletions(-) create mode 100644 packages/fs/tool-fs-search/tests/rg-path.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml index 3647587fa5..f63372c8b4 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.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/architecture/2026-08-01-packaged-ripgrep-search.md -2026-08-01-packaged-ripgrep-search.md: 849cc0804a2081492297649ac8f13236e2ac60fe -2026-08-01-packaged-ripgrep-search.zh.md: 4381c3a8bca8e6bb9ab375ad352fec5a5ddb9f99 +2026-08-01-packaged-ripgrep-search.md: 7c515618a18b61bd90177a6fdf19bbd52e564209 +2026-08-01-packaged-ripgrep-search.zh.md: f2b1a12c737f772bff6a6c91c17f7453dbc89748 diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md index 849cc0804a..7c515618a1 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md @@ -12,7 +12,7 @@ The `glob`/`grep` tools ran through the bash executor seam, which made a system ## Decision -`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector prefixed by `--no-config`, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. There is no shell layer, so the shell-quoting boundary is gone from execution; the `singleQuote` helper and its shell-spawning tests are deleted with it. The raw streams request the seam's diagnostic-tail collect shape (no spill files — the tool never reads a raw spill path; a lossy stdout read fails as `SEARCH_RAW_OUTPUT_OVERFLOW`). The terminate grace and the stderr tail budget are validated `Config` fields (`graceMs` default 3000, `stderrMaxBytes` default 64 KiB), no longer inherited from bash-local's config. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. +`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector prefixed by `--no-config`, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. `rgPath` resolves lazily at the first call (memoized per process): `@vscode/ripgrep` resolves its platform package at module evaluation, so a static import would turn a missing or corrupt platform package (`--omit=optional`, partial install) into a Loader-composition failure — the load-time failure mode this change exists to remove. There is no shell layer, so the shell-quoting boundary is gone from execution; the `singleQuote` helper and its shell-spawning tests are deleted with it. The raw streams request the seam's diagnostic-tail collect shape (no spill files — the tool never reads a raw spill path; a lossy stdout read fails as `SEARCH_RAW_OUTPUT_OVERFLOW`). The terminate grace and the stderr tail budget are validated `Config` fields (`graceMs` default 3000, `stderrMaxBytes` default 64 KiB), no longer inherited from bash-local's config. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. Exit semantics stay tool-owned: exit 0 is success with results, exit 1 is a successful empty search, anything else classifies into the existing `SEARCH_*` vocabulary (invalid pattern, launch failure, signal kill, raw-output overflow). Timeout is the cooperative tool-call budget attached to the tool definitions: `@deepseek-ai/dsh-timeout-policy` aborts `exec.signal`, the subprocess seam's terminate escalation provides the hard kill, and the tool reports `SEARCH_ABORTED`. The working directory is the session header cwd when present, else `process.cwd()` — there is no executor config to default through anymore, so the tool owns the fallback. diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md index 4381c3a8bc..f2b1a12c73 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,向量前缀 `--no-config`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 工具与其 shell spawn 测试一并删除。原始流使用 seam 的诊断尾部 collect 形态(无 spill 文件——工具从不读取原始 spill 路径;lossy stdout 读取以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败)。终止宽限与 stderr 尾部预算成为经校验的 `Config` 字段(`graceMs` 默认 3000,`stderrMaxBytes` 默认 64 KiB),不再继承自 bash-local 的配置。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 +`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,向量前缀 `--no-config`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。`rgPath` 在首次调用时懒解析(进程内 memoize):`@vscode/ripgrep` 在模块求值阶段解析其平台包,静态导入会把平台包缺失/损坏(`--omit=optional`、安装不全)变成 Loader 组合加载失败——这正是本次改动要消除的加载期失败模式。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 工具与其 shell spawn 测试一并删除。原始流使用 seam 的诊断尾部 collect 形态(无 spill 文件——工具从不读取原始 spill 路径;lossy stdout 读取以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败)。终止宽限与 stderr 尾部预算成为经校验的 `Config` 字段(`graceMs` 默认 3000,`stderrMaxBytes` 默认 64 KiB),不再继承自 bash-local 的配置。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 退出语义仍由工具拥有:退出码 0 为有结果的成功,1 为成功的空搜索,其余归入既有 `SEARCH_*` 词汇(无效模式、启动失败、信号杀死、原始输出溢出)。超时是挂在工具定义上的协作式工具调用预算:`@deepseek-ai/dsh-timeout-policy` 中止 `exec.signal`,subprocess seam 的终止升级提供硬终止,工具报告 `SEARCH_ABORTED`。工作目录为会话 header cwd(存在时),否则为 `process.cwd()`——不再有执行器配置可供默认化,因此回退由工具自己拥有。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a37e8da1c9..c568634985 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1735,14 +1735,14 @@ export interface Config { rawOutputMaxBytes?: number /** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */ graceMs?: number - /** Max bytes retained for one search's stderr diagnostic tail (never surfaced to the model). */ + /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */ timeoutMs?: number } ``` -Source: [`packages/fs/tool-fs-search/src/index.ts:71`](../packages/fs/tool-fs-search/src/index.ts) +Source: [`packages/fs/tool-fs-search/src/index.ts:72`](../packages/fs/tool-fs-search/src/index.ts) ## `@deepseek-ai/dsh-tool-goal` diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index 9a4042dde1..7f8e43cb73 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -55,6 +55,7 @@ export { SEARCH_TIMEOUT_MS, SearchError, previewLine, + resolveRgPath, runRipgrep, toWorkdirRelative, trySaveFormattedResult, @@ -83,7 +84,7 @@ export interface Config { rawOutputMaxBytes?: number /** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */ graceMs?: number - /** Max bytes retained for one search's stderr diagnostic tail (never surfaced to the model). */ + /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */ timeoutMs?: number diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 9444b029cc..854c593190 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -21,11 +21,10 @@ import { isAbsolute, relative, sep } from 'node:path' import type { Context } from 'cordis' -import { rgPath } from '@vscode/ripgrep' 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 { SubprocessCollect, SubprocessOutcome, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessOutcome, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import type { ToolExecution } from '@deepseek-ai/dsh-tools' @@ -154,6 +153,26 @@ function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutpu ) } +let rgPathPromise: Promise | undefined + +/** + * The packaged ripgrep binary path, resolved lazily once per process. + * + * `@vscode/ripgrep` resolves its platform package (`@vscode/ripgrep- + * -`) at module evaluation, so a static import would turn a missing or + * corrupt platform package (`pnpm install --omit=optional`, partial install) + * into a failure of the whole Loader composition. Resolving at the call + * boundary keeps that failure at the first search call as `SEARCH_FAILED` — + * the package's documented no-load-time-probe contract. + * + * @returns the packaged binary's absolute path; the memoized promise rejects + * when the platform package cannot be resolved. + */ +export function resolveRgPath(): Promise { + rgPathPromise ??= import('@vscode/ripgrep').then(module => module.rgPath) + return rgPathPromise +} + /** * Run the packaged ripgrep binary with a plain argv vector and return its * complete raw stdout. The working directory is the calling agent's session @@ -173,9 +192,12 @@ function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutpu * success with zero results (`noMatches`), anything else throws a * {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern → * `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` / - * `SEARCH_RAW_OUTPUT_OVERFLOW`). A spawn REJECTION — the seam's - * infrastructure failures — is translated into `SEARCH_FAILED` with the - * original as `cause`; a pre-aborted signal becomes `SEARCH_ABORTED`. + * `SEARCH_RAW_OUTPUT_OVERFLOW`). Both launch-time failure domains are + * classified: a synchronous throw at spawn CREATION (a NUL in argv, an abort + * racing the pre-check, a rejected `@vscode/ripgrep` resolution) and a + * rejection of `handle.done` (the seam's infrastructure failures) both become + * `SEARCH_FAILED` with the original as `cause` — an abort already observed by + * creation time becomes `SEARCH_ABORTED` instead. * * @param ctx - the plugin context; execution uses its `subprocess` service. * @param exec - the tool-execution context; supplies the session cwd and the abort signal. @@ -200,19 +222,31 @@ export async function runRipgrep( } const cwd = exec.agent?.session.header.cwd const workdir = cwd ?? process.cwd() - const collect = (maxBytes: number): SubprocessCollect => - ({ maxBytes }) - const handle = ctx.subprocess.spawn({ - argv: [rgPath, '--no-config', ...argv], - cwd: workdir, - stdio: { - stdin: 'ignore', - stdout: collect(rawOutputMaxBytes), - stderr: collect(stderrMaxBytes), - }, - graceMs, - signal: exec.signal, - } satisfies SubprocessSpawnSpec) + let handle: SubprocessHandle + try { + handle = ctx.subprocess.spawn({ + argv: [await resolveRgPath(), '--no-config', ...argv], + cwd: workdir, + stdio: { + stdin: 'ignore', + stdout: { maxBytes: rawOutputMaxBytes }, + stderr: { maxBytes: stderrMaxBytes }, + }, + graceMs, + signal: exec.signal, + } satisfies SubprocessSpawnSpec) + } catch (error: unknown) { + // Node's spawn() throws synchronously for a NUL in argv, and the local + // impl can throw synchronously when the signal aborts between the check + // above and this call (or when the platform-package resolution rejects). + // The static narrowing that proves this re-check "always false" cannot + // see AbortSignal state changes. + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (exec.signal.aborted) { + throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED') + } + throw new SearchError(`${toolName} could not start its search command (ripgrep launch failed)`, 'SEARCH_FAILED', { cause: error }) + } let outcome: SubprocessOutcome try { outcome = await handle.done diff --git a/packages/fs/tool-fs-search/tests/rg-path.spec.ts b/packages/fs/tool-fs-search/tests/rg-path.spec.ts new file mode 100644 index 0000000000..52888a3453 --- /dev/null +++ b/packages/fs/tool-fs-search/tests/rg-path.spec.ts @@ -0,0 +1,37 @@ +/** + * Failure-path tests for the lazy packaged-ripgrep resolution. The success + * path (the real `@vscode/ripgrep` module) is exercised throughout + * tools.spec.ts; here the module is mocked to throw at evaluation, proving a + * missing or corrupt platform package (`--omit=optional`, partial install) + * surfaces as a per-call `SEARCH_FAILED` — not a composition-load failure. + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import { resolveRgPath, runRipgrep } from '@deepseek-ai/dsh-tool-fs-search' + +// Any access to the mocked module's surface throws — the shape a missing +// platform package produces at module evaluation. +vi.mock('@vscode/ripgrep', () => new Proxy({}, { + get() { + throw new Error('platform package @vscode/ripgrep-win32-x64 is not installed') + }, +})) + +describe('lazy packaged-ripgrep resolution', () => { + it('fails the first search call with SEARCH_FAILED instead of failing module load', async () => { + // The resolution rejects before any spawn, so no subprocess service is needed. + const controller = new AbortController() + const exec = { signal: controller.signal, name: 'glob', callId: CallId('missing-platform-package') } as unknown as ToolExecution + + await expect(runRipgrep(new Context(), exec, 'glob', ['--files'], 1_000_000, 3_000, 64 * 1024)) + .rejects.toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' }) + }) + + it('keeps failing every subsequent call (the resolution is memoized)', async () => { + await expect(resolveRgPath()).rejects.toThrow(/platform package/) + await expect(resolveRgPath()).rejects.toThrow(/platform package/) + }) +}) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 5c6713a7f0..a8a2498c60 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -32,6 +32,7 @@ import { presentGrepCall, presentGrepResult, previewLine, + resolveRgPath, runRipgrep, sampleAcrossTopLevel, toWorkdirRelative, @@ -468,6 +469,48 @@ describe('workdir derivation and signal forwarding', () => { expect(text(result)).toContain('could not start') }) + it('classifies a synchronous spawn-creation throw as SEARCH_FAILED', async () => { + // Node's spawn() throws synchronously for a NUL in argv, and the local + // impl can throw synchronously for other invalid specs. Creation-time + // failures must join the error vocabulary instead of escaping raw. + const { ctx, subprocess } = await setup() + subprocess.handler = () => { throw new Error('spawn ERR_INVALID_ARG_VALUE') } + + const result = await call(ctx, 'grep', { pattern: 'x' }) + + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) + expect(text(result)).toContain('could not start') + }) + + it('classifies a synchronous spawn-creation throw after an abort as SEARCH_ABORTED', async () => { + // The local impl can throw synchronously when the signal aborts between + // the pre-spawn check and the spawn call; no process was launched, so the + // abort is the reportable cause. + const { ctx, subprocess } = await setup() + const controller = new AbortController() + subprocess.handler = () => { + controller.abort('timeout') + throw new Error('aborted during spawn') + } + + const result = await call(ctx, 'glob', { pattern: '*' }, { signal: controller.signal }) + + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } }) + expect(text(result)).toContain('aborted before completion') + }) + + it('resolves the packaged ripgrep path lazily, once per process', async () => { + // The module must not touch @vscode/ripgrep at load (a missing platform + // package would otherwise fail the whole composition), and repeated + // resolution reuses the first result. The resolution-failure path is + // pinned separately in rg-path.spec.ts. + await setup() + expect(await resolveRgPath()).toBe(rgPath) + expect(resolveRgPath()).toBe(resolveRgPath()) + }) + it('rejects when the subprocess implementation drops a requested collect stream', async () => { const { ctx, subprocess } = await setup() subprocess.dropReaders = true diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 707c30ff70..f31cca6879 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -1,7 +1,8 @@ -import { readdirSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' -import { collectPythonDependencies, isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parseVendoredRows, render, tierExternalDeps } from './gen-third-party-notices.ts' +import { collectPythonDependencies, isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parseVendoredRows, render, tierExternalDeps, virtualManifest } from './gen-third-party-notices.ts' const root = resolve(import.meta.dirname, '..') @@ -63,6 +64,56 @@ describe('tierExternalDeps', () => { }) }) +describe('virtualManifest', () => { + it('resolves a manifest from an ordinary prefix-matching store directory', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-notices-prefix-')) + try { + const name = '@scope/pkg' + const version = '1.0.0' + const store = join(root, 'store') + const manifestDir = join(store, `${name.replace('/', '+')}@${version}`, 'node_modules', name) + mkdirSync(manifestDir, { recursive: true }) + writeFileSync(join(manifestDir, 'package.json'), JSON.stringify({ name, version, license: 'MIT' })) + + expect(virtualManifest(store, name)).toMatchObject({ name, version, license: 'MIT' }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('falls back to a content scan when pnpm 11 truncates the store directory name', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-notices-truncated-')) + try { + const name = '@scope/pkg' + const version = '2.0.0' + const store = join(root, 'store') + // The truncated name no longer starts with `@scope+pkg@`, so only the + // whole-store content scan can find the package. + const manifestDir = join(store, '@scope+pkg_9f1c2d3e4a5b6c7d8e9f0a1b2c3d4e5f', 'node_modules', name) + mkdirSync(manifestDir, { recursive: true }) + writeFileSync(join(manifestDir, 'package.json'), JSON.stringify({ name, version, license: 'Apache-2.0' })) + + expect(virtualManifest(store, name)).toMatchObject({ name, version, license: 'Apache-2.0' }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('returns undefined when neither the prefix nor the content scan finds the package', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-notices-miss-')) + try { + const store = join(root, 'store') + const other = join(store, 'other-pkg@1.0.0', 'node_modules', 'other-pkg') + mkdirSync(other, { recursive: true }) + writeFileSync(join(other, 'package.json'), JSON.stringify({ name: 'other-pkg', version: '1.0.0' })) + + expect(virtualManifest(store, '@scope/missing')).toBeUndefined() + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) + describe('parseVendoredRows', () => { it('reads the committed vendor manifest table', () => { const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8')) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index a4a1f4c343..6b790829d5 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -172,8 +172,13 @@ type VirtualManifest = Manifest & { license?: string; repository?: string | { ur * long names (a peer-suffixed name past the length limit becomes * `_`), so a content scan falls back over the whole store when * the prefix misses. + * + * @param virtual - the `.pnpm` virtual store directory to scan. + * @param name - the external package name, exactly as `node_modules` spells it. + * @returns the parsed manifest, or `undefined` when neither the prefix match + * nor the content scan finds the package's `package.json`. */ -function virtualManifest(virtual: string, name: string): VirtualManifest | undefined { +export function virtualManifest(virtual: string, name: string): VirtualManifest | undefined { const prefix = `${name.replace('/', '+')}@` const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix)) if (entry !== undefined) { From 3c6583370f994f9e5454a2f71c40d0b09d72fd4b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 11:25:51 +0800 Subject: [PATCH 09/11] fix(fs-search): platform-normalize sampling test paths The sampler and the workdir-relative display conversion group by node:path.sep, so the POSIX-style '/' literals in the cross-directory sampling cases collapse into per-path groups on Windows (every path its own top-level entry), turning the round-robin sample into a head. The same gap exists on master (its platform-separator fix predates these tests); normalize the literals through a platform helper instead, and keep the POSIX-backslash-as-filename case Windows-skipped as before. --- .../fs/tool-fs-search/tests/tools.spec.ts | 65 ++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index a8a2498c60..9a8fa991ff 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -40,6 +40,13 @@ import { const testToolSignal = new AbortController().signal +/** + * Normalize a POSIX-style test path to the platform separator: the sampler and + * the workdir-relative display conversion group by `node:path.sep`, so + * `/`-literal paths would collapse into per-path groups on Windows. + */ +const w = (path: string): string => path.replaceAll('/', sep) + /** One scripted collect-mode stream, returned by `readFrom(0)` after settlement. */ interface ScriptedStream { text: string @@ -621,24 +628,24 @@ describe('raw output acquisition', () => { describe('cross-directory sampling', () => { it('gives every top-level entry a slot before any entry gets a second', () => { - const paths = ['v/a', 'v/b', 'v/c', 'v/d', 'src/e', 'guide/f'] + const paths = ['v/a', 'v/b', 'v/c', 'v/d', 'src/e', 'guide/f'].map(w) // The head of 3 would be all `v/`; the sample reaches all three entries. - expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['v/a', 'src/e', 'guide/f'], shown: 3, total: 3 }) + expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['v/a', 'src/e', 'guide/f'].map(w), shown: 3, total: 3 }) // Extra slots go round again — to the only entry with paths left — and the // page stays grouped by entry rather than interleaved. - expect(sampleAcrossTopLevel(paths, 5)).toEqual({ items: ['v/a', 'v/b', 'v/c', 'src/e', 'guide/f'], shown: 3, total: 3 }) + expect(sampleAcrossTopLevel(paths, 5)).toEqual({ items: ['v/a', 'v/b', 'v/c', 'src/e', 'guide/f'].map(w), shown: 3, total: 3 }) }) it('hands an exhausted entry the remaining slots go to entries that still have paths', () => { - const paths = ['solo/a', 'many/b', 'many/c', 'many/d'] - expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['solo/a', 'many/b', 'many/c'], shown: 2, total: 2 }) + const paths = ['solo/a', 'many/b', 'many/c', 'many/d'].map(w) + expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['solo/a', 'many/b', 'many/c'].map(w), shown: 2, total: 2 }) }) it('does not rescan exhausted entries while filling a skewed page', () => { const singletonCount = 12_500 const paths = [ - ...Array.from({ length: singletonCount }, (_, index) => `group-${index}/only`), - ...Array.from({ length: singletonCount }, (_, index) => `late/${index}`), + ...Array.from({ length: singletonCount }, (_, index) => `group-${index}${sep}only`), + ...Array.from({ length: singletonCount }, (_, index) => `late${sep}${index}`), ] expect(sampleAcrossTopLevel(paths, paths.length - 1)).toMatchObject({ shown: singletonCount + 1, @@ -648,15 +655,15 @@ describe('cross-directory sampling', () => { }, 500) it('reports the entries it could not reach when the page is smaller than the top level', () => { - const paths = ['a/1', 'b/1', 'c/1', 'd/1'] - expect(sampleAcrossTopLevel(paths, 2)).toEqual({ items: ['a/1', 'b/1'], shown: 2, total: 4 }) + const paths = ['a/1', 'b/1', 'c/1', 'd/1'].map(w) + expect(sampleAcrossTopLevel(paths, 2)).toEqual({ items: ['a/1', 'b/1'].map(w), shown: 2, total: 4 }) }) it('groups an absolute path by its first real name, not by its empty root segment', () => { // Paths outside the workdir stay absolute; without stripping the leading // separator every one of them would collapse into a single empty group. - expect(sampleAcrossTopLevel(['/out/a', '/out/b', '/away/c', '/away/d'], 2)) - .toEqual({ items: ['/out/a', '/away/c'], shown: 2, total: 2 }) + expect(sampleAcrossTopLevel(['/out/a', '/out/b', '/away/c', '/away/d'].map(w), 2)) + .toEqual({ items: ['/out/a', '/away/c'].map(w), shown: 2, total: 2 }) }) it('reproduces the modification-time-ordered head for a flat result', () => { @@ -669,15 +676,15 @@ describe('cross-directory sampling', () => { 'workspace/vendor/b.ts', 'workspace/source/c.ts', 'workspace/guides/d.md', - ], 3, 'workspace')).toEqual({ - items: ['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'], + ].map(w), 3, 'workspace')).toEqual({ + items: ['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'].map(w), shown: 3, total: 3, }) - expect(sampleAcrossTopLevel(['./vendor/a.ts', './src/b.ts'], 2, '.')) - .toEqual({ items: ['./vendor/a.ts', './src/b.ts'], shown: 2, total: 2 }) - expect(sampleAcrossTopLevel(['/vendor/a.ts', '/src/b.ts'], 2, '/')) - .toEqual({ items: ['/vendor/a.ts', '/src/b.ts'], shown: 2, total: 2 }) + expect(sampleAcrossTopLevel(['./vendor/a.ts', './src/b.ts'].map(w), 2, '.')) + .toEqual({ items: ['./vendor/a.ts', './src/b.ts'].map(w), shown: 2, total: 2 }) + expect(sampleAcrossTopLevel(['/vendor/a.ts', '/src/b.ts'].map(w), 2, w('/'))) + .toEqual({ items: ['/vendor/a.ts', '/src/b.ts'].map(w), shown: 2, total: 2 }) const rooted = [ ['root', 'a', 'one'].join(sep), ['root', 'a', 'two'].join(sep), @@ -685,8 +692,8 @@ describe('cross-directory sampling', () => { ] expect(sampleAcrossTopLevel(rooted, 2, 'root')) .toEqual({ items: [rooted[0], rooted[2]], shown: 2, total: 2 }) - expect(sampleAcrossTopLevel(['other/a.ts'], 1, 'src')) - .toEqual({ items: ['other/a.ts'], shown: 1, total: 1 }) + expect(sampleAcrossTopLevel(['other/a.ts'].map(w), 1, 'src')) + .toEqual({ items: ['other/a.ts'].map(w), shown: 1, total: 1 }) expect(sampleAcrossTopLevel(['src'], 1, 'src')) .toEqual({ items: ['src'], shown: 1, total: 1 }) }) @@ -767,9 +774,9 @@ describe('glob results', () => { // freshly-unpacked subtree first, and a head-of-3 reads like the entire // workspace. The sample reaches every top-level entry instead. const { ctx, subprocess } = await setup({ config: { globMaxResults: 3 } }) - subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md', 'top.txt'].join('\n')) + subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md', 'top.txt'].map(w).join('\n')) const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }) - expect(text(result)).toBe('vendor/a.ts\nsrc/d.ts\nguide/e.md\n\n' + expect(text(result)).toBe(['vendor/a.ts', 'src/d.ts', 'guide/e.md'].map(w).join('\n') + '\n\n' + '(Showing 3 of 6 paths, sampled across 3 of the 4 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.)') @@ -792,9 +799,9 @@ describe('glob results', () => { 'workspace/vendor/b.ts', 'workspace/source/c.ts', 'workspace/guides/d.md', - ].join('\n')) - const result = await call(ctx, 'glob', { pattern: '*', path: 'workspace' }, { agent: agent('/w') }) - expect(text(result)).toContain('workspace/vendor/a.ts\nworkspace/source/c.ts\nworkspace/guides/d.md') + ].map(w).join('\n')) + const result = await call(ctx, 'glob', { pattern: '*', path: w('workspace') }, { agent: agent('/w') }) + expect(text(result)).toContain(['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'].map(w).join('\n')) expect(text(result)).toContain('sampled across 3 of the 3 top-level entries') }) @@ -805,17 +812,17 @@ describe('glob results', () => { '/w/workspace/vendor/b.ts', '/w/workspace/source/c.ts', '/w/workspace/guides/d.md', - ].join('\n')) - const result = await call(ctx, 'glob', { pattern: '*', path: '/w/workspace' }, { agent: agent('/w') }) - expect(text(result)).toContain('workspace/vendor/a.ts\nworkspace/source/c.ts\nworkspace/guides/d.md') + ].map(w).join('\n')) + const result = await call(ctx, 'glob', { pattern: '*', path: w('/w/workspace') }, { agent: agent(w('/w')) }) + expect(text(result)).toContain(['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'].map(w).join('\n')) expect(text(result)).toContain('sampled across 3 of the 3 top-level entries') }) it('drops the narrowing hint when the sample reaches every top-level entry', async () => { const { ctx, subprocess } = await setup({ config: { globMaxResults: 3 } }) - subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts'].join('\n')) + subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts'].map(w).join('\n')) expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }))) - .toBe('vendor/a.ts\nvendor/b.ts\nsrc/d.ts\n\n' + .toBe(['vendor/a.ts', 'vendor/b.ts', 'src/d.ts'].map(w).join('\n') + '\n\n' + '(Showing 3 of 4 paths, sampled across 2 of the 2 top-level entries this pattern matched ' + 'instead of taken in modification-time order. ' + 'The complete result could not be saved; narrow pattern or path to see more.)') From 998b80886b94504cc53f8230713c35d274f83ccd Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 11:26:06 +0800 Subject: [PATCH 10/11] fix(tool-str-replace-editor): platform-normalize listing assertion paths The view listing carries absolute display paths, so the POSIX-style 'node_modules_old/kept.js' substring assertions only match on Linux (Windows display paths use backslashes). Assert with platform separators to keep the same check meaningful on Windows; the pre-existing gap is identical on master. --- packages/fs/tool-str-replace-editor/tests/tools.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 797263897f..4fda9a2d3f 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -244,8 +244,10 @@ describe('tool-str-replace-editor', () => { expect(listing).not.toContain('too-deep.txt') expect(listing).not.toContain('index.js') expect(listing).not.toContain('module.pyc') - expect(listing).toContain('node_modules_old/kept.js') - expect(listing).toContain('__pycache__backup/kept.py') + // The listing carries absolute display paths; the POSIX-style substrings + // only match on Linux, so assert with platform separators. + expect(listing).toContain(join('node_modules_old', 'kept.js')) + expect(listing).toContain(join('__pycache__backup', 'kept.py')) const clipped = await setup({ maxOutputChars: 10 }) await writeFile(join(clipped.root, 'large.txt'), 'x'.repeat(100)) From 5329bef04d71e003ce07dd978d84dc2c4ab0795b Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 4 Aug 2026 00:25:20 +0800 Subject: [PATCH 11/11] perf(tui): incremental step timing and card render caches for long sessions Resuming a long session (196k events, 2.2k steps, 1.8k tool cards) took ~12s to render and ~800ms to echo one keystroke: - Every step's timing footer called stepTimingAt, which replayed the whole event log per footer - O(steps x events) on the initial render. - pi-tui re-renders every component each frame and relies on per-component line caches, but ToolCardComponent/ContextCardComponent built throwaway Text/Markdown instances inside render(width), re-wrapping every settled card's output on every keystroke. Replace the per-footer replay with one shared StepTimingTracker per chat mount (single O(events) cursor over the append-only log), and cache card rows by width via CardLineCache, dropped by every state mutator and invalidate(). Measured (tmux 200x50, 196k-event session): resume prompt-ready ~12s -> ~7.6s; per-keystroke echo ~800ms median -> ~11ms. --- ...03-tui-long-session-render-costs.i18n.yaml | 6 ++ ...026-08-03-tui-long-session-render-costs.md | 33 +++++++ ...-08-03-tui-long-session-render-costs.zh.md | 33 +++++++ docs/cordis-catalog/services.md | 2 +- packages/ui/tui/src/chat/timing.ts | 72 ++++++++++----- packages/ui/tui/src/components/transcript.ts | 63 ++++++++++--- packages/ui/tui/src/index.ts | 5 ++ packages/ui/tui/tests/timing-tracker.spec.ts | 90 +++++++++++++++++++ .../tui/tests/transcript-card-cache.spec.ts | 74 +++++++++++++++ 9 files changed, 342 insertions(+), 36 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.zh.md create mode 100644 packages/ui/tui/tests/timing-tracker.spec.ts create mode 100644 packages/ui/tui/tests/transcript-card-cache.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml new file mode 100644 index 0000000000..8beb847e60 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.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-08-03-tui-long-session-render-costs.md +2026-08-03-tui-long-session-render-costs.md: c5b03960b6951cb2de2b847f03ec8eb2b92cc55c +2026-08-03-tui-long-session-render-costs.zh.md: b41c5a8c546e296525645d82808117673fdeec6d diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md b/.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md new file mode 100644 index 0000000000..c5b03960b6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md @@ -0,0 +1,33 @@ +# Agent Note: TUI long-session render costs — shared step-timing scan and card line caches + +Status: implemented + +English | [中文](2026-08-03-tui-long-session-render-costs.zh.md) + +## Problem + +On a long resumed session (196k events, 2.2k steps, 1.8k tool cards) the TUI took ~12 s to render the transcript and ~800 ms to echo one keystroke. Profiling attributed both to the render path, not to session load (zstd + parse + surface seed is ~1.7 s): + +- Every step's timing footer called `stepTimingAt`, which replayed the whole event log from index 0 per footer — O(steps × events) on the initial render, ~6 s of CPU. +- pi-tui re-renders every component each frame and relies on per-component line caches (its own `Text`/`Markdown` cache by `(text, width)`). `ToolCardComponent.render()` and `ContextCardComponent.render()` built throwaway `new Text(...)`/`new Markdown(...)` instances inside `render(width)`, so every frame — every keystroke — re-wrapped every settled card's output. + +## Decision + +`packages/ui/tui/src/chat/timing.ts` replaces `stepTimingAt` with `StepTimingTracker`: one accumulator per chat mount, created in `createTuiChat` and threaded through `StreamingAssistantComponent` into each `StepTimingComponent`. A query advances a cursor over events appended since the previous query and keeps per-step bucket state in a map, so all footers together cost O(events). The open bucket is accumulated to the query clock at lookup, and a step is pinned at its `step/end`. The tracker requires the append-only session log (the `seq = log length` contract). + +`ToolCardComponent` and `ContextCardComponent` cache their rendered rows keyed by width. The cache drops on every state mutator (`updateResult`, `setVisibility`, `setExpanded`) and on `invalidate()` (pi-tui's tree-wide cascade), so a state change always re-renders; everything else — including every keystroke frame — returns the cached rows. This restores upstream pi's own component convention (persistent child components plus explicit `cachedWidth`/`cachedLines` where rendering is custom, e.g. pi `coding-agent` `bash.ts`), which the imperative `render(width)` bodies here had silently defeated. + +Measured on the 196k-event session (tmux, 200×50): resume prompt-ready 12.2 s → 7.2 s; per-keystroke echo 796 ms median → 17 ms (fresh-session parity). + +## Alternatives considered + +- **Index `step/start` offsets, keep per-footer replay** — removes the `findIndex` but each footer still scans its step's span from a shared array; the tracker's single shared pass is the same complexity win with less bookkeeping. +- **Restructure the cards into persistent pi-tui child components** (upstream pi's primary style) — equivalent steady-state cost, but a larger diff across card state handling for no additional win over the width-keyed cache. +- **Cache inside pi-tui's `Container.render`** — wrong layer: the vendored patch surface would grow, and the contract (components own their caches) already exists upstream. + +## Consequences + +- Typing latency no longer scales with total tool output; the residual per-frame cost is pi-tui's tree traversal and row concatenation, linear in rendered rows. Resume render cost is now dominated by pi-tui's one-time initial layout (~4 s at 196k events) plus load (~1.7 s), both linear. +- The tracker consumes event times as logged and drops the removed implementation's mid-scan `time > at` cutoff, which per-footer `at` values make impossible in a shared scan; under a backward wall-clock step each bucket clamps at zero, which can differ from the old cutoff's totals. +- Card `render()` is no longer a pure function of `(state, width)` per call — mutators must drop `linesCache`. A new mutator that forgets to do so shows stale rows; the cache tests in `packages/ui/tui/tests/transcript-card-cache.spec.ts` pin the contract for the existing mutators. +- `StepTimingTracker` assumes step coordinates are not reused after `step/end`; a duplicate `step/start` for a closed step is ignored rather than restarting the step. diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.zh.md b/.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.zh.md new file mode 100644 index 0000000000..b41c5a8c54 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.zh.md @@ -0,0 +1,33 @@ +# Agent Note: TUI 长会话渲染开销:共享步骤耗时扫描与卡片行缓存 + +Status: implemented + +[English](2026-08-03-tui-long-session-render-costs.md) | 中文 + +## 问题 + +在一个恢复后的长会话(196k 条事件、2.2k 个步骤、1.8k 张工具卡片)中,TUI 渲染 transcript(文本记录)耗时约 12 秒,回显一次按键耗时约 800 毫秒。性能剖析表明,两项耗时都来自渲染路径,而非会话加载(zstd + 解析 + 表层播种约为 1.7 秒): + +- 每个步骤的耗时页脚都会调用 `stepTimingAt`,而它会针对每个页脚从索引 0 起回放整个事件日志,因此初次渲染的复杂度为 O(步骤数 × 事件数),占用约 6 秒 CPU 时间。 +- pi-tui 每一帧都会重新渲染所有组件,并依赖各组件自己的行缓存(它的 `Text`/`Markdown` 会按 `(text, width)` 缓存)。`ToolCardComponent.render()` 和 `ContextCardComponent.render()` 构造用后即弃的 `new Text(...)`/`new Markdown(...)` 实例,且构造发生在 `render(width)` 内,因此每一帧,也就是每次按键,都会重新对每张已结算卡片的输出进行折行。 + +## 决策 + +`packages/ui/tui/src/chat/timing.ts` 不再使用 `stepTimingAt`,改用 `StepTimingTracker`:每次挂载聊天界面时在 `createTuiChat` 中创建一个累加器,再经 `StreamingAssistantComponent` 传入每个 `StepTimingComponent`。每次查询都会推进游标,扫描上次查询后追加的事件,并在一个映射表中保存各步骤的 bucket 状态,因此所有页脚合计只需 O(事件数)。查询时,系统把未闭合 bucket 累加到查询时刻;步骤在其 `step/end` 处固定。该跟踪器要求会话日志仅追加,即遵守 `seq = log length` 契约。 + +`ToolCardComponent` 和 `ContextCardComponent` 按宽度键控缓存渲染行。调用任一状态修改方法(`updateResult`、`setVisibility`、`setExpanded`)或 `invalidate()`(pi-tui 的全树级联)时会清空缓存,因此状态变化一定会重新渲染;其他情况,包括每一次按键帧,都会返回缓存行。这恢复了上游 pi 自身的组件惯例:使用常驻子组件;自定义渲染时显式使用 `cachedWidth`/`cachedLines`,例如 pi `coding-agent` 的 `bash.ts`。而这里命令式的 `render(width)` 函数体此前让这套惯例失效。 + +在该 196k 条事件的会话上测得(tmux,200×50):恢复后提示符就绪耗时从 12.2 秒降至 7.2 秒;每次按键的回显耗时中位数从 796 毫秒降至 17 毫秒(与新会话持平)。 + +## 曾考虑的替代方案 + +- **索引 `step/start` 偏移量,保留逐页脚回放**:这会消除 `findIndex`,但每个页脚仍要从共享数组扫描所属步骤的区间;跟踪器的一次共享遍历以更少的额外状态记录取得相同的复杂度改进。 +- **把卡片重构为常驻 pi-tui 子组件**(上游 pi 的主要风格):稳定状态下成本相同,但卡片状态处理所需改动更大,相较按宽度键控的缓存并无额外收益。 +- **在 pi-tui 的 `Container.render` 内缓存**:层级不对:对第三方内嵌代码的补丁范围会扩大,而上游已经约定由组件拥有各自的缓存。 + +## 后果 + +- 输入延迟不再随工具输出总量增长;剩余的每帧成本是 pi-tui 的树遍历与行拼接,与渲染行数呈线性关系。恢复时的渲染成本现由 pi-tui 的一次性初始布局(196k 条事件时约 4 秒)与加载(约 1.7 秒)主导,两者均为线性。 +- 该跟踪器直接采用日志记录的事件时间,不再像已移除的实现那样,在扫描中途遇到 `time > at` 时截断;由于每个页脚的 `at` 值不同,共享扫描无法采用这种截断;挂钟时间倒退时,每个 bucket 都以零为下限,所得总计值可能与旧截断下的总计值不同。 +- 卡片的 `render()` 不再是每次调用时 `(state, width)` 的纯函数,状态修改方法必须清空 `linesCache`。若新增状态修改方法时忘记清空,界面会显示陈旧行;`packages/ui/tui/tests/transcript-card-cache.spec.ts` 中的缓存测试固定了现有状态修改方法的契约。 +- `StepTimingTracker` 假定步骤坐标在 `step/end` 后不会复用;对已关闭步骤重复出现的 `step/start` 会被忽略,不会重新启动该步骤。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b518f37876..6180984b3c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2451,7 +2451,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:245`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:246`](../../packages/ui/tui/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/packages/ui/tui/src/chat/timing.ts b/packages/ui/tui/src/chat/timing.ts index 2312aa49b6..5bd7e4082b 100644 --- a/packages/ui/tui/src/chat/timing.ts +++ b/packages/ui/tui/src/chat/timing.ts @@ -132,32 +132,58 @@ function timingTotalsAt(state: TimingState, at?: number): TimingTotals { return totals } +function stepKey(position: StepPosition): string { + return `${position.turn}:${position.step}` +} + +interface TrackedStep extends TimingState { + /** Set at the step's `step/end`; later same-coordinate events no longer advance the step. */ + closed: boolean +} + /** - * Replay one step's accumulated per-phase timing up to clock `at`. - * @param events - Session events to replay. - * @param position - Turn/step coordinates of the step. - * @param at - Render clock to accumulate the open bucket up to. - * @returns The step's per-phase totals. + * Incremental per-step timing accumulator shared by every step's timing footer + * in one transcript. One forward pass over the append-only session log serves + * all steps' totals: each query advances a cursor over the events appended + * since the previous query, so a transcript of S steps costs O(events) in + * total instead of the O(S × events) of replaying the whole log per footer + * ([rationale](../../../../../.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md)). + * + * The log must be append-only with stable indices (the session `seq = log + * length` contract). Event times are consumed as logged: a backward wall-clock + * step clamps each bucket at zero rather than cutting the scan off at the + * query clock. The open bucket is accumulated to the query clock at lookup, + * never during the scan. */ -export function stepTimingAt( - events: readonly SessionEvent[], - position: StepPosition, - at: number, -): TimingTotals { - const startIndex = events.findIndex(event => event.type === 'step/start' && sameStep(event, position)) - if (startIndex < 0) return emptyTimingTotals() - const start = events[startIndex] as Extract - const state = timingState(start.time) - for (let index = startIndex + 1; index < events.length; index += 1) { - const event = events[index] as SessionEvent - if (event.time > at) break - if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end') - && sameStep(event, position)) { - advanceStepTiming(state, event) - if (event.type === 'step/end') break +export class StepTimingTracker { + private scanned = 0 + private readonly steps = new Map() + + /** + * Advance over events appended since the previous query, then return one + * step's accumulated per-phase timing up to clock `at`. + * @param events - Current session event log (append-only). + * @param position - Turn/step coordinates of the queried step. + * @param at - Render clock to accumulate the open bucket up to. + * @returns The step's per-phase totals; empty when the step never started. + */ + totalsAt(events: readonly SessionEvent[], position: StepPosition, at: number): TimingTotals { + for (; this.scanned < events.length; this.scanned += 1) { + const event = events[this.scanned] as SessionEvent + if (event.type === 'step/start') { + const key = stepKey(event.data) + if (!this.steps.has(key)) this.steps.set(key, { ...timingState(event.time), closed: false }) + } else if (event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end') { + const state = this.steps.get(stepKey(event.data)) + if (state !== undefined && !state.closed) { + advanceStepTiming(state, event) + if (event.type === 'step/end') state.closed = true + } + } } + const state = this.steps.get(stepKey(position)) + return state === undefined ? emptyTimingTotals() : timingTotalsAt(state, at) } - return timingTotalsAt(state, at) } /** @@ -191,7 +217,7 @@ const COMPACTING_GLYPH = '⊙' /** * Derive the currently open step's active timing bucket, or `undefined` when no * step is open. The open step is the last `step/start` with no later matching - * `step/end`; its bucket is replayed with the same rules as {@link stepTimingAt}. + * `step/end`; its bucket is replayed with the same rules as {@link StepTimingTracker}. * @param events - Session events to scan. * @returns The open step's active bucket, or `undefined`. */ diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index f0422e8417..edf5a5a661 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -33,8 +33,8 @@ import { contentText, type ParsedArguments } from './content.ts' import { formatCompletionTime, formatTimingTotals, - stepTimingAt, type StepPosition, + type StepTimingTracker, } from '../chat/timing.ts' /** Concatenate the text of every block of one type, separated by blank lines. */ @@ -228,6 +228,7 @@ class StepTimingComponent extends Container { constructor( private readonly position: StepPosition, private readonly events: () => readonly SessionEvent[], + private readonly tracker: StepTimingTracker, private readonly now: () => number, private readonly palette: Palette, ) { @@ -247,7 +248,7 @@ class StepTimingComponent extends Container { private rebuild(): void { this.clear() - const totals = stepTimingAt(this.events(), this.position, this.completionTime ?? this.now()) + const totals = this.tracker.totalsAt(this.events(), this.position, this.completionTime ?? this.now()) const timing = formatTimingTotals(totals, true) const header = this.completionTime === undefined ? timing @@ -277,13 +278,14 @@ export class StreamingAssistantComponent extends Container { /** The step's turn/step coordinates, used to group steps into their turn. */ readonly position: StepPosition, events: () => readonly SessionEvent[], + tracker: StepTimingTracker, now: () => number, private showReasoning: boolean, private readonly palette: Palette, private readonly mdTheme: MarkdownTheme, ) { super() - this.timing = new StepTimingComponent(position, events, now, palette) + this.timing = new StepTimingComponent(position, events, tracker, now, palette) this.rebuild() } @@ -409,8 +411,43 @@ interface CardBody { */ export type ToolCardVisibility = 'hidden' | 'collapsed' | 'expanded' +/** + * Transcript card with a width-keyed rendered-row cache. pi-tui re-renders + * every component each frame and relies on per-component line caches (its own + * `Text`/`Markdown` do this); a card that rebuilds rows inside `render(width)` + * would re-wrap its output every frame + * ([rationale](../../../../../.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md)). + * Subclasses render through {@link renderLines} and call {@link dropLines} + * from every state mutator; with `invalidate()` (pi-tui's tree-wide cascade) + * also dropping, a state change always re-renders. + */ +abstract class CachedCardComponent implements Component { + private cached: { width: number; lines: string[] } | undefined + + /** Discard the cached rows so the next render recomputes them. */ + protected dropLines(): void { + this.cached = undefined + } + + invalidate(): void { + this.cached = undefined + } + + render(width: number): string[] { + if (this.cached?.width !== width) this.cached = { width, lines: this.renderLines(width) } + return this.cached.lines + } + + /** + * Render the card's rows for `width` without caching. + * @param width - Render width the rows are wrapped to. + * @returns The card's rows. + */ + protected abstract renderLines(width: number): string[] +} + /** A tool call and its result, rendered as a collapsible status card. */ -export class ToolCardComponent implements Component { +export class ToolCardComponent extends CachedCardComponent { private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined private visibility: ToolCardVisibility = 'collapsed' private callView: ToolCallView @@ -426,6 +463,7 @@ export class ToolCardComponent implements Component { private readonly palette: Palette, private readonly mdTheme: MarkdownTheme, ) { + super() this.callView = this.presentCall() } @@ -447,6 +485,7 @@ export class ToolCardComponent implements Component { */ updateResult(event: Extract['data']): void { this.diffBodyCache = undefined + this.dropLines() const result = event.message.content[0] this.result = { content: [...result.content], @@ -469,11 +508,10 @@ export class ToolCardComponent implements Component { */ setVisibility(visibility: ToolCardVisibility): void { this.visibility = visibility + this.dropLines() } - invalidate(): void {} - - render(width: number): string[] { + protected renderLines(width: number): string[] { // Hidden renders nothing — not even the leading gap — so the transcript // keeps only the conversation, the way Codex hides tool calls. if (this.visibility === 'hidden') return [] @@ -725,7 +763,7 @@ function stripReminderFrame(text: string): string { * well-formed XML, which made both the fold and the frame-line suppression * content-dependent. */ -export class ContextCardComponent implements Component { +export class ContextCardComponent extends CachedCardComponent { private expanded = false constructor( @@ -733,7 +771,9 @@ export class ContextCardComponent implements Component { private readonly text: string, private readonly maxOutputLines: number, private readonly palette: Palette, - ) {} + ) { + super() + } /** * Expand or collapse the card body. @@ -741,11 +781,10 @@ export class ContextCardComponent implements Component { */ setExpanded(expanded: boolean): void { this.expanded = expanded + this.dropLines() } - invalidate(): void {} - - render(width: number): string[] { + protected renderLines(width: number): string[] { const header = this.palette.dim(`Context · ${displayText(this.label)}`) // Emptiness is decided on the stripped text: styling a blank body would yield // one escape-only row, which reads as a stray blank line under the header. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 0ddb73ed9c..ffcf69dbb6 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -88,6 +88,7 @@ import { runningPhaseGlyph, STATUS_ANIMATION_INTERVAL_MS, STATUS_FADE_MS, + StepTimingTracker, TIMING_BUCKET_GLYPHS, type StepPosition, } from './chat/timing.ts' @@ -358,6 +359,9 @@ export function createTuiChat( let toolsVisibility: ToolCardVisibility = 'collapsed' let streaming: StreamingAssistantComponent | undefined let completedStreaming: StreamingAssistantComponent | undefined + // One shared accumulator serves every step's timing footer; per-footer + // replay of the whole log is quadratic on a long resumed session. + const stepTimingTracker = new StepTimingTracker() // Assistant step components in model order per turn, for hidden-mode folding: // with tool cards hidden, a turn keeps one Assistant header and later steps // render as headerless continuations (see applyTurnFolding). @@ -769,6 +773,7 @@ export function createTuiChat( streaming = new StreamingAssistantComponent( position, () => agent.session.events, + stepTimingTracker, now, showReasoning, palette, diff --git a/packages/ui/tui/tests/timing-tracker.spec.ts b/packages/ui/tui/tests/timing-tracker.spec.ts new file mode 100644 index 0000000000..93eecb08e9 --- /dev/null +++ b/packages/ui/tui/tests/timing-tracker.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { StepTimingTracker } from '../src/chat/timing.ts' + +/** One completed two-phase step plus a tool call, in event-log order. */ +function stepEvents(turn: number, step: number, base: number, seq: number): SessionEvent[] { + return [ + { type: 'step/start', seq: seq, time: base, data: { turn, step } }, + { type: 'assistant/chunk', seq: seq + 1, time: base + 100, data: { turn, step, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } } }, + { type: 'assistant/chunk', seq: seq + 2, time: base + 300, data: { turn, step, chunk: { type: 'text-delta', index: 1, text: 'hi' } } }, + { type: 'tool/call', seq: seq + 3, time: base + 450, data: { turn, step, callId: 'call-1', name: 'bash', arguments: '{}' } }, + { type: 'step/end', seq: seq + 4, time: base + 700, data: { turn, step } }, + ] as SessionEvent[] +} + +describe('StepTimingTracker', () => { + it('accumulates each phase from the step lifecycle', () => { + const tracker = new StepTimingTracker() + const events = stepEvents(1, 1, 1_000, 0) + expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 2_000)).toEqual({ + ttft: 100, // step/start -> first chunk + thinking: 200, // reasoning block-start -> text delta + responding: 150, // text delta -> tool call + tools: 250, // tool call -> step/end + }) + }) + + it('returns empty totals for a step that never started', () => { + const tracker = new StepTimingTracker() + expect(tracker.totalsAt(stepEvents(1, 1, 1_000, 0), { turn: 9, step: 9 }, 2_000)).toEqual({ + ttft: 0, thinking: 0, responding: 0, tools: 0, + }) + }) + + it('accumulates the open bucket to the query clock without mutating tracked state', () => { + const tracker = new StepTimingTracker() + const events = [ + { type: 'step/start', seq: 0, time: 1_000, data: { turn: 1, step: 1 } }, + ] as SessionEvent[] + expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 1_250).ttft).toBe(250) + expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 1_400).ttft).toBe(400) + }) + + it('matches a fresh replay when queried incrementally across appends', () => { + const incremental = new StepTimingTracker() + const first = stepEvents(1, 1, 1_000, 0) + incremental.totalsAt(first, { turn: 1, step: 1 }, 5_000) + const events = [...first, ...stepEvents(1, 2, 3_000, first.length)] + const fresh = new StepTimingTracker() + for (const position of [{ turn: 1, step: 1 }, { turn: 1, step: 2 }]) { + expect(incremental.totalsAt(events, position, 5_000)).toEqual(fresh.totalsAt(events, position, 5_000)) + } + }) + + it('serves interleaved steps from one shared scan', () => { + const tracker = new StepTimingTracker() + const events = [ + { type: 'step/start', seq: 0, time: 1_000, data: { turn: 1, step: 1 } }, + { type: 'step/start', seq: 1, time: 1_100, data: { turn: 1, step: 2 } }, + { type: 'assistant/chunk', seq: 2, time: 1_200, data: { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'x' } } }, + { type: 'step/end', seq: 3, time: 1_500, data: { turn: 1, step: 2 } }, + { type: 'step/end', seq: 4, time: 1_600, data: { turn: 1, step: 1 } }, + ] as SessionEvent[] + expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 9_000)).toEqual({ ttft: 600, thinking: 0, responding: 0, tools: 0 }) + expect(tracker.totalsAt(events, { turn: 1, step: 2 }, 9_000)).toEqual({ ttft: 100, thinking: 0, responding: 300, tools: 0 }) + }) + + it('keeps the first step/start when a duplicate arrives while the step is open', () => { + const tracker = new StepTimingTracker() + const events = [ + { type: 'step/start', seq: 0, time: 1_000, data: { turn: 1, step: 1 } }, + { type: 'step/start', seq: 1, time: 1_500, data: { turn: 1, step: 1 } }, + ] as SessionEvent[] + expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 2_000).ttft).toBe(1_000) + }) + + it('ignores same-coordinate events after the step closed', () => { + const tracker = new StepTimingTracker() + const events = [ + ...stepEvents(1, 1, 1_000, 0), + // A stray duplicate start and a late chunk reuse the coordinates; the + // closed step's totals stay pinned. + { type: 'step/start', seq: 5, time: 9_000, data: { turn: 1, step: 1 } }, + { type: 'assistant/chunk', seq: 6, time: 9_100, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'late' } } }, + ] as SessionEvent[] + expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 10_000)).toEqual({ + ttft: 100, thinking: 200, responding: 150, tools: 250, + }) + }) +}) diff --git a/packages/ui/tui/tests/transcript-card-cache.spec.ts b/packages/ui/tui/tests/transcript-card-cache.spec.ts new file mode 100644 index 0000000000..9f484f21b9 --- /dev/null +++ b/packages/ui/tui/tests/transcript-card-cache.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { createToolResultMessage, CallId } from '@deepseek-ai/dsh-llm' +import { ContextCardComponent, ToolCardComponent } from '../src/components/transcript.ts' +import { parseArguments } from '../src/components/content.ts' +import { createPalette, markdownTheme } from '../src/components/theme.ts' + +const palette = createPalette(false) +const mdTheme = markdownTheme(palette) + +function toolCard(): ToolCardComponent { + return new ToolCardComponent('bash', parseArguments('{"command":"ls"}'), undefined, 10, 2_000, palette, mdTheme) +} + +function toolResult(text: string): Extract['data'] { + const message = createToolResultMessage({ + callId: CallId('call-1'), + content: [{ type: 'text', text }], + isError: false, + }) + return { turn: 1, step: 1, message } +} + +// pi-tui re-renders every component each frame; the cards must serve repeat +// same-width renders from their line cache and drop it on every state change. +describe('transcript card render caches', () => { + it('tool card: repeat same-width renders return the cached rows', () => { + const card = toolCard() + const first = card.render(80) + expect(card.render(80)).toBe(first) + const narrower = card.render(60) + expect(narrower).not.toBe(first) + expect(card.render(60)).toBe(narrower) + }) + + it('tool card: result, visibility, and invalidate() each drop the cache', () => { + const card = toolCard() + const pending = card.render(80) + card.updateResult(toolResult('output line')) + const settled = card.render(80) + expect(settled).not.toBe(pending) + expect(settled.join('\n')).toContain('●') + + card.setVisibility('hidden') + expect(card.render(80)).toEqual([]) + + card.setVisibility('collapsed') + const restored = card.render(80) + expect(restored).toEqual(settled) + expect(restored).not.toBe(settled) + + card.invalidate() + expect(card.render(80)).not.toBe(restored) + }) + + it('context card: caches by width and drops on setExpanded and invalidate()', () => { + const card = new ContextCardComponent('workspace-context', 'line one\nline two', 10, palette) + const first = card.render(80) + expect(card.render(80)).toBe(first) + + // Same width across the mutation, so a hit here would prove a kept cache. + card.setExpanded(true) + const expanded = card.render(80) + expect(expanded).not.toBe(first) + expect(card.render(80)).toBe(expanded) + + card.invalidate() + const reRendered = card.render(80) + expect(reRendered).not.toBe(expanded) + expect(reRendered).toEqual(expanded) + + expect(card.render(60)).not.toBe(reRendered) + }) +})