cli.spec.ts 21 KB

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