Merge remote-tracking branch 'origin/master' into codex/enforce-tool-cancellation
# Conflicts: # docs/cookbook/adding-a-tool.i18n.yaml # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/bash/tool-bash/src/index.ts # packages/core/agent-loop/README.md # packages/core/tools/README.md # packages/core/tools/tests/scoped.spec.ts # packages/fs/tool-fs-search/tests/tools.spec.ts # website/zh-CN/api/harness/events.md # website/zh-CN/api/harness/tools.md
This commit is contained in:
@@ -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'
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} 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>): BashRunResult {
|
||||
@@ -54,14 +55,19 @@ 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
|
||||
forwardSignal = true
|
||||
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',
|
||||
@@ -71,9 +77,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++
|
||||
@@ -100,18 +111,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). */
|
||||
@@ -140,13 +169,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