cli.spec.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. import { readdir, mkdtemp } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join, resolve } from 'node:path'
  4. import { Context } from 'cordis'
  5. import type { Agent } from '@deepseek-ai/dsh-agent'
  6. import { createUserMessage,
  7. CallId,
  8. LlmAdapter,
  9. resolveRetryPolicy,
  10. type GenerateOptions,
  11. type ResolvedRetryPolicy,
  12. type StreamChunk,
  13. type TokenUsage,
  14. } from '@deepseek-ai/dsh-llm'
  15. import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  16. import { afterEach, describe, expect, it } from 'vitest'
  17. import * as cliDemo from '../src/index.ts'
  18. import {
  19. executeCli,
  20. parseCliArgs,
  21. runOneShot,
  22. type CliResult,
  23. } from '../src/cli.ts'
  24. type ScriptEntry = readonly StreamChunk[] | 'hang'
  25. class ScriptedAdapter extends LlmAdapter {
  26. readonly requests: GenerateOptions[] = []
  27. private cursor = 0
  28. private readonly retryPolicy = resolveRetryPolicy({
  29. mode: 'normal',
  30. backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 },
  31. }, 'cli test provider retryPolicy')
  32. constructor(private readonly script: readonly ScriptEntry[]) {
  33. super()
  34. }
  35. override providerRetryPolicy(_provider: string): ResolvedRetryPolicy {
  36. return this.retryPolicy
  37. }
  38. async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  39. this.requests.push(options)
  40. const entry = this.script[this.cursor++]
  41. if (entry === undefined) throw new Error('script exhausted')
  42. if (entry === 'hang') {
  43. yield { type: 'block-start', index: 0, blockType: 'text' }
  44. yield { type: 'text-delta', index: 0, text: 'partial' }
  45. await new Promise<void>((_resolve, reject) => {
  46. if (options.signal?.aborted === true) {
  47. reject(new Error('aborted'))
  48. return
  49. }
  50. options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
  51. })
  52. return
  53. }
  54. for (const chunk of entry) yield chunk
  55. }
  56. }
  57. function textResponse(text: string, usage?: TokenUsage, finish: 'stop' | 'max-tokens' = 'stop'): StreamChunk[] {
  58. return [
  59. { type: 'block-start', index: 0, blockType: 'text' },
  60. { type: 'text-delta', index: 0, text },
  61. { type: 'block-end', index: 0, block: { type: 'text', text } },
  62. ...usage === undefined ? [] : [{ type: 'usage', usage } as const],
  63. { type: 'finish', reason: { kind: finish } },
  64. ]
  65. }
  66. function toolResponse(usage: TokenUsage): StreamChunk[] {
  67. const id = CallId('cli-call')
  68. const args = JSON.stringify({ text: 'round trip' })
  69. return [
  70. { type: 'block-start', index: 0, blockType: 'text' },
  71. { type: 'text-delta', index: 0, text: 'working' },
  72. { type: 'block-end', index: 0, block: { type: 'text', text: 'working' } },
  73. { type: 'block-start', index: 1, blockType: 'tool-call' },
  74. { type: 'tool-call-delta', index: 1, id, name: 'echo', argumentsDelta: args },
  75. { type: 'block-end', index: 1, block: { type: 'tool-call', id, name: 'echo', arguments: args } },
  76. { type: 'usage', usage },
  77. { type: 'finish', reason: { kind: 'tool-calls' } },
  78. ]
  79. }
  80. function failedResponse(usage: TokenUsage): StreamChunk[] {
  81. return [
  82. { type: 'block-start', index: 0, blockType: 'text' },
  83. { type: 'text-delta', index: 0, text: 'discarded' },
  84. { type: 'usage', usage },
  85. { type: 'finish', reason: { kind: 'error', failure: { message: 'temporary', code: 'SERVER' } } },
  86. ]
  87. }
  88. function reasoningResponse(text: string): StreamChunk[] {
  89. return [
  90. { type: 'block-start', index: 0, blockType: 'reasoning' },
  91. { type: 'reasoning-delta', index: 0, text },
  92. { type: 'block-end', index: 0, block: { type: 'reasoning', text } },
  93. { type: 'finish', reason: { kind: 'stop' } },
  94. ]
  95. }
  96. interface Harness {
  97. readonly ctx: Context
  98. readonly agent: Agent
  99. readonly persistenceRoot: string
  100. }
  101. const liveContexts: Context[] = []
  102. async function harness(script: readonly ScriptEntry[]): Promise<Harness> {
  103. const root = await mkdtemp(join(tmpdir(), 'dsh-cli-runner-'))
  104. const ctx = new Context()
  105. liveContexts.push(ctx)
  106. await ctx.plugin(cliDemo, {
  107. provider: 'mock',
  108. model: 'mock',
  109. persistenceRoot: root,
  110. skills: { enabled: false },
  111. workspaceContext: false,
  112. })
  113. await new Promise(resolve => setTimeout(resolve, 80))
  114. ctx.llm.registerAdapter(['mock'], new ScriptedAdapter(script))
  115. ctx.tools.register({
  116. name: 'echo',
  117. description: 'Echo text.',
  118. parameters: { text: { type: 'string', required: true } },
  119. output: {
  120. schema: { type: 'string' },
  121. render: (_args, value) => [{ type: 'text', text: value as string }],
  122. },
  123. execute: async args => `ECHO: ${(args as { text: string }).text}`,
  124. })
  125. const [agent] = ctx.agents.roots()
  126. if (agent === undefined) throw new Error('test main agent missing')
  127. return { ctx, agent, persistenceRoot: root }
  128. }
  129. async function invoke(
  130. ctx: Context,
  131. args: readonly string[],
  132. options: { signal?: AbortSignal; failStdout?: boolean; failDispose?: boolean } = {},
  133. ): Promise<{ code: number; stdout: string; stderr: string }> {
  134. let stdout = ''
  135. let stderr = ''
  136. const code = await executeCli(args, {
  137. cwd: '/tmp/cli-cwd',
  138. ...options.signal === undefined ? {} : { signal: options.signal },
  139. boot: async () => ctx,
  140. loadEnv: () => {},
  141. writeStdout: (chunk) => {
  142. if (options.failStdout === true) throw new Error('stdout closed')
  143. stdout += chunk
  144. },
  145. writeStderr: (chunk) => { stderr += chunk },
  146. ...options.failDispose === true
  147. ? { dispose: async (target: Context) => {
  148. await target.fiber.dispose()
  149. throw new Error('dispose exploded')
  150. } }
  151. : {},
  152. })
  153. return { code, stdout, stderr }
  154. }
  155. afterEach(async () => {
  156. await Promise.all(liveContexts.splice(0).map(ctx => ctx.fiber.dispose()))
  157. })
  158. describe('parseCliArgs', () => {
  159. it('parses defaults, explicit options, spaces, and an option-like task after --', () => {
  160. expect(parseCliArgs(['task with spaces'])).toEqual({
  161. kind: 'run', configPath: './cordis.yml', outputFormat: 'text', task: 'task with spaces',
  162. })
  163. expect(parseCliArgs(['--config', 'custom.yml', '--output-format', 'stream-json', 'do it'])).toEqual({
  164. kind: 'run', configPath: 'custom.yml', outputFormat: 'stream-json', task: 'do it',
  165. })
  166. expect(parseCliArgs(['--', '-task'])).toMatchObject({ task: '-task' })
  167. expect(parseCliArgs(['-p', 'flag task'])).toMatchObject({ task: 'flag task' })
  168. expect(parseCliArgs(['--prompt', 'long-flag task'])).toMatchObject({ task: 'long-flag task' })
  169. expect(parseCliArgs(['--help', 'ignored'])).toEqual({ kind: 'help' })
  170. })
  171. it('rejects missing, blank, extra, invalid-format, and unsupported flags', () => {
  172. expect(() => parseCliArgs([])).toThrow('received 0')
  173. expect(() => parseCliArgs([' '])).toThrow('must not be blank')
  174. expect(() => parseCliArgs(['-p', ' '])).toThrow('must not be blank')
  175. expect(() => parseCliArgs(['one', 'two'])).toThrow('received 2')
  176. expect(() => parseCliArgs(['-p', 'task', 'positional'])).toThrow('mutually exclusive')
  177. expect(() => parseCliArgs(['--output-format', 'xml', 'task'])).toThrow('unsupported output format')
  178. expect(() => parseCliArgs(['-x', 'task'])).toThrow('Unknown option')
  179. })
  180. })
  181. describe('runOneShot and executeCli', () => {
  182. it('prints help and argument diagnostics without booting or contaminating stdout', async () => {
  183. let booted = false
  184. let stdout = ''
  185. let stderr = ''
  186. const runtime = {
  187. boot: async (): Promise<Context> => { booted = true; throw new Error('unexpected') },
  188. writeStdout: (chunk: string): void => { stdout += chunk },
  189. writeStderr: (chunk: string): void => { stderr += chunk },
  190. }
  191. expect(await executeCli(['--help'], runtime)).toBe(0)
  192. expect(stdout).toContain('Usage: dsh-cli-demo')
  193. stdout = ''
  194. expect(await executeCli([], runtime)).toBe(1)
  195. expect(stdout).toBe('')
  196. expect(stderr).toContain('received 0')
  197. expect(booted).toBe(false)
  198. })
  199. it('leaves stdout empty for environment and boot failures and resolves the default config', async () => {
  200. let bootPath = ''
  201. let stderr = ''
  202. const code = await executeCli(['task'], {
  203. cwd: '/tmp/cli-work',
  204. loadEnv: (_name, _dir, warn) => { warn('env warning\n') },
  205. boot: async (_name, path) => { bootPath = path; throw 'boot exploded' },
  206. writeStdout: () => { throw new Error('stdout must stay empty') },
  207. writeStderr: (chunk) => { stderr += chunk },
  208. })
  209. expect(code).toBe(1)
  210. expect(bootPath).toBe(resolve('/tmp/cli-work/cordis.yml'))
  211. expect(stderr).toContain('env warning')
  212. expect(stderr).toContain('boot exploded')
  213. })
  214. it('contains a thrown value whose inspection and coercion both fail', async () => {
  215. const hostile = new Proxy({}, {
  216. getPrototypeOf: () => { throw new Error('prototype trap escaped') },
  217. get: (target, key, receiver) => {
  218. if (key === Symbol.toPrimitive) throw new Error('coercion escaped')
  219. return Reflect.get(target, key, receiver) as unknown
  220. },
  221. })
  222. let stdout = ''
  223. let stderr = ''
  224. const code = await executeCli(['task'], {
  225. boot: async () => { throw hostile },
  226. loadEnv: () => {},
  227. writeStdout: (chunk) => { stdout += chunk },
  228. writeStderr: (chunk) => { stderr += chunk },
  229. })
  230. expect(code).toBe(1)
  231. expect(stdout).toBe('')
  232. expect(stderr).toBe('dsh-cli-demo: [unrenderable thrown value]\n')
  233. })
  234. it('interrupts Loader boot and contains every late boot outcome', async () => {
  235. const abort = new AbortController()
  236. const lateContext = new Context()
  237. liveContexts.push(lateContext)
  238. const boot = Promise.withResolvers<Context>()
  239. const disposed = Promise.withResolvers<undefined>()
  240. let disposeCalls = 0
  241. let stderr = ''
  242. const running = executeCli(['task'], {
  243. signal: abort.signal,
  244. boot: () => boot.promise,
  245. loadEnv: () => {},
  246. writeStdout: () => {},
  247. writeStderr: (chunk) => { stderr += chunk },
  248. dispose: async (ctx) => {
  249. disposeCalls += 1
  250. await ctx.fiber.dispose()
  251. disposed.resolve(undefined)
  252. },
  253. })
  254. abort.abort('received SIGTERM')
  255. await expect(running).resolves.toBe(1)
  256. expect(stderr).toContain('received SIGTERM')
  257. expect(disposeCalls).toBe(0)
  258. boot.resolve(lateContext)
  259. await disposed.promise
  260. expect(disposeCalls).toBe(1)
  261. const rejectedBoot = Promise.withResolvers<Context>()
  262. const rejectedAbort = new AbortController()
  263. const rejected = executeCli(['task'], {
  264. signal: rejectedAbort.signal,
  265. boot: () => rejectedBoot.promise,
  266. loadEnv: () => {},
  267. writeStdout: () => {},
  268. writeStderr: () => {},
  269. })
  270. rejectedAbort.abort('stop rejected boot')
  271. await expect(rejected).resolves.toBe(1)
  272. rejectedBoot.reject(new Error('late boot rejection'))
  273. await Promise.resolve()
  274. let ordinaryBootStderr = ''
  275. const ordinaryBootFailure = await executeCli(['task'], {
  276. signal: new AbortController().signal,
  277. boot: async () => { throw new Error('ordinary boot failure') },
  278. loadEnv: () => {},
  279. writeStdout: () => {},
  280. writeStderr: (chunk) => { ordinaryBootStderr += chunk },
  281. })
  282. expect(ordinaryBootFailure).toBe(1)
  283. expect(ordinaryBootStderr).toContain('ordinary boot failure')
  284. const failedCleanupBoot = Promise.withResolvers<Context>()
  285. const failedCleanupAbort = new AbortController()
  286. const cleanupFailure = Promise.withResolvers<undefined>()
  287. const failedCleanupContext = new Context()
  288. liveContexts.push(failedCleanupContext)
  289. const failedCleanup = executeCli(['task'], {
  290. signal: failedCleanupAbort.signal,
  291. boot: () => failedCleanupBoot.promise,
  292. loadEnv: () => {},
  293. writeStdout: () => {},
  294. writeStderr: (chunk) => {
  295. if (chunk.includes('dispose after interrupted boot failed: late cleanup')) cleanupFailure.resolve(undefined)
  296. },
  297. dispose: async (ctx) => {
  298. await ctx.fiber.dispose()
  299. throw new Error('late cleanup')
  300. },
  301. })
  302. failedCleanupAbort.abort('stop failed cleanup boot')
  303. await expect(failedCleanup).resolves.toBe(1)
  304. failedCleanupBoot.resolve(failedCleanupContext)
  305. await cleanupFailure.promise
  306. })
  307. it('renders text, flushes a persisted fresh session, and disposes the context', async () => {
  308. const { ctx, agent, persistenceRoot } = await harness([textResponse('final answer')])
  309. const output = await invoke(ctx, ['task'])
  310. expect(output).toEqual({ code: 0, stdout: 'final answer\n', stderr: '' })
  311. expect(agent.status).toBe('idle')
  312. const files = await readdir(persistenceRoot, { recursive: true })
  313. expect(files.some(file => file.endsWith('.jsonl.zstd'))).toBe(true)
  314. })
  315. it('writes correlated session events in stream-json mode', async () => {
  316. const { ctx } = await harness([textResponse('streamed answer')])
  317. const output = await invoke(ctx, ['--output-format', 'stream-json', 'task'])
  318. const records = output.stdout.trim().split('\n').map(line => JSON.parse(line) as { type: string })
  319. expect(output.code).toBe(0)
  320. expect(records.some(record => record.type === 'session_event')).toBe(true)
  321. expect(records.at(-1)).toMatchObject({ type: 'result', output: 'streamed answer' })
  322. })
  323. it('sums usage across tool steps and selects the last text-bearing assistant message', async () => {
  324. const first = { inputTokens: 10, outputTokens: 3, cacheReadTokens: 2, cacheWriteTokens: 1 }
  325. const second = { inputTokens: 7, outputTokens: 5, cacheReadTokens: 4, reasoningTokens: 6 }
  326. const { ctx } = await harness([toolResponse(first), textResponse('done', second)])
  327. const output = await invoke(ctx, ['--output-format', 'json', 'task'])
  328. const result = JSON.parse(output.stdout) as CliResult
  329. expect(output.code).toBe(0)
  330. expect(result).toMatchObject({ type: 'result', output: 'done' })
  331. expect(result.usage).toEqual({
  332. inputTokens: 17,
  333. outputTokens: 8,
  334. cacheReadTokens: 6,
  335. cacheWriteTokens: 1,
  336. reasoningTokens: 6,
  337. })
  338. })
  339. it('reports usage committed by the recovered assistant message', async () => {
  340. const failed = { inputTokens: 11, outputTokens: 2, cacheReadTokens: 3 }
  341. const recovered = { inputTokens: 7, outputTokens: 5, reasoningTokens: 4 }
  342. const { ctx } = await harness([failedResponse(failed), textResponse('done', recovered)])
  343. const result = await runOneShot(ctx, { task: 'task' })
  344. expect(result.usage).toEqual({
  345. inputTokens: 7,
  346. outputTokens: 5,
  347. reasoningTokens: 4,
  348. })
  349. })
  350. it('keeps the prior text when a later assistant message has no text blocks', async () => {
  351. const { ctx } = await harness([
  352. toolResponse({ inputTokens: 1, outputTokens: 1 }),
  353. reasoningResponse('reasoning only'),
  354. ])
  355. const result = await runOneShot(ctx, { task: 'task' })
  356. expect(result.output).toBe('working')
  357. })
  358. it('observes only the correlated main message turn', async () => {
  359. const { ctx, agent } = await harness([
  360. textResponse('startup'),
  361. textResponse('autonomous'),
  362. textResponse('streamed'),
  363. ])
  364. const other = ctx.sessions.create(SessionId('unrelated'))
  365. let startupStarted!: () => void
  366. const started = new Promise<void>((resolve) => { startupStarted = resolve })
  367. const releaseStartup = Promise.withResolvers<undefined>()
  368. ctx.on('session/event', (session, event) => {
  369. if (session === agent.session && event.type === 'assistant/message'
  370. && event.data.turn === 1) startupStarted()
  371. })
  372. ctx.on('agent/turn-stopping', async (subject, turn) => {
  373. if (subject === agent && turn === 1) await releaseStartup.promise
  374. })
  375. agent.followup(createUserMessage({
  376. content: [{ type: 'text', text: 'startup' }],
  377. source: { kind: 'plugin', plugin: 'startup' },
  378. }))
  379. await started
  380. const followup = agent.followup.bind(agent)
  381. let injectedBeforeReceipt = false
  382. agent.followup = (input) => {
  383. if (!injectedBeforeReceipt && input.source.kind === 'user') {
  384. injectedBeforeReceipt = true
  385. agent.inbox.append('next-step', createUserMessage({
  386. content: [{ type: 'text', text: 'wrong receipt' }],
  387. source: { kind: 'plugin', plugin: 'test-wrong-receipt' },
  388. }))
  389. other.append('user/message', createUserMessage({
  390. content: [{ type: 'text', text: 'unrelated session event' }],
  391. source: { kind: 'plugin', plugin: 'test' },
  392. }), { surfaceOp: 'append' })
  393. agent.session.append('user/message', createUserMessage({
  394. content: [{ type: 'text', text: 'uncorrelated main-session event' }],
  395. source: { kind: 'plugin', plugin: 'test-before-receipt' },
  396. }), { surfaceOp: 'append' })
  397. }
  398. followup(input)
  399. }
  400. let replacementQueued = false
  401. ctx.on('agent/status', (subject, status) => {
  402. if (subject !== agent || status !== 'idle' || replacementQueued) return
  403. replacementQueued = true
  404. agent.followup(createUserMessage({
  405. content: [{ type: 'text', text: 'autonomous' }],
  406. source: { kind: 'plugin', plugin: 'test' },
  407. }))
  408. other.append('turn/start', { turn: 1 })
  409. other.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  410. })
  411. const streamed: { sessionId: string; event: SessionEvent }[] = []
  412. const result = runOneShot(ctx, {
  413. task: 'task',
  414. onEvent: (sessionId, event) => { streamed.push({ sessionId, event }) },
  415. })
  416. releaseStartup.resolve(undefined)
  417. const outcome = await result
  418. expect(outcome).toMatchObject({ type: 'result', output: 'streamed' })
  419. const events = streamed.map(item => item.event)
  420. expect(events.find(event => event.type === 'turn/start'))
  421. .toMatchObject({ type: 'turn/start', data: { turn: 3 } })
  422. expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 3 } })
  423. expect(streamed.every(item => item.sessionId === agent.session.id)).toBe(true)
  424. expect(events.some(event => event.type === 'user/message'
  425. && event.data.source.kind === 'plugin'
  426. && event.data.source.plugin === 'test')).toBe(false)
  427. expect(events.some(event => event.type === 'user/message'
  428. && event.data.source.kind === 'plugin'
  429. && event.data.source.plugin === 'test-before-receipt')).toBe(false)
  430. })
  431. it('correlates a task whose step history is replaced', async () => {
  432. const { ctx } = await harness([textResponse('rewritten answer')])
  433. ctx.on('agent/pre-step', async () => ({
  434. kind: 'enter',
  435. messages: [createUserMessage({
  436. content: [{ type: 'text', text: 'rewritten task' }],
  437. source: { kind: 'plugin', plugin: 'test' },
  438. })],
  439. }))
  440. await expect(runOneShot(ctx, { task: 'original task' })).resolves.toMatchObject({
  441. type: 'result',
  442. output: 'rewritten answer',
  443. })
  444. })
  445. it('settles rejected tasks at whole-agent idle without attributing a result', async () => {
  446. const blocked = await harness([])
  447. blocked.ctx.on('agent/pre-step', async () => ({
  448. kind: 'reject' as const,
  449. }))
  450. await expect(runOneShot(blocked.ctx, { task: 'task' })).resolves.toMatchObject({ output: '' })
  451. const failed = await harness([])
  452. failed.ctx.on('agent/pre-step', async () => { throw new Error('pre-step exploded') })
  453. await expect(runOneShot(failed.ctx, { task: 'task' })).resolves.toMatchObject({ output: '' })
  454. })
  455. it('emits partial data without attributing a turn outcome', async () => {
  456. const { ctx } = await harness([textResponse('partial', { inputTokens: 2, outputTokens: 3 }, 'max-tokens')])
  457. const output = await invoke(ctx, ['--output-format', 'json', 'task'])
  458. expect(JSON.parse(output.stdout)).toMatchObject({ type: 'result', output: 'partial' })
  459. expect(output.code).toBe(0)
  460. expect(output.stderr).toBe('')
  461. })
  462. it('cancels an active turn, emits its durable aborted result, and disposes', async () => {
  463. const { ctx, agent } = await harness(['hang'])
  464. const abort = new AbortController()
  465. let started!: () => void
  466. const running = new Promise<void>((resolveStarted) => { started = resolveStarted })
  467. ctx.on('session/event', (session, event) => {
  468. if (session === agent.session && event.type === 'assistant/chunk') started()
  469. })
  470. const outcome = invoke(ctx, ['--output-format', 'json', 'task'], { signal: abort.signal })
  471. await running
  472. abort.abort('received SIGINT')
  473. const output = await outcome
  474. expect(output.stdout).toBe('')
  475. expect(output.code).toBe(1)
  476. expect(output.stderr).toContain('received SIGINT')
  477. expect(agent.status).toBe('idle')
  478. })
  479. it('contains stream-writer failures, cancels, flushes, and returns the output error', async () => {
  480. const { ctx, agent } = await harness(['hang'])
  481. await expect(runOneShot(ctx, {
  482. task: 'task',
  483. onEvent: () => { throw new Error('stream sink failed') },
  484. })).rejects.toThrow('stream sink failed')
  485. expect(agent.status).toBe('idle')
  486. })
  487. it('handles cancellation before submission, a missing main agent, and final-output failure', async () => {
  488. const early = await harness([textResponse('unused')])
  489. const fakeSignal = {
  490. aborted: true,
  491. reason: undefined,
  492. } as unknown as AbortSignal
  493. await expect(runOneShot(early.ctx, { task: 'task', signal: fakeSignal })).rejects.toThrow('interrupted')
  494. const raced = await harness([textResponse('unused')])
  495. let registrations = 0
  496. const racedSignal = {
  497. aborted: false,
  498. reason: 'cancel before followup',
  499. addEventListener: (_type: string, listener: () => void) => {
  500. registrations += 1
  501. if (registrations === 2) listener()
  502. },
  503. removeEventListener: () => {},
  504. } as unknown as AbortSignal
  505. await expect(runOneShot(raced.ctx, { task: 'task', signal: racedSignal }))
  506. .rejects.toThrow('cancel before followup')
  507. expect(raced.agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
  508. const preBootAbort = new AbortController()
  509. preBootAbort.abort('before boot completed')
  510. const preBoot = await invoke(early.ctx, ['task'], { signal: preBootAbort.signal })
  511. expect(preBoot).toMatchObject({ code: 1, stdout: '' })
  512. expect(preBoot.stderr).toContain('before boot completed')
  513. const empty = new Context()
  514. liveContexts.push(empty)
  515. await expect(runOneShot(empty, { task: 'task' })).rejects.toThrow('exactly one top-level agent')
  516. const final = await harness([textResponse('answer')])
  517. const output = await invoke(final.ctx, ['task'], { failStdout: true })
  518. expect(output.code).toBe(1)
  519. expect(output.stdout).toBe('')
  520. expect(output.stderr).toContain('stdout closed')
  521. expect(final.agent.status).toBe('idle')
  522. const disposal = await harness([textResponse('answer')])
  523. const disposalOutput = await invoke(disposal.ctx, ['task'], { failDispose: true })
  524. expect(disposalOutput).toMatchObject({ code: 1, stdout: 'answer\n' })
  525. expect(disposalOutput.stderr).toContain('dispose exploded')
  526. })
  527. it('reports disposal failure alongside an earlier run failure', async () => {
  528. const ctx = new Context()
  529. liveContexts.push(ctx)
  530. const output = await invoke(ctx, ['task'], { failDispose: true })
  531. expect(output).toEqual({
  532. code: 1,
  533. stdout: '',
  534. stderr: 'dsh-cli-demo: config must create exactly one top-level agent, found 0\n'
  535. + 'dsh-cli-demo: dispose failed: dispose exploded\n',
  536. })
  537. })
  538. it('cancels startup work and queued work before the correlated turn begins', async () => {
  539. const startup = await harness(['hang'])
  540. let started!: () => void
  541. const running = new Promise<void>((resolveStarted) => { started = resolveStarted })
  542. startup.ctx.on('session/event', (session, event) => {
  543. if (session === startup.agent.session && event.type === 'assistant/chunk') started()
  544. })
  545. startup.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } }))
  546. await running
  547. const startupAbort = new AbortController()
  548. const waiting = runOneShot(startup.ctx, { task: 'second', signal: startupAbort.signal })
  549. startupAbort.abort('cancel startup')
  550. await expect(waiting).rejects.toThrow('cancel startup')
  551. await startup.agent.whenIdle()
  552. const queued = await harness([textResponse('unused')])
  553. const queuedAbort = new AbortController()
  554. queued.ctx.on('session/event', (session, event) => {
  555. if (session === queued.agent.session && event.type === 'agent/inbox/spliced'
  556. && event.data.inserted.some(message => message.source.kind === 'user')) {
  557. queueMicrotask(() => { queuedAbort.abort('cancel queued') })
  558. }
  559. })
  560. await expect(runOneShot(queued.ctx, { task: 'task', signal: queuedAbort.signal })).rejects.toThrow('cancel queued')
  561. await queued.agent.whenIdle()
  562. })
  563. })