Merge remote-tracking branch 'origin/codex/goal-session' into codex/commands
This commit is contained in:
@@ -49,6 +49,19 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
expect(bash?.source).toBe('packages/bash/tool-bash/src/index.ts')
|
||||
})
|
||||
|
||||
it('harvests search tools without depending on the generator process PATH', async () => {
|
||||
const oldPath = process.env.PATH
|
||||
try {
|
||||
process.env.PATH = ''
|
||||
const catalog = await collectToolCatalog()
|
||||
const search = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-fs-search')
|
||||
expect(search?.schemas.map(s => s.name).sort()).toEqual(['glob', 'grep'])
|
||||
} finally {
|
||||
if (oldPath === undefined) delete process.env.PATH
|
||||
else process.env.PATH = oldPath
|
||||
}
|
||||
})
|
||||
|
||||
it('records the shipped `subagent_fork` alias in a note (config-driven tool name)', async () => {
|
||||
// `tool-subagent`'s registered name is the load-time `toolName` config, so the shipped
|
||||
// agents surface this one package as both `subagent` and `subagent_fork`.
|
||||
|
||||
@@ -8,9 +8,9 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
|
||||
| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
|
||||
| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
|
||||
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
|
||||
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools, backed by fixed ripgrep commands through the bash seam (`ctx.bash`), NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
|
||||
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
|
||||
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
|
||||
|
||||
## No timeouts on file IO
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# @deepseek-ai/dsh-tool-fs-search
|
||||
|
||||
The **model-facing filesystem discovery tools** — `glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. 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 bounded, workdir-relative result. 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` — 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 bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
|
||||
|
||||
```ts ignore-check
|
||||
// Default deployment: a bash executor, then the discovery tools.
|
||||
// Default deployment: a bash executor whose PATH includes rg, then the discovery tools.
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
|
||||
await ctx.plugin(ToolFsSearch) // this package — registers glob/grep
|
||||
await ctx.plugin(ToolFsSearch) // this package — conditionally registers glob/grep
|
||||
// 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.
|
||||
|
||||
## Deployment requirement: co-located bash + filesystem
|
||||
## Deployment requirement: rg + co-located bash/filesystem
|
||||
|
||||
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 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 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.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -43,7 +43,7 @@ Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMax
|
||||
|
||||
## 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` (missing `rg`, 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` (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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -51,7 +51,7 @@ Search failures carry the package-owned `SearchError` (a `HarnessError` subclass
|
||||
|
||||
#### What the model sees
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
##### Glob guidance
|
||||
|
||||
@@ -67,7 +67,7 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed guidance cost per request while the plugin is active.
|
||||
Fixed guidance cost per request while the tools are registered.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -77,7 +77,7 @@ Prefix-stable while the plugin scope and guidance text are unchanged. Activation
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) while this surface is visible.
|
||||
The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) after the load-time `rg` probe succeeds and while this surface is visible.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -118,5 +118,5 @@ 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 or incompatible `rg` executable fails calls with `SEARCH_FAILED`; remote or virtual filesystems need a co-located executor or another search consumer.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The model-facing filesystem discovery tool suite (`glob`, `grep`) over the
|
||||
* bash executor seam (`ctx.bash`). This single plugin registers both tools.
|
||||
* bash executor seam (`ctx.bash`). This single plugin registers both tools
|
||||
* only when the mounted bash executor can find `rg` on its `PATH`.
|
||||
*
|
||||
* ## Bash-backed, not a `ctx.fs` provider method
|
||||
*
|
||||
@@ -12,9 +13,11 @@
|
||||
* 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. The package injects `tools`, `systemPrompt`, and
|
||||
* `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read opportunistically
|
||||
* with `ctx.get()` because formatted-result spill is optional.
|
||||
* 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
|
||||
* 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
|
||||
@@ -80,6 +83,9 @@ export const Config: z<Config> = z.object({
|
||||
/** The shape after schemastery applied the defaults. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** 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) {
|
||||
@@ -87,8 +93,38 @@ function assertPositiveInteger(name: string, value: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the `glob`/`grep` filesystem discovery tool suite. */
|
||||
export function apply(ctx: Context, config: Config): 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<boolean> {
|
||||
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.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
// schemastery (Config) has already filled every defaulted field.
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveInteger('globMaxResults', resolved.globMaxResults)
|
||||
@@ -96,6 +132,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes)
|
||||
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, {
|
||||
maxResults: resolved.globMaxResults,
|
||||
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
|
||||
|
||||
@@ -18,9 +18,48 @@ 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 { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
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,
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
}
|
||||
|
||||
override run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
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)
|
||||
@@ -38,7 +77,7 @@ describe('dsh-tool-fs-search real-load-path guard', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await ctx.plugin(ProbeSuccessBashExecutor)
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters<Context['plugin']>[0]
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* 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 — 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
toWorkdirRelative,
|
||||
} from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
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>): BashRunResult {
|
||||
return {
|
||||
@@ -52,13 +54,18 @@ function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunR
|
||||
* create a background task.
|
||||
*/
|
||||
class FakeBash extends BashExecutor {
|
||||
probeRequests: BashExecRequest[] = []
|
||||
probeSpecs: BashExecSpec[] = []
|
||||
requests: BashExecRequest[] = []
|
||||
specs: BashExecSpec[] = []
|
||||
startCalls = 0
|
||||
probeResult: BashRunResult = runResult('')
|
||||
probeError?: Error
|
||||
handler: (spec: BashExecSpec) => BashRunResult = () => runResult('')
|
||||
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
this.requests.push(request)
|
||||
if (request.command === RG_PROBE_COMMAND) this.probeRequests.push(request)
|
||||
else this.requests.push(request)
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? '/work',
|
||||
@@ -68,9 +75,14 @@ class FakeBash extends BashExecutor {
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
}
|
||||
override run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
override async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
if (spec.command === RG_PROBE_COMMAND) {
|
||||
this.probeSpecs.push(spec)
|
||||
if (this.probeError) throw this.probeError
|
||||
return this.probeResult
|
||||
}
|
||||
this.specs.push(spec)
|
||||
return Promise.resolve(this.handler(spec))
|
||||
return this.handler(spec)
|
||||
}
|
||||
override start(): BashProcess {
|
||||
this.startCalls++
|
||||
@@ -97,18 +109,36 @@ class FakeSpill extends SpillStore {
|
||||
interface SetupOptions {
|
||||
config?: ToolFsSearch.Config
|
||||
spill?: boolean
|
||||
probeError?: Error
|
||||
probeResult?: BashRunResult
|
||||
}
|
||||
|
||||
async function setup(options: SetupOptions = {}) {
|
||||
const ctx = new Context()
|
||||
const warnings: string[] = []
|
||||
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
|
||||
if (options.spill === true) await ctx.plugin(FakeSpill)
|
||||
const fiber = await ctx.plugin(ToolFsSearch, options.config)
|
||||
const bash = ctx.bash as FakeBash
|
||||
const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined
|
||||
return { ctx, bash, spill, fiber }
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
/** A stand-in agent whose session header carries the given cwd (and a stable id). */
|
||||
@@ -136,13 +166,37 @@ function matchLine(path: string, lineNumber: number, lineText: string): string {
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers glob and grep with their prompt sections', async () => {
|
||||
const { ctx } = await setup()
|
||||
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')
|
||||
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')
|
||||
expect(prompt).toContain('Use the grep tool')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
Reference in New Issue
Block a user