cli.spec.ts 21 KB

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