tools.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. import { mkdtempSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import { Context } from 'cordis'
  6. import { CallId } from '@deepseek-ai/dsh-llm'
  7. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  8. import ToolRegistry from '@deepseek-ai/dsh-tools'
  9. import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
  10. import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
  11. import { renderResult } from '@deepseek-ai/dsh-tool-bash'
  12. const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
  13. async function setup() {
  14. const ctx = new Context()
  15. await ctx.plugin(SystemPrompt)
  16. await ctx.plugin(ToolRegistry)
  17. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  18. ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 }
  19. await ctx.plugin(ToolBash)
  20. return ctx
  21. }
  22. let callCounter = 0
  23. function call(ctx: Context, name: string, args: unknown) {
  24. return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args })
  25. }
  26. function text(result: { content: { type: string; text?: string }[] }): string {
  27. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  28. }
  29. describe('bash tool', () => {
  30. it('returns stdout for a successful command', async () => {
  31. const ctx = await setup()
  32. const result = await call(ctx, 'bash', { command: 'echo hello', description: 'test command' })
  33. expect(result.isError).toBe(false)
  34. expect(text(result)).toBe('hello\n')
  35. })
  36. it('reports (no output) for silent commands', async () => {
  37. const ctx = await setup()
  38. const result = await call(ctx, 'bash', { command: 'true', description: 'test command' })
  39. expect(text(result)).toBe('(no output)')
  40. })
  41. it('marks stderr sections', async () => {
  42. const ctx = await setup()
  43. const result = await call(ctx, 'bash', { command: 'echo out; echo err >&2', description: 'test command' })
  44. expect(text(result)).toBe('out\n[stderr]\nerr\n')
  45. expect(result.isError).toBe(false)
  46. })
  47. it('reports non-zero exits without isError', async () => {
  48. const ctx = await setup()
  49. const result = await call(ctx, 'bash', { command: 'echo failing; exit 3', description: 'test command' })
  50. expect(result.isError).toBe(false)
  51. expect(text(result)).toBe('failing\n[exit code: 3]')
  52. })
  53. it('reports timeout kills with both markers (timeout first)', async () => {
  54. const ctx = await setup()
  55. const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', timeoutMs: 100 })
  56. expect(result.isError).toBe(false)
  57. expect(text(result)).toBe('(no output)\n[timed out after 100ms]\n[killed by signal: SIGTERM]')
  58. })
  59. it('reports a timeout even when the command traps the signal and exits 0', async () => {
  60. // The signal-independent timeout marker: a trapped SIGTERM that exits 0
  61. // after our timer fired must NOT look like a clean success. (bash may
  62. // print "Terminated" to stderr for the killed sleep — environment
  63. // dependent — so assert the marker, not the exact body.)
  64. const ctx = await setup()
  65. const result = await call(ctx, 'bash', { command: 'trap "exit 0" TERM; sleep 60', description: 'test command', timeoutMs: 100 })
  66. expect(result.isError).toBe(false)
  67. expect(text(result)).toContain('[timed out after 100ms]')
  68. expect(text(result)).not.toContain('[exit code:')
  69. })
  70. it('reports truncation with the spill path', async () => {
  71. const ctx = new Context()
  72. await ctx.plugin(SystemPrompt)
  73. await ctx.plugin(ToolRegistry)
  74. await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100 })
  75. ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 }
  76. await ctx.plugin(ToolBash)
  77. const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' })
  78. expect(text(result)).toContain('[output truncated; full output: ')
  79. expect(text(result)).toContain('line-0100')
  80. })
  81. it('honors workdir', async () => {
  82. const ctx = await setup()
  83. const result = await call(ctx, 'bash', { command: 'pwd', description: 'test command', workdir: '/tmp' })
  84. expect(text(result).trim()).toMatch(/\/tmp$/)
  85. })
  86. it('surfaces spawn failures as isError', async () => {
  87. const ctx = await setup()
  88. const result = await call(ctx, 'bash', { command: 'true', description: 'test command', workdir: '/nonexistent-dsh' })
  89. expect(result.isError).toBe(true)
  90. expect(text(result)).toMatch(/ENOENT/)
  91. })
  92. it('surfaces aborts as isError', async () => {
  93. const ctx = await setup()
  94. const controller = new AbortController()
  95. const pending = ctx.tools.execute({
  96. callId: CallId('call-abort'),
  97. name: 'bash',
  98. arguments: { command: 'sleep 60', description: 'test command' },
  99. signal: controller.signal,
  100. })
  101. setTimeout(() => { controller.abort() }, 50)
  102. const result = await pending
  103. expect(result.isError).toBe(true)
  104. expect(text(result)).toMatch(/aborted/)
  105. })
  106. // Type and required-key violations are now rejected by the harness
  107. // (defineTool validates against the SchemaSpec — ADR 0011) before execute.
  108. it.each([
  109. [{}, /missing required property "command"/],
  110. [{ command: 42, description: 'd' }, /"command" must be a string/],
  111. [{ command: 'x' }, /missing required property "description"/],
  112. [{ command: 'x', description: 7 }, /"description" must be a string/],
  113. [{ command: 'x', description: 'd', timeoutMs: 'soon' }, /"timeoutMs" must be a number/],
  114. [{ command: 'x', description: 'd', workdir: 7 }, /"workdir" must be a string/],
  115. [{ command: 'x', description: 'd', run_in_background: 'yes' }, /"run_in_background" must be a boolean/],
  116. ])('rejects schema-invalid args %j', async (args, pattern) => {
  117. const ctx = await setup()
  118. const result = await call(ctx, 'bash', args)
  119. expect(result.isError).toBe(true)
  120. expect(text(result)).toMatch(pattern)
  121. })
  122. // Value constraints the SchemaSpec can't express stay in the tool body.
  123. it.each([
  124. [{ command: ' ', description: 'd' }, /invalid command/],
  125. [{ command: 'x', description: ' ' }, /invalid description/],
  126. [{ command: 'x', description: 'd', timeoutMs: -1 }, /invalid timeoutMs/],
  127. [{ command: 'x', description: 'd', timeoutMs: Number.NaN }, /invalid timeoutMs/],
  128. ])('rejects value-invalid args %j', async (args, pattern) => {
  129. const ctx = await setup()
  130. const result = await call(ctx, 'bash', args)
  131. expect(result.isError).toBe(true)
  132. expect(text(result)).toMatch(pattern)
  133. })
  134. it('registers all three schemas in the system prompt assembly', async () => {
  135. const ctx = await setup()
  136. const names = ctx.tools.schemas().map(schema => schema.name)
  137. expect(names).toEqual(['bash', 'bash_output', 'bash_kill'])
  138. const bashSchema = ctx.tools.schemas()[0]!
  139. expect(bashSchema.parameters).toMatchObject({
  140. type: 'object',
  141. required: ['command', 'description'],
  142. })
  143. })
  144. it('unregisters everything when the plugin fiber is disposed (HMR safety)', async () => {
  145. const ctx = new Context()
  146. await ctx.plugin(SystemPrompt)
  147. await ctx.plugin(ToolRegistry)
  148. await ctx.plugin(LocalBashExecutor, {})
  149. const fiber = await ctx.plugin(ToolBash)
  150. expect(ctx.tools.schemas()).toHaveLength(3)
  151. await fiber.dispose()
  152. expect(ctx.tools.schemas()).toHaveLength(0)
  153. })
  154. it('tools depend on the executor: no registration without ctx.bash', async () => {
  155. const ctx = new Context()
  156. await ctx.plugin(SystemPrompt)
  157. await ctx.plugin(ToolRegistry)
  158. // inject: ['tools', 'bash'] keeps the plugin pending until bash exists.
  159. await ctx.plugin(ToolBash)
  160. expect(ctx.tools.schemas()).toHaveLength(0)
  161. await ctx.plugin(LocalBashExecutor, {})
  162. await new Promise(resolve => setTimeout(resolve, 0))
  163. expect(ctx.tools.schemas()).toHaveLength(3)
  164. })
  165. })
  166. describe('background tools', () => {
  167. it('bash with run_in_background returns a task id immediately', async () => {
  168. const ctx = await setup()
  169. const result = await call(ctx, 'bash', { command: 'sleep 0.2; echo bg-done', description: 'test command', run_in_background: true })
  170. expect(result.isError).toBe(false)
  171. expect(text(result)).toMatch(/^started background task bash-\d+$/)
  172. })
  173. it('bash_output polls incrementally and reports status', async () => {
  174. const ctx = await setup()
  175. const started = await call(ctx, 'bash', { command: 'echo first; sleep 0.3; echo second', description: 'test command', run_in_background: true })
  176. const id = /task (bash-\d+)/.exec(text(started))![1]!
  177. await new Promise(resolve => setTimeout(resolve, 150))
  178. const first = await call(ctx, 'bash_output', { task_id: id })
  179. expect(text(first)).toContain('first')
  180. expect(text(first)).toContain('[status: running]')
  181. await ctx.bash.get(id)!.done
  182. const second = await call(ctx, 'bash_output', { task_id: id })
  183. expect(text(second)).toContain('second')
  184. expect(text(second)).not.toContain('first')
  185. expect(text(second)).toContain('[status: completed, exit code: 0]')
  186. const third = await call(ctx, 'bash_output', { task_id: id })
  187. expect(text(third)).toContain('(no new output)')
  188. })
  189. it('bash_output flags lossy reads with spill paths', async () => {
  190. const ctx = new Context()
  191. await ctx.plugin(SystemPrompt)
  192. await ctx.plugin(ToolRegistry)
  193. await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100 })
  194. ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 }
  195. await ctx.plugin(ToolBash)
  196. 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 })
  197. const id = /task (bash-\d+)/.exec(text(started))![1]!
  198. await ctx.bash.get(id)!.done
  199. const read = await call(ctx, 'bash_output', { task_id: id })
  200. expect(text(read)).toContain('[some output was dropped from memory; full output: ')
  201. })
  202. it('bash_kill stops a running task; repeat reports already-finished', async () => {
  203. const ctx = await setup()
  204. const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
  205. const id = /task (bash-\d+)/.exec(text(started))![1]!
  206. const killed = await call(ctx, 'bash_kill', { task_id: id })
  207. expect(text(killed)).toBe(`killed background task ${id}`)
  208. await ctx.bash.get(id)!.done
  209. const again = await call(ctx, 'bash_kill', { task_id: id })
  210. expect(text(again)).toBe(`task ${id} had already finished`)
  211. const status = await call(ctx, 'bash_output', { task_id: id })
  212. expect(text(status)).toContain('[status: killed by SIGTERM]')
  213. })
  214. it('unknown task ids are isError for both tools', async () => {
  215. const ctx = await setup()
  216. const read = await call(ctx, 'bash_output', { task_id: 'bash-999' })
  217. expect(read.isError).toBe(true)
  218. expect(text(read)).toMatch(/unknown bash task/)
  219. const kill = await call(ctx, 'bash_kill', { task_id: 'bash-999' })
  220. expect(kill.isError).toBe(true)
  221. })
  222. it.each([
  223. ['bash_output', {}, /missing required property "task_id"/],
  224. ['bash_output', { task_id: 9 }, /"task_id" must be a string/],
  225. ['bash_kill', { task_id: '' }, /invalid task_id/],
  226. ])('%s rejects invalid task_id %j', async (tool, args, pattern) => {
  227. const ctx = await setup()
  228. const result = await call(ctx, tool, args)
  229. expect(result.isError).toBe(true)
  230. expect(text(result)).toMatch(pattern)
  231. })
  232. it('injects a completion notice into the owning agent', async () => {
  233. const ctx = await setup()
  234. const inject = vi.fn()
  235. const agent = { inject } as unknown as import('@deepseek-ai/dsh-agent').Agent
  236. const started = await ctx.tools.execute({
  237. callId: CallId('call-bg'),
  238. name: 'bash',
  239. arguments: { command: 'true', description: 'test command', run_in_background: true },
  240. agent,
  241. })
  242. const id = /task (bash-\d+)/.exec(text(started))![1]!
  243. await ctx.bash.get(id)!.done
  244. expect(inject).toHaveBeenCalledTimes(1)
  245. const [content, options] = inject.mock.calls[0] as [
  246. { type: string; text: string }[],
  247. { source: { kind: string; plugin: string } },
  248. ]
  249. expect(content[0]!.text).toContain(`background bash task ${id} finished`)
  250. expect(content[0]!.text).toContain('bash_output')
  251. expect(options.source).toEqual({ kind: 'plugin', plugin: 'tool-bash' })
  252. })
  253. it('swallows ONLY the disposed-agent inject error', async () => {
  254. const ctx = await setup()
  255. const agent = {
  256. inject: () => { throw new Error('agent "x" is disposed') },
  257. } as unknown as import('@deepseek-ai/dsh-agent').Agent
  258. const started = await ctx.tools.execute({
  259. callId: CallId('call-bg2'),
  260. name: 'bash',
  261. arguments: { command: 'true', description: 'test command', run_in_background: true },
  262. agent,
  263. })
  264. const id = /task (bash-\d+)/.exec(text(started))![1]!
  265. await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
  266. })
  267. it('rethrows a non-disposed inject failure (not blindly swallowed)', async () => {
  268. const ctx = await setup()
  269. // A real bug in inject (not the benign disposed race) must surface — the
  270. // base-class notifier contains it (logs, does not reject task.done), but
  271. // the listener itself must have thrown rather than silently eaten it.
  272. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  273. try {
  274. const agent = {
  275. inject: () => { throw new Error('unexpected inject bug') },
  276. } as unknown as import('@deepseek-ai/dsh-agent').Agent
  277. const started = await ctx.tools.execute({
  278. callId: CallId('call-bg3'),
  279. name: 'bash',
  280. arguments: { command: 'true', description: 'test command', run_in_background: true },
  281. agent,
  282. })
  283. const id = /task (bash-\d+)/.exec(text(started))![1]!
  284. await ctx.bash.get(id)!.done
  285. // notifyTaskDone caught and logged the rethrown error.
  286. expect(errorSpy).toHaveBeenCalled()
  287. const logged = errorSpy.mock.calls.flat().some(arg => arg instanceof Error && arg.message === 'unexpected inject bug')
  288. expect(logged).toBe(true)
  289. } finally {
  290. errorSpy.mockRestore()
  291. }
  292. })
  293. it('does not notify when no agent owned the task', async () => {
  294. const ctx = await setup()
  295. const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
  296. const id = /task (bash-\d+)/.exec(text(started))![1]!
  297. await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
  298. })
  299. })
  300. describe('renderResult', () => {
  301. const base = {
  302. exitCode: 0 as number | null,
  303. signal: null as NodeJS.Signals | null,
  304. timedOut: false,
  305. aborted: false,
  306. timeoutMs: 1000,
  307. stdout: { text: '', truncated: false },
  308. stderr: { text: '', truncated: false },
  309. }
  310. it('renders stderr-only output without a stdout prefix', () => {
  311. expect(renderResult({ ...base, stderr: { text: 'err\n', truncated: false } }))
  312. .toBe('[stderr]\nerr\n')
  313. })
  314. it('adds a separator when stdout does not end with a newline', () => {
  315. expect(renderResult({
  316. ...base,
  317. stdout: { text: 'out', truncated: false },
  318. stderr: { text: 'err', truncated: false },
  319. })).toBe('out\n[stderr]\nerr')
  320. })
  321. it('appends exit-code markers after a newline for unterminated output', () => {
  322. expect(renderResult({ ...base, exitCode: 7, stdout: { text: 'x', truncated: false } }))
  323. .toBe('x\n[exit code: 7]')
  324. })
  325. it('renders signal kills without the timeout marker when not timed out', () => {
  326. expect(renderResult({ ...base, exitCode: null, signal: 'SIGKILL' }))
  327. .toBe('(no output)\n[killed by signal: SIGKILL]')
  328. })
  329. it('reports a timeout that exited 0 (trapped signal) without a kill marker', () => {
  330. expect(renderResult({ ...base, exitCode: 0, signal: null, timedOut: true }))
  331. .toBe('(no output)\n[timed out after 1000ms]')
  332. })
  333. it('orders the timeout marker before a kill marker', () => {
  334. expect(renderResult({ ...base, exitCode: null, signal: 'SIGTERM', timedOut: true }))
  335. .toBe('(no output)\n[timed out after 1000ms]\n[killed by signal: SIGTERM]')
  336. })
  337. it('notes truncation with a fallback when the spill path is missing', () => {
  338. expect(renderResult({ ...base, stdout: { text: 'tail', truncated: true } }))
  339. .toBe('tail\n[output truncated; full output: (unavailable)]')
  340. })
  341. })
  342. describe('status lines', () => {
  343. it('reports kills without a recorded signal (executor raced process exit)', async () => {
  344. const ctx = await setup()
  345. const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
  346. const id = /task (bash-\d+)/.exec(text(started))![1]!
  347. const task = ctx.bash.get(id)!
  348. await call(ctx, 'bash_kill', { task_id: id })
  349. await task.done
  350. // Simulate the variant where the close event carried no signal.
  351. task.signal = null
  352. const read = await call(ctx, 'bash_output', { task_id: id })
  353. expect(text(read)).toContain('[status: killed]')
  354. })
  355. it('reports completed tasks with a null exit code as exit 0', async () => {
  356. const ctx = await setup()
  357. const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
  358. const id = /task (bash-\d+)/.exec(text(started))![1]!
  359. const task = ctx.bash.get(id)!
  360. await task.done
  361. // Defensive: completed tasks always carry an exit code in practice; the
  362. // ?? 0 fallback covers task shapes from other executor implementations.
  363. task.exitCode = null
  364. const read = await call(ctx, 'bash_output', { task_id: id })
  365. expect(text(read)).toContain('[status: completed, exit code: 0]')
  366. })
  367. })