cli.spec.ts 21 KB

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