| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418 |
- import { mkdtempSync } from 'node:fs'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import { describe, expect, it, vi } from 'vitest'
- import { Context } from 'cordis'
- import { CallId } from '@deepseek-ai/dsh-llm'
- import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
- import ToolRegistry from '@deepseek-ai/dsh-tools'
- import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
- import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
- import { renderResult } from '@deepseek-ai/dsh-tool-bash'
- const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
- async function setup() {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRegistry)
- await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
- ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 }
- await ctx.plugin(ToolBash)
- return ctx
- }
- let callCounter = 0
- function call(ctx: Context, name: string, args: unknown) {
- return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args })
- }
- function text(result: { content: { type: string; text?: string }[] }): string {
- return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
- }
- describe('bash tool', () => {
- it('returns stdout for a successful command', async () => {
- const ctx = await setup()
- const result = await call(ctx, 'bash', { command: 'echo hello', description: 'test command' })
- expect(result.isError).toBe(false)
- expect(text(result)).toBe('hello\n')
- })
- it('reports (no output) for silent commands', async () => {
- const ctx = await setup()
- const result = await call(ctx, 'bash', { command: 'true', description: 'test command' })
- expect(text(result)).toBe('(no output)')
- })
- it('marks stderr sections', async () => {
- const ctx = await setup()
- const result = await call(ctx, 'bash', { command: 'echo out; echo err >&2', description: 'test command' })
- expect(text(result)).toBe('out\n[stderr]\nerr\n')
- expect(result.isError).toBe(false)
- })
- it('reports non-zero exits without isError', async () => {
- const ctx = await setup()
- const result = await call(ctx, 'bash', { command: 'echo failing; exit 3', description: 'test command' })
- expect(result.isError).toBe(false)
- expect(text(result)).toBe('failing\n[exit code: 3]')
- })
- it('reports timeout kills with both markers (timeout first)', async () => {
- const ctx = await setup()
- const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', timeoutMs: 100 })
- expect(result.isError).toBe(false)
- expect(text(result)).toBe('(no output)\n[timed out after 100ms]\n[killed by signal: SIGTERM]')
- })
- it('reports a timeout even when the command traps the signal and exits 0', async () => {
- // The signal-independent timeout marker: a trapped SIGTERM that exits 0
- // after our timer fired must NOT look like a clean success. (bash may
- // print "Terminated" to stderr for the killed sleep — environment
- // dependent — so assert the marker, not the exact body.)
- const ctx = await setup()
- const result = await call(ctx, 'bash', { command: 'trap "exit 0" TERM; sleep 60', description: 'test command', timeoutMs: 100 })
- expect(result.isError).toBe(false)
- expect(text(result)).toContain('[timed out after 100ms]')
- expect(text(result)).not.toContain('[exit code:')
- })
- it('reports truncation with the spill path', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRegistry)
- await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100 })
- ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 }
- await ctx.plugin(ToolBash)
- const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' })
- expect(text(result)).toContain('[output truncated; full output: ')
- expect(text(result)).toContain('line-0100')
- })
- it('honors workdir', async () => {
- const ctx = await setup()
- const result = await call(ctx, 'bash', { command: 'pwd', description: 'test command', workdir: '/tmp' })
- expect(text(result).trim()).toMatch(/\/tmp$/)
- })
- it('surfaces spawn failures as isError', async () => {
- const ctx = await setup()
- const result = await call(ctx, 'bash', { command: 'true', description: 'test command', workdir: '/nonexistent-dsh' })
- expect(result.isError).toBe(true)
- expect(text(result)).toMatch(/ENOENT/)
- })
- it('surfaces aborts as isError', async () => {
- const ctx = await setup()
- const controller = new AbortController()
- const pending = ctx.tools.execute({
- callId: CallId('call-abort'),
- name: 'bash',
- arguments: { command: 'sleep 60', description: 'test command' },
- signal: controller.signal,
- })
- setTimeout(() => { controller.abort() }, 50)
- const result = await pending
- expect(result.isError).toBe(true)
- expect(text(result)).toMatch(/aborted/)
- })
- // Type and required-key violations are now rejected by the harness
- // (defineTool validates against the SchemaSpec — ADR 0011) before execute.
- it.each([
- [{}, /missing required property "command"/],
- [{ command: 42, description: 'd' }, /"command" must be a string/],
- [{ command: 'x' }, /missing required property "description"/],
- [{ command: 'x', description: 7 }, /"description" must be a string/],
- [{ command: 'x', description: 'd', timeoutMs: 'soon' }, /"timeoutMs" must be a number/],
- [{ command: 'x', description: 'd', workdir: 7 }, /"workdir" must be a string/],
- [{ command: 'x', description: 'd', run_in_background: 'yes' }, /"run_in_background" must be a boolean/],
- ])('rejects schema-invalid args %j', async (args, pattern) => {
- const ctx = await setup()
- const result = await call(ctx, 'bash', args)
- expect(result.isError).toBe(true)
- expect(text(result)).toMatch(pattern)
- })
- // Value constraints the SchemaSpec can't express stay in the tool body.
- it.each([
- [{ command: ' ', description: 'd' }, /invalid command/],
- [{ command: 'x', description: ' ' }, /invalid description/],
- [{ command: 'x', description: 'd', timeoutMs: -1 }, /invalid timeoutMs/],
- [{ command: 'x', description: 'd', timeoutMs: Number.NaN }, /invalid timeoutMs/],
- ])('rejects value-invalid args %j', async (args, pattern) => {
- const ctx = await setup()
- const result = await call(ctx, 'bash', args)
- expect(result.isError).toBe(true)
- expect(text(result)).toMatch(pattern)
- })
- it('registers all three schemas in the system prompt assembly', async () => {
- const ctx = await setup()
- const names = ctx.tools.schemas().map(schema => schema.name)
- expect(names).toEqual(['bash', 'bash_output', 'bash_kill'])
- const bashSchema = ctx.tools.schemas()[0]!
- expect(bashSchema.parameters).toMatchObject({
- type: 'object',
- required: ['command', 'description'],
- })
- })
- it('unregisters everything when the plugin fiber is disposed (HMR safety)', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRegistry)
- await ctx.plugin(LocalBashExecutor, {})
- const fiber = await ctx.plugin(ToolBash)
- expect(ctx.tools.schemas()).toHaveLength(3)
- await fiber.dispose()
- expect(ctx.tools.schemas()).toHaveLength(0)
- })
- it('tools depend on the executor: no registration without ctx.bash', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRegistry)
- // inject: ['tools', 'bash'] keeps the plugin pending until bash exists.
- await ctx.plugin(ToolBash)
- expect(ctx.tools.schemas()).toHaveLength(0)
- await ctx.plugin(LocalBashExecutor, {})
- await new Promise(resolve => setTimeout(resolve, 0))
- expect(ctx.tools.schemas()).toHaveLength(3)
- })
- })
- describe('background tools', () => {
- it('bash with run_in_background returns a task id immediately', async () => {
- const ctx = await setup()
- const result = await call(ctx, 'bash', { command: 'sleep 0.2; echo bg-done', description: 'test command', run_in_background: true })
- expect(result.isError).toBe(false)
- expect(text(result)).toMatch(/^started background task bash-\d+$/)
- })
- it('bash_output polls incrementally and reports status', async () => {
- const ctx = await setup()
- const started = await call(ctx, 'bash', { command: 'echo first; sleep 0.3; echo second', description: 'test command', run_in_background: true })
- const id = /task (bash-\d+)/.exec(text(started))![1]!
- await new Promise(resolve => setTimeout(resolve, 150))
- const first = await call(ctx, 'bash_output', { task_id: id })
- expect(text(first)).toContain('first')
- expect(text(first)).toContain('[status: running]')
- await ctx.bash.get(id)!.done
- const second = await call(ctx, 'bash_output', { task_id: id })
- expect(text(second)).toContain('second')
- expect(text(second)).not.toContain('first')
- expect(text(second)).toContain('[status: completed, exit code: 0]')
- const third = await call(ctx, 'bash_output', { task_id: id })
- expect(text(third)).toContain('(no new output)')
- })
- it('bash_output flags lossy reads with spill paths', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRegistry)
- await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100 })
- ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 }
- await ctx.plugin(ToolBash)
- const started = await call(ctx, 'bash', { command: 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', description: 'test command', run_in_background: true })
- const id = /task (bash-\d+)/.exec(text(started))![1]!
- await ctx.bash.get(id)!.done
- const read = await call(ctx, 'bash_output', { task_id: id })
- expect(text(read)).toContain('[some output was dropped from memory; full output: ')
- })
- it('bash_kill stops a running task; repeat reports already-finished', async () => {
- const ctx = await setup()
- const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
- const id = /task (bash-\d+)/.exec(text(started))![1]!
- const killed = await call(ctx, 'bash_kill', { task_id: id })
- expect(text(killed)).toBe(`killed background task ${id}`)
- await ctx.bash.get(id)!.done
- const again = await call(ctx, 'bash_kill', { task_id: id })
- expect(text(again)).toBe(`task ${id} had already finished`)
- const status = await call(ctx, 'bash_output', { task_id: id })
- expect(text(status)).toContain('[status: killed by SIGTERM]')
- })
- it('unknown task ids are isError for both tools', async () => {
- const ctx = await setup()
- const read = await call(ctx, 'bash_output', { task_id: 'bash-999' })
- expect(read.isError).toBe(true)
- expect(text(read)).toMatch(/unknown bash task/)
- const kill = await call(ctx, 'bash_kill', { task_id: 'bash-999' })
- expect(kill.isError).toBe(true)
- })
- it.each([
- ['bash_output', {}, /missing required property "task_id"/],
- ['bash_output', { task_id: 9 }, /"task_id" must be a string/],
- ['bash_kill', { task_id: '' }, /invalid task_id/],
- ])('%s rejects invalid task_id %j', async (tool, args, pattern) => {
- const ctx = await setup()
- const result = await call(ctx, tool, args)
- expect(result.isError).toBe(true)
- expect(text(result)).toMatch(pattern)
- })
- it('injects a completion notice into the owning agent', async () => {
- const ctx = await setup()
- const inject = vi.fn()
- const agent = { inject } as unknown as import('@deepseek-ai/dsh-agent').Agent
- const started = await ctx.tools.execute({
- callId: CallId('call-bg'),
- name: 'bash',
- arguments: { command: 'true', description: 'test command', run_in_background: true },
- agent,
- })
- const id = /task (bash-\d+)/.exec(text(started))![1]!
- await ctx.bash.get(id)!.done
- expect(inject).toHaveBeenCalledTimes(1)
- const [content, options] = inject.mock.calls[0] as [
- { type: string; text: string }[],
- { source: { kind: string; plugin: string } },
- ]
- expect(content[0]!.text).toContain(`background bash task ${id} finished`)
- expect(content[0]!.text).toContain('bash_output')
- expect(options.source).toEqual({ kind: 'plugin', plugin: 'tool-bash' })
- })
- it('swallows ONLY the disposed-agent inject error', async () => {
- const ctx = await setup()
- const agent = {
- inject: () => { throw new Error('agent "x" is disposed') },
- } as unknown as import('@deepseek-ai/dsh-agent').Agent
- const started = await ctx.tools.execute({
- callId: CallId('call-bg2'),
- name: 'bash',
- arguments: { command: 'true', description: 'test command', run_in_background: true },
- agent,
- })
- const id = /task (bash-\d+)/.exec(text(started))![1]!
- await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
- })
- it('rethrows a non-disposed inject failure (not blindly swallowed)', async () => {
- const ctx = await setup()
- // A real bug in inject (not the benign disposed race) must surface — the
- // base-class notifier contains it (logs, does not reject task.done), but
- // the listener itself must have thrown rather than silently eaten it.
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
- try {
- const agent = {
- inject: () => { throw new Error('unexpected inject bug') },
- } as unknown as import('@deepseek-ai/dsh-agent').Agent
- const started = await ctx.tools.execute({
- callId: CallId('call-bg3'),
- name: 'bash',
- arguments: { command: 'true', description: 'test command', run_in_background: true },
- agent,
- })
- const id = /task (bash-\d+)/.exec(text(started))![1]!
- await ctx.bash.get(id)!.done
- // notifyTaskDone caught and logged the rethrown error.
- expect(errorSpy).toHaveBeenCalled()
- const logged = errorSpy.mock.calls.flat().some(arg => arg instanceof Error && arg.message === 'unexpected inject bug')
- expect(logged).toBe(true)
- } finally {
- errorSpy.mockRestore()
- }
- })
- it('does not notify when no agent owned the task', async () => {
- const ctx = await setup()
- const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
- const id = /task (bash-\d+)/.exec(text(started))![1]!
- await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
- })
- })
- describe('renderResult', () => {
- const base = {
- exitCode: 0 as number | null,
- signal: null as NodeJS.Signals | null,
- timedOut: false,
- aborted: false,
- timeoutMs: 1000,
- stdout: { text: '', truncated: false },
- stderr: { text: '', truncated: false },
- }
- it('renders stderr-only output without a stdout prefix', () => {
- expect(renderResult({ ...base, stderr: { text: 'err\n', truncated: false } }))
- .toBe('[stderr]\nerr\n')
- })
- it('adds a separator when stdout does not end with a newline', () => {
- expect(renderResult({
- ...base,
- stdout: { text: 'out', truncated: false },
- stderr: { text: 'err', truncated: false },
- })).toBe('out\n[stderr]\nerr')
- })
- it('appends exit-code markers after a newline for unterminated output', () => {
- expect(renderResult({ ...base, exitCode: 7, stdout: { text: 'x', truncated: false } }))
- .toBe('x\n[exit code: 7]')
- })
- it('renders signal kills without the timeout marker when not timed out', () => {
- expect(renderResult({ ...base, exitCode: null, signal: 'SIGKILL' }))
- .toBe('(no output)\n[killed by signal: SIGKILL]')
- })
- it('reports a timeout that exited 0 (trapped signal) without a kill marker', () => {
- expect(renderResult({ ...base, exitCode: 0, signal: null, timedOut: true }))
- .toBe('(no output)\n[timed out after 1000ms]')
- })
- it('orders the timeout marker before a kill marker', () => {
- expect(renderResult({ ...base, exitCode: null, signal: 'SIGTERM', timedOut: true }))
- .toBe('(no output)\n[timed out after 1000ms]\n[killed by signal: SIGTERM]')
- })
- it('notes truncation with a fallback when the spill path is missing', () => {
- expect(renderResult({ ...base, stdout: { text: 'tail', truncated: true } }))
- .toBe('tail\n[output truncated; full output: (unavailable)]')
- })
- })
- describe('status lines', () => {
- it('reports kills without a recorded signal (executor raced process exit)', async () => {
- const ctx = await setup()
- const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
- const id = /task (bash-\d+)/.exec(text(started))![1]!
- const task = ctx.bash.get(id)!
- await call(ctx, 'bash_kill', { task_id: id })
- await task.done
- // Simulate the variant where the close event carried no signal.
- task.signal = null
- const read = await call(ctx, 'bash_output', { task_id: id })
- expect(text(read)).toContain('[status: killed]')
- })
- it('reports completed tasks with a null exit code as exit 0', async () => {
- const ctx = await setup()
- const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
- const id = /task (bash-\d+)/.exec(text(started))![1]!
- const task = ctx.bash.get(id)!
- await task.done
- // Defensive: completed tasks always carry an exit code in practice; the
- // ?? 0 fallback covers task shapes from other executor implementations.
- task.exitCode = null
- const read = await call(ctx, 'bash_output', { task_id: id })
- expect(text(read)).toContain('[status: completed, exit code: 0]')
- })
- })
|