cli.spec.ts 21 KB

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