tools.spec.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713
  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 — the arg-validation RFC) 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, session: { header: { version: 1, id: 'bg', createdAt: 0 } } } 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. session: { header: { version: 1, id: 'bg', createdAt: 0 } },
  258. } as unknown as import('@deepseek-ai/dsh-agent').Agent
  259. const started = await ctx.tools.execute({
  260. callId: CallId('call-bg2'),
  261. name: 'bash',
  262. arguments: { command: 'true', description: 'test command', run_in_background: true },
  263. agent,
  264. })
  265. const id = /task (bash-\d+)/.exec(text(started))![1]!
  266. await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
  267. })
  268. it('rethrows a non-disposed inject failure (not blindly swallowed)', async () => {
  269. const ctx = await setup()
  270. // A real bug in inject (not the benign disposed race) must surface — the
  271. // base-class notifier contains it (logs, does not reject task.done), but
  272. // the listener itself must have thrown rather than silently eaten it.
  273. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  274. try {
  275. const agent = {
  276. inject: () => { throw new Error('unexpected inject bug') },
  277. session: { header: { version: 1, id: 'bg', createdAt: 0 } },
  278. } as unknown as import('@deepseek-ai/dsh-agent').Agent
  279. const started = await ctx.tools.execute({
  280. callId: CallId('call-bg3'),
  281. name: 'bash',
  282. arguments: { command: 'true', description: 'test command', run_in_background: true },
  283. agent,
  284. })
  285. const id = /task (bash-\d+)/.exec(text(started))![1]!
  286. await ctx.bash.get(id)!.done
  287. // notifyTaskDone caught and logged the rethrown error.
  288. expect(errorSpy).toHaveBeenCalled()
  289. const logged = errorSpy.mock.calls.flat().some(arg => arg instanceof Error && arg.message === 'unexpected inject bug')
  290. expect(logged).toBe(true)
  291. } finally {
  292. errorSpy.mockRestore()
  293. }
  294. })
  295. it('does not notify when no agent owned the task', async () => {
  296. const ctx = await setup()
  297. const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
  298. const id = /task (bash-\d+)/.exec(text(started))![1]!
  299. await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
  300. })
  301. })
  302. describe('background task ownership (cross-session isolation)', () => {
  303. /** Run a tool on behalf of a specific agent (sets exec.agent). */
  304. function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, name: string, args: unknown) {
  305. return ctx.tools.execute({ callId: CallId(`own-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
  306. }
  307. // Distinct identities — ownership is by agent object identity, not id.
  308. const fakeAgent = () => ({ inject: () => undefined, session: { header: { version: 1, id: 'bg', createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
  309. it('rejects bash_output/bash_kill for a task owned by a DIFFERENT agent', async () => {
  310. const ctx = await setup()
  311. const a = fakeAgent()
  312. const b = fakeAgent()
  313. // Agent A starts a long-running background task.
  314. const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
  315. const id = /task (bash-\d+)/.exec(text(started))![1]!
  316. // Agent B cannot read or kill A's task.
  317. const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
  318. expect(readByB.isError).toBe(true)
  319. expect(text(readByB)).toMatch(/belongs to another session/)
  320. const killByB = await callAs(ctx, b, 'bash_kill', { task_id: id })
  321. expect(killByB.isError).toBe(true)
  322. expect(text(killByB)).toMatch(/belongs to another session/)
  323. // The task is still running (B's kill did nothing) — A can still kill it.
  324. const killByA = await callAs(ctx, a, 'bash_kill', { task_id: id })
  325. expect(killByA.isError).toBe(false)
  326. expect(text(killByA)).toBe(`killed background task ${id}`)
  327. })
  328. it('the no-agent (non-loop) caller cannot access an owned task', async () => {
  329. const ctx = await setup()
  330. const a = fakeAgent()
  331. const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
  332. const id = /task (bash-\d+)/.exec(text(started))![1]!
  333. // A call with no exec.agent cannot prove ownership of an owned task.
  334. const read = await callAs(ctx, undefined, 'bash_output', { task_id: id })
  335. expect(read.isError).toBe(true)
  336. expect(text(read)).toMatch(/belongs to another session/)
  337. await callAs(ctx, a, 'bash_kill', { task_id: id }) // cleanup
  338. })
  339. it('an UNOWNED task (started with no agent) is accessible to anyone', async () => {
  340. const ctx = await setup()
  341. // Started by a non-loop caller (no exec.agent) → no recorded owner.
  342. const started = await callAs(ctx, undefined, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
  343. const id = /task (bash-\d+)/.exec(text(started))![1]!
  344. // Any agent (and the no-agent caller) may read/kill it.
  345. const read = await callAs(ctx, fakeAgent(), 'bash_output', { task_id: id })
  346. expect(read.isError).toBe(false)
  347. const killed = await callAs(ctx, undefined, 'bash_kill', { task_id: id })
  348. expect(killed.isError).toBe(false)
  349. })
  350. it('the owner can still access its task AFTER it completes (owner record persists)', async () => {
  351. const ctx = await setup()
  352. const a = fakeAgent()
  353. const b = fakeAgent()
  354. const started = await callAs(ctx, a, 'bash', { command: 'echo done', description: 'bg', run_in_background: true })
  355. const id = /task (bash-\d+)/.exec(text(started))![1]!
  356. await ctx.bash.get(id)!.done
  357. // Completion does NOT clear ownership: B is still rejected, A still allowed.
  358. const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
  359. expect(readByB.isError).toBe(true)
  360. expect(text(readByB)).toMatch(/belongs to another session/)
  361. const readByA = await callAs(ctx, a, 'bash_output', { task_id: id })
  362. expect(readByA.isError).toBe(false)
  363. })
  364. it('documents the HMR caveat: an independent tool-bash reload resets ownership', async () => {
  365. // The ownership map is per-plugin-instance (XXX(tool-bash-owner-hmr)). When
  366. // ONLY tool-bash is reloaded (bash/executor + task survive), the new instance
  367. // has an empty map, so the previously-owned task becomes unowned (open). This
  368. // test pins that documented behavior — a regression here (e.g. an accidental
  369. // global map) would change it.
  370. const ctx = new Context()
  371. await ctx.plugin(SystemPrompt)
  372. await ctx.plugin(ToolRegistry)
  373. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  374. ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 }
  375. const fiber = await ctx.plugin(ToolBash)
  376. const a = fakeAgent()
  377. const b = fakeAgent()
  378. const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
  379. const id = /task (bash-\d+)/.exec(text(started))![1]!
  380. // Before reload: B is rejected (A owns it).
  381. expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(true)
  382. // Reload ONLY tool-bash; the executor and its running task survive.
  383. await fiber.dispose()
  384. await ctx.plugin(ToolBash)
  385. expect(ctx.bash.get(id)?.status).toBe('running')
  386. // After reload the fresh map has no owner → B can now access it (the caveat).
  387. expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(false)
  388. await callAs(ctx, b, 'bash_kill', { task_id: id }) // cleanup
  389. })
  390. })
  391. describe('session-cwd routing (per-session workdir)', () => {
  392. function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, args: unknown) {
  393. return ctx.tools.execute({ callId: CallId(`cwd-${++callCounter}`), name: 'bash', arguments: args, ...agent ? { agent } : {} })
  394. }
  395. // An agent whose session header carries a cwd (what session/new records).
  396. const agentInCwd = (cwd: string) =>
  397. ({ inject: () => undefined, session: { header: { version: 1, id: 'c', createdAt: 0, cwd } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
  398. it('defaults bash to the agent\'s session cwd (not the server launch dir)', async () => {
  399. const ctx = await setup()
  400. const result = await callAs(ctx, agentInCwd('/tmp'), { command: 'pwd', description: 'pwd' })
  401. expect(text(result).trim()).toMatch(/\/tmp$/)
  402. })
  403. it('an explicit absolute workdir overrides the session cwd', async () => {
  404. const ctx = await setup()
  405. const result = await callAs(ctx, agentInCwd('/'), { command: 'pwd', description: 'pwd', workdir: '/tmp' })
  406. expect(text(result).trim()).toMatch(/\/tmp$/)
  407. })
  408. it('a relative workdir is resolved against the session cwd', async () => {
  409. const ctx = await setup()
  410. // session cwd /usr + relative 'bin' → /usr/bin
  411. const result = await callAs(ctx, agentInCwd('/usr'), { command: 'pwd', description: 'pwd', workdir: 'bin' })
  412. expect(text(result).trim()).toMatch(/\/usr\/bin$/)
  413. })
  414. it('two sessions with different cwds each run bash in their own dir', async () => {
  415. const ctx = await setup()
  416. const inUsr = await callAs(ctx, agentInCwd('/usr'), { command: 'pwd', description: 'pwd' })
  417. const inTmp = await callAs(ctx, agentInCwd('/tmp'), { command: 'pwd', description: 'pwd' })
  418. expect(text(inUsr).trim()).toMatch(/\/usr$/)
  419. expect(text(inTmp).trim()).toMatch(/\/tmp$/)
  420. })
  421. it('falls back to the executor default when the agent has no session cwd', async () => {
  422. const ctx = await setup()
  423. // No exec.agent at all → executor uses its config/process.cwd() default.
  424. const result = await ctx.tools.execute({ callId: CallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } })
  425. expect(result.isError).toBe(false)
  426. expect(text(result).trim().length).toBeGreaterThan(0)
  427. })
  428. })
  429. describe('renderResult', () => {
  430. const base = {
  431. exitCode: 0 as number | null,
  432. signal: null as NodeJS.Signals | null,
  433. timedOut: false,
  434. aborted: false,
  435. timeoutMs: 1000,
  436. stdout: { text: '', truncated: false },
  437. stderr: { text: '', truncated: false },
  438. }
  439. it('renders stderr-only output without a stdout prefix', () => {
  440. expect(renderResult({ ...base, stderr: { text: 'err\n', truncated: false } }))
  441. .toBe('[stderr]\nerr\n')
  442. })
  443. it('adds a separator when stdout does not end with a newline', () => {
  444. expect(renderResult({
  445. ...base,
  446. stdout: { text: 'out', truncated: false },
  447. stderr: { text: 'err', truncated: false },
  448. })).toBe('out\n[stderr]\nerr')
  449. })
  450. it('appends exit-code markers after a newline for unterminated output', () => {
  451. expect(renderResult({ ...base, exitCode: 7, stdout: { text: 'x', truncated: false } }))
  452. .toBe('x\n[exit code: 7]')
  453. })
  454. it('renders signal kills without the timeout marker when not timed out', () => {
  455. expect(renderResult({ ...base, exitCode: null, signal: 'SIGKILL' }))
  456. .toBe('(no output)\n[killed by signal: SIGKILL]')
  457. })
  458. it('reports a timeout that exited 0 (trapped signal) without a kill marker', () => {
  459. expect(renderResult({ ...base, exitCode: 0, signal: null, timedOut: true }))
  460. .toBe('(no output)\n[timed out after 1000ms]')
  461. })
  462. it('orders the timeout marker before a kill marker', () => {
  463. expect(renderResult({ ...base, exitCode: null, signal: 'SIGTERM', timedOut: true }))
  464. .toBe('(no output)\n[timed out after 1000ms]\n[killed by signal: SIGTERM]')
  465. })
  466. it('notes truncation with a fallback when the spill path is missing', () => {
  467. expect(renderResult({ ...base, stdout: { text: 'tail', truncated: true } }))
  468. .toBe('tail\n[output truncated; full output: (unavailable)]')
  469. })
  470. })
  471. describe('status lines', () => {
  472. it('reports kills without a recorded signal (executor raced process exit)', async () => {
  473. const ctx = await setup()
  474. const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
  475. const id = /task (bash-\d+)/.exec(text(started))![1]!
  476. const task = ctx.bash.get(id)!
  477. await call(ctx, 'bash_kill', { task_id: id })
  478. await task.done
  479. // Simulate the variant where the close event carried no signal.
  480. task.signal = null
  481. const read = await call(ctx, 'bash_output', { task_id: id })
  482. expect(text(read)).toContain('[status: killed]')
  483. })
  484. it('reports completed tasks with a null exit code as exit 0', async () => {
  485. const ctx = await setup()
  486. const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
  487. const id = /task (bash-\d+)/.exec(text(started))![1]!
  488. const task = ctx.bash.get(id)!
  489. await task.done
  490. // Defensive: completed tasks always carry an exit code in practice; the
  491. // ?? 0 fallback covers task shapes from other executor implementations.
  492. task.exitCode = null
  493. const read = await call(ctx, 'bash_output', { task_id: id })
  494. expect(text(read)).toContain('[status: completed, exit code: 0]')
  495. })
  496. })
  497. describe('tool-owned UI presentation (presentCall / presentResult)', () => {
  498. it('bash presentCall: title is the command, description as a content block, marks a terminal; workdir → cwd (absolute or relative, bridge resolves)', async () => {
  499. const ctx = await setup()
  500. // No explicit workdir → the call still flags a terminal, but with no cwd (the
  501. // UI bridge fills the session cwd it owns; the pure presenter can't see it).
  502. // The command is the title (an execute card hides rawInput); the description
  503. // rides as a content text block (shown above the terminal card).
  504. expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls -la src', description: 'List files in src' }))
  505. .toEqual({ title: 'ls -la src', kind: 'execute', rawInput: 'ls -la src', content: [{ type: 'text', text: 'List files in src' }], terminal: {} })
  506. // An ABSOLUTE workdir is surfaced verbatim as the terminal cwd header.
  507. expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: '/tmp/x' }))
  508. .toEqual({ title: 'pwd', kind: 'execute', rawInput: 'pwd', content: [{ type: 'text', text: 'Print dir' }], terminal: { cwd: '/tmp/x' } })
  509. // A RELATIVE workdir is passed through AS-IS (the bridge resolves it against
  510. // the session cwd, matching where execution runs) — not dropped.
  511. expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: 'sub' }))
  512. .toEqual({ title: 'pwd', kind: 'execute', rawInput: 'pwd', content: [{ type: 'text', text: 'Print dir' }], terminal: { cwd: 'sub' } })
  513. })
  514. it('bash presentResult: console-block content AND terminal.output (RAW newlines) + parsed exit code', async () => {
  515. const ctx = await setup()
  516. const present = ctx.tools.get('bash')!.presentResult!(
  517. { command: 'echo hi', description: 'echo' },
  518. { content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false },
  519. )
  520. // The fenced ```console content trims trailing blank lines for a tidy block;
  521. // terminal.output keeps the RAW bytes (newlines intact) a terminal renderer
  522. // needs; exitCode is parsed back from the [exit code: N] marker.
  523. expect(present).toEqual({
  524. content: [{ type: 'text', text: '```console\nhi\n[exit code: 0]\n```' }],
  525. terminal: { output: 'hi\n[exit code: 0]\n\n', exitCode: 0 },
  526. })
  527. })
  528. it('bash presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => {
  529. const ctx = await setup()
  530. const args = { command: 'x', description: 'x' }
  531. const nonzero = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false })
  532. expect(nonzero?.terminal).toEqual({ output: 'oops\n[exit code: 3]', exitCode: 3 })
  533. const killed = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false })
  534. expect(killed?.terminal).toEqual({ output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' })
  535. })
  536. it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => {
  537. const ctx = await setup()
  538. const present = ctx.tools.get('bash')!
  539. // For each renderResult outcome, the rendered text fed back through
  540. // presentResult recovers the matching structured exit — the parse and the
  541. // marker emission co-evolve in one file, so this pins the pair.
  542. const base = {
  543. aborted: false,
  544. timeoutMs: 1000,
  545. stdout: { text: 'out', truncated: false },
  546. stderr: { text: '', truncated: false },
  547. }
  548. const cases = [
  549. { result: { ...base, exitCode: 0, signal: null, timedOut: false }, expect: { exitCode: 0 } },
  550. { result: { ...base, exitCode: 7, signal: null, timedOut: false }, expect: { exitCode: 7 } },
  551. { result: { ...base, exitCode: null, signal: 'SIGTERM' as const, timedOut: false }, expect: { signal: 'SIGTERM' } },
  552. // A trapped-timeout run that exits 0 has no signal/exit marker → reads as exit 0 (it did exit 0).
  553. { result: { ...base, exitCode: 0, signal: null, timedOut: true }, expect: { exitCode: 0 } },
  554. ]
  555. for (const c of cases) {
  556. const rendered = renderResult(c.result)
  557. const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false })
  558. const { output: _o, ...exit } = out?.terminal ?? {}
  559. expect(exit).toEqual(c.expect)
  560. }
  561. })
  562. it('bash presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => {
  563. const ctx = await setup()
  564. const args = { command: 'printf "[exit code: 5]"', description: 'print' }
  565. // A successful command can print text that looks like a marker. renderResult
  566. // for a clean exit 0 appends NOTHING (and no trailing newline), so the body's
  567. // own tail is `[exit code: 5]`. The parse requires a LEADING newline before
  568. // the marker (renderResult always inserts one before a REAL marker), so this
  569. // no-trailing-newline body is NOT mistaken for a failure → exitCode 0.
  570. const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
  571. expect(out?.terminal).toEqual({ output: '[exit code: 5]', exitCode: 0 })
  572. // Same for a fake signal marker with no leading newline.
  573. const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false })
  574. expect(sig?.terminal).toEqual({ output: '[killed by signal: SIGKILL]', exitCode: 0 })
  575. })
  576. it('bash presentCall/presentResult: a run_in_background call is NOT a terminal and its ack carries no exit pill', async () => {
  577. const ctx = await setup()
  578. // The background start returns a task-id ack, not a streamed run — no terminal.
  579. const call = ctx.tools.get('bash')!.presentCall!({ command: 'sleep 100', description: 'wait', run_in_background: true })
  580. expect(call).toEqual({ title: 'sleep 100', kind: 'execute', rawInput: 'sleep 100', content: [{ type: 'text', text: 'wait' }] })
  581. expect((call as { terminal?: unknown }).terminal).toBeUndefined()
  582. // The ack result is fenced text only — no terminal output / exit pill.
  583. const result = ctx.tools.get('bash')!.presentResult!(
  584. { command: 'sleep 100', description: 'wait', run_in_background: true },
  585. { content: [{ type: 'text', text: 'started background task bash-1' }], isError: false },
  586. )
  587. expect(result?.terminal).toBeUndefined()
  588. expect(result?.content).toEqual([{ type: 'text', text: '```console\nstarted background task bash-1\n```' }])
  589. })
  590. it('bash presentResult: an isError result carries no exit pill (no real process exit to report)', async () => {
  591. const ctx = await setup()
  592. // A spawn failure / abort has no process exit — the body is an error message,
  593. // not renderResult output, so no terminal output/exit is emitted.
  594. const out = ctx.tools.get('bash')!.presentResult!(
  595. { command: 'x', description: 'x' },
  596. { content: [{ type: 'text', text: 'command aborted' }], isError: true },
  597. )
  598. expect(out?.terminal).toBeUndefined()
  599. expect(out?.content).toEqual([{ type: 'text', text: '```console\ncommand aborted\n```' }])
  600. })
  601. it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => {
  602. const ctx = await setup()
  603. const present = ctx.tools.get('bash')!.presentResult!(
  604. { command: 'x', description: 'x' },
  605. { content: [{ type: 'image', url: 'https://x/y.png' }], isError: false },
  606. )
  607. expect(present).toBeUndefined()
  608. })
  609. it('bash presentResult: a result that is not exactly one block → undefined (no single text to fence)', async () => {
  610. const ctx = await setup()
  611. const args = { command: 'x', description: 'x' }
  612. // Empty content (no block) and multi-block content both fall through.
  613. expect(ctx.tools.get('bash')!.presentResult!(args, { content: [], isError: false })).toBeUndefined()
  614. expect(ctx.tools.get('bash')!.presentResult!(args, {
  615. content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }],
  616. isError: false,
  617. })).toBeUndefined()
  618. })
  619. it('bash_output / bash_kill presentCall: a readable task-scoped title, task id as rawInput', async () => {
  620. const ctx = await setup()
  621. expect(ctx.tools.get('bash_output')!.presentCall!({ task_id: 'bash-3' }))
  622. .toEqual({ title: 'Read output from background task bash-3', kind: 'execute', rawInput: 'bash-3' })
  623. expect(ctx.tools.get('bash_kill')!.presentCall!({ task_id: 'bash-3' }))
  624. .toEqual({ title: 'Kill background task bash-3', kind: 'execute', rawInput: 'bash-3' })
  625. })
  626. it('presentCall validates softly: malformed args (missing required description) return undefined, never throw', async () => {
  627. const ctx = await setup()
  628. // defineTool wraps presentCall to soft-validate against the schema and fall
  629. // back to undefined (a generic UI presentation) rather than throwing on the
  630. // display path — it may run on replay of arbitrary logged args. The
  631. // ToolDefinition.presentCall takes `unknown`, so a malformed shape needs no cast.
  632. expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls' })).toBeUndefined()
  633. })
  634. })