subagent-claude-code.spec.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864
  1. import { PassThrough } from 'node:stream'
  2. import type {
  3. Options,
  4. Query,
  5. SDKMessage,
  6. SDKResultMessage,
  7. SpawnOptions,
  8. } from '@anthropic-ai/claude-agent-sdk'
  9. import { Context } from 'cordis'
  10. import Loader from '@cordisjs/plugin-loader'
  11. import {
  12. afterEach,
  13. beforeEach,
  14. describe,
  15. expect,
  16. it,
  17. type Mock,
  18. vi,
  19. } from 'vitest'
  20. import type { Agent } from '@deepseek-ai/dsh-agent'
  21. import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
  22. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  23. import SubagentService from '@deepseek-ai/dsh-subagent'
  24. import type {
  25. SubprocessHandle,
  26. SubprocessOutcome,
  27. SubprocessSpawnSpec,
  28. } from '@deepseek-ai/dsh-subprocess'
  29. import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
  30. import * as claudeCode from '../src/index.ts'
  31. import * as invariant from '../src/invariant.ts'
  32. import {
  33. claudeSpawnSpec,
  34. definedEnvironment,
  35. ManagedClaudeCodeProcess,
  36. } from '../src/process.ts'
  37. import {
  38. claudeQueryOptions,
  39. consumeClaudeQuery,
  40. disposeClaudeCodeChild,
  41. startClaudeCodeRun,
  42. successfulResult,
  43. textTask,
  44. type ClaudeCodeRunSpec,
  45. } from '../src/run.ts'
  46. type QueryFactory = (params: {
  47. prompt: string
  48. options: Options
  49. }) => Query
  50. const queryMock = vi.hoisted(() => vi.fn<QueryFactory>())
  51. vi.mock('@anthropic-ai/claude-agent-sdk', async importOriginal => ({
  52. ...await importOriginal<typeof import('@anthropic-ai/claude-agent-sdk')>(),
  53. query: queryMock,
  54. }))
  55. const fakeParent = {
  56. id: 'parent',
  57. session: { header: { cwd: process.cwd() } },
  58. } as unknown as Agent
  59. function request(
  60. prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
  61. signal = new AbortController().signal,
  62. ) {
  63. return { prompt, parent: fakeParent, signal }
  64. }
  65. async function nextTask(): Promise<void> {
  66. await new Promise<void>((resolve) => { setImmediate(resolve) })
  67. }
  68. interface FakeChildOptions {
  69. readonly pid?: number
  70. readonly stdin?: PassThrough | undefined
  71. readonly stdout?: PassThrough | undefined
  72. readonly exitOnTerminate?: boolean
  73. readonly waitForExitError?: Error
  74. readonly doneError?: Error
  75. }
  76. interface FakeChild {
  77. readonly handle: SubprocessHandle
  78. readonly stdin: PassThrough
  79. readonly stdout: PassThrough
  80. readonly settle: (outcome?: SubprocessOutcome) => void
  81. readonly fail: (error: Error) => void
  82. readonly terminate: Mock<SubprocessHandle['terminate']>
  83. readonly waitForExit: Mock<SubprocessHandle['waitForExit']>
  84. }
  85. function fakeChild(options: FakeChildOptions = {}): FakeChild {
  86. const stdin = new PassThrough()
  87. const stdout = new PassThrough()
  88. let exited = false
  89. let resolveDone!: (outcome: SubprocessOutcome) => void
  90. let rejectDone!: (error: Error) => void
  91. const done = new Promise<SubprocessOutcome>((resolve, reject) => {
  92. resolveDone = resolve
  93. rejectDone = reject
  94. })
  95. // Individual tests deliberately exercise rejected and still-pending handles.
  96. void done.catch(() => {})
  97. const settle = (
  98. outcome: SubprocessOutcome = { exitCode: 0, signal: null },
  99. ): void => {
  100. if (exited) return
  101. exited = true
  102. resolveDone(outcome)
  103. }
  104. const fail = (error: Error): void => {
  105. if (exited) return
  106. exited = true
  107. rejectDone(error)
  108. }
  109. if (options.doneError !== undefined) fail(options.doneError)
  110. const terminate = vi.fn<SubprocessHandle['terminate']>(() => {
  111. if (options.exitOnTerminate !== false) settle()
  112. })
  113. const waitForExit = vi.fn<SubprocessHandle['waitForExit']>(async (signal?: AbortSignal): Promise<boolean> => {
  114. if (options.waitForExitError !== undefined) {
  115. throw options.waitForExitError
  116. }
  117. if (exited) return true
  118. if (signal === undefined) {
  119. await done.catch(() => {})
  120. return true
  121. }
  122. return await new Promise<boolean>((resolve) => {
  123. const onAbort = (): void => { resolve(false) }
  124. signal.addEventListener('abort', onAbort, { once: true })
  125. void done.then(
  126. () => {
  127. signal.removeEventListener('abort', onAbort)
  128. resolve(true)
  129. },
  130. () => {
  131. signal.removeEventListener('abort', onAbort)
  132. resolve(true)
  133. },
  134. )
  135. })
  136. })
  137. const handle: SubprocessHandle = {
  138. pid: options.pid ?? 1234,
  139. stdin: options.stdin === undefined ? stdin : options.stdin,
  140. stdout: options.stdout === undefined ? stdout : options.stdout,
  141. stderr: undefined,
  142. collected: {},
  143. done,
  144. terminate,
  145. waitForExit,
  146. }
  147. return {
  148. handle,
  149. stdin,
  150. stdout,
  151. settle,
  152. fail,
  153. terminate,
  154. waitForExit,
  155. }
  156. }
  157. function success(
  158. result = 'answer',
  159. isError = false,
  160. ): SDKResultMessage {
  161. return {
  162. type: 'result',
  163. subtype: 'success',
  164. is_error: isError,
  165. result,
  166. } as SDKResultMessage
  167. }
  168. type ErrorSubtype = Exclude<SDKResultMessage['subtype'], 'success'>
  169. function failure(
  170. subtype: ErrorSubtype,
  171. errors: string[] = ['fixture failure'],
  172. ): SDKResultMessage {
  173. return {
  174. type: 'result',
  175. subtype,
  176. is_error: true,
  177. errors,
  178. } as SDKResultMessage
  179. }
  180. function queryFrom(
  181. messages: readonly SDKMessage[],
  182. after?: Error,
  183. close = vi.fn(),
  184. ): Query {
  185. async function* stream(): AsyncGenerator<SDKMessage, void> {
  186. for (const message of messages) yield message
  187. if (after !== undefined) throw after
  188. }
  189. return Object.assign(stream(), { close }) as unknown as Query
  190. }
  191. function waitingQuery(signal: AbortSignal, close = vi.fn()): Query {
  192. async function* stream(): AsyncGenerator<SDKMessage, void> {
  193. await new Promise<never>((_resolve, reject) => {
  194. const fail = (): void => {
  195. reject(signal.reason instanceof Error
  196. ? signal.reason
  197. : new Error(String(signal.reason)))
  198. }
  199. if (signal.aborted) fail()
  200. else signal.addEventListener('abort', fail, { once: true })
  201. })
  202. }
  203. return Object.assign(stream(), { close }) as unknown as Query
  204. }
  205. function sdkSpawnOptions(
  206. overrides: Partial<SpawnOptions> = {},
  207. ): SpawnOptions {
  208. return {
  209. command: '/sdk/claude',
  210. args: ['--output-format', 'stream-json'],
  211. cwd: '/workspace',
  212. env: { PATH: '/bin', OMITTED: undefined },
  213. signal: new AbortController().signal,
  214. ...overrides,
  215. }
  216. }
  217. interface FakeRun {
  218. readonly child: FakeChild
  219. readonly query: Query
  220. readonly close: ReturnType<typeof vi.fn>
  221. readonly spawnSpecs: SubprocessSpawnSpec[]
  222. readonly options: Options[]
  223. readonly spec: ClaudeCodeRunSpec
  224. }
  225. function fakeRun(
  226. messages: readonly SDKMessage[] = [success()],
  227. after?: Error,
  228. child = fakeChild(),
  229. ): FakeRun {
  230. const close = vi.fn()
  231. const query = queryFrom(messages, after, close)
  232. const spawnSpecs: SubprocessSpawnSpec[] = []
  233. const options: FakeRun['options'] = []
  234. const spec: ClaudeCodeRunSpec = {
  235. cwd: '/workspace',
  236. env: { ANTHROPIC_API_KEY: 'fake-key' },
  237. disposeGraceMs: 5,
  238. spawn: (spawnSpec) => {
  239. spawnSpecs.push(spawnSpec)
  240. return child.handle
  241. },
  242. }
  243. queryMock.mockImplementation((params) => {
  244. options.push(params.options)
  245. params.options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  246. return query
  247. })
  248. return { child, query, close, spawnSpecs, options, spec }
  249. }
  250. beforeEach(() => {
  251. queryMock.mockImplementation(({ options }) => {
  252. options.spawnClaudeCodeProcess!(sdkSpawnOptions({
  253. cwd: options.cwd!,
  254. env: options.env!,
  255. signal: options.abortController!.signal,
  256. }))
  257. return queryFrom([])
  258. })
  259. })
  260. afterEach(() => {
  261. queryMock.mockReset()
  262. vi.restoreAllMocks()
  263. vi.unstubAllEnvs()
  264. })
  265. describe('task admission and package contracts', () => {
  266. it('preserves text sequences and rejects empty, blank, and non-text tasks', () => {
  267. expect(textTask([
  268. { type: 'text', text: 'one' },
  269. { type: 'text', text: 'two' },
  270. ])).toBe('onetwo')
  271. expect(() => textTask([])).toThrow('only text blocks')
  272. expect(() => textTask([{ type: 'reasoning', text: 'hidden' }]))
  273. .toThrow('only text blocks')
  274. expect(() => textTask([{ type: 'text', text: ' \n ' }]))
  275. .toThrow('must not be empty')
  276. })
  277. it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => {
  278. const ctx = new Context()
  279. await ctx.plugin(SubagentService)
  280. await ctx.plugin(LocalSubprocessService)
  281. const fiber = await ctx.plugin(claudeCode, {})
  282. expect(ctx.subagents.getProvider('claude-code')).toMatchObject({
  283. name: 'claude-code',
  284. capabilities: {
  285. outputSchema: false,
  286. depthLimit: false,
  287. toolFilter: false,
  288. persona: false,
  289. },
  290. inheritsParentContext: false,
  291. })
  292. expect(ctx.subagents.list()).toEqual(['claude-code'])
  293. await fiber.dispose()
  294. expect(ctx.subagents.list()).toEqual([])
  295. for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
  296. await expect(ctx.plugin(claudeCode, { disposeGraceMs }))
  297. .rejects.toThrow('disposeGraceMs must be a positive finite number')
  298. }
  299. await ctx.fiber.dispose()
  300. })
  301. it('starts through the registered provider with its resolved config and diagnostics', async () => {
  302. const ctx = new Context()
  303. await ctx.plugin(SubagentService)
  304. await ctx.plugin(LocalSubprocessService)
  305. const child = fakeChild()
  306. const spawn = vi.spyOn(ctx.subprocess, 'spawn')
  307. .mockImplementation(() => child.handle)
  308. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  309. await ctx.plugin(claudeCode, {
  310. env: {
  311. ANTHROPIC_API_KEY: 'provider-fake-key',
  312. CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config',
  313. HOME: '/private/tmp/dsh-claude-code-unit-home',
  314. },
  315. disposeGraceMs: 29,
  316. })
  317. const run = await ctx.subagents.start('claude-code', request())
  318. child.settle({ exitCode: 9, signal: null })
  319. child.stdout.end()
  320. await expect(run.result).resolves.toEqual({
  321. output: [],
  322. stopReason: 'error',
  323. })
  324. expect(warn).toHaveBeenCalledWith(expect.stringContaining(
  325. 'subagent-claude-code: child run failed (error):',
  326. ))
  327. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  328. cwd: process.cwd(),
  329. graceMs: 29,
  330. }))
  331. expect(spawn.mock.calls[0]?.[0].env).toMatchObject({
  332. ANTHROPIC_API_KEY: 'provider-fake-key',
  333. })
  334. await run.dispose()
  335. await ctx.fiber.dispose()
  336. })
  337. it('keeps the Loader namespace shape and package-owned empty invariant', async () => {
  338. expect('default' in claudeCode).toBe(false)
  339. expect(claudeCode.name).toBe('subagent-claude-code')
  340. expect(claudeCode.inject).toEqual(['subagents', 'subprocess'])
  341. const loader = Object.create(Loader.prototype) as Loader
  342. expect(loader.unwrapExports(claudeCode)).toBe(claudeCode)
  343. const dispose = vi.fn()
  344. const register = vi.fn((
  345. _packageName: string,
  346. _installer: InvariantInstaller,
  347. ) => dispose)
  348. const ctx = { invariants: { register } } as unknown as Context
  349. await expect(invariant.apply(ctx)).resolves.toBe(dispose)
  350. expect(register).toHaveBeenCalledWith(
  351. '@deepseek-ai/dsh-subagent-claude-code',
  352. expect.any(Function),
  353. )
  354. const install = register.mock.calls[0]![1]
  355. await install(new Context(), (message) => { throw new Error(message) })
  356. expect(invariant.name).toBe('subagent-claude-code-invariant')
  357. expect(invariant.inject).toEqual(['invariants'])
  358. })
  359. })
  360. describe('official spawn projection', () => {
  361. it('forwards command, arguments, cwd, environment, and signal exactly', () => {
  362. const signal = new AbortController().signal
  363. const options = sdkSpawnOptions({
  364. command: '/official/claude',
  365. args: ['--one', 'two'],
  366. cwd: '/parent/workspace',
  367. env: { A: 'one', B: undefined, C: 'three' },
  368. signal,
  369. })
  370. expect(definedEnvironment(options.env)).toEqual({ A: 'one', C: 'three' })
  371. expect(claudeSpawnSpec(options, 321)).toEqual({
  372. argv: ['/official/claude', '--one', 'two'],
  373. cwd: '/parent/workspace',
  374. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
  375. graceMs: 321,
  376. signal,
  377. env: { A: 'one', C: 'three' },
  378. })
  379. const missingCwd = sdkSpawnOptions()
  380. delete missingCwd.cwd
  381. expect(() => claudeSpawnSpec(
  382. missingCwd,
  383. 321,
  384. )).toThrow('SDK spawn request omitted its workspace')
  385. expect(() => claudeSpawnSpec(
  386. sdkSpawnOptions({ cwd: '' }),
  387. 321,
  388. )).toThrow('SDK spawn request omitted its workspace')
  389. })
  390. it('projects streams, exit facts, listeners, and idempotent tree termination', async () => {
  391. const child = fakeChild({ exitOnTerminate: false })
  392. const process = new ManagedClaudeCodeProcess(child.handle)
  393. expect(process.stdin).toBe(child.stdin)
  394. expect(process.stdout).toBe(child.stdout)
  395. expect(process.killed).toBe(false)
  396. expect(process.exitCode).toBeNull()
  397. expect(process.signalCode).toBeNull()
  398. const exit = vi.fn()
  399. const once = vi.fn()
  400. const removed = vi.fn()
  401. process.on('exit', exit)
  402. process.once('exit', once)
  403. process.on('exit', removed)
  404. process.off('exit', removed)
  405. expect(process.kill('SIGTERM')).toBe(true)
  406. expect(process.killed).toBe(true)
  407. expect(process.kill('SIGKILL')).toBe(false)
  408. expect(child.terminate).toHaveBeenCalledOnce()
  409. child.settle({ exitCode: null, signal: 'SIGTERM' })
  410. await nextTask()
  411. expect(exit).toHaveBeenCalledWith(null, 'SIGTERM')
  412. expect(once).toHaveBeenCalledOnce()
  413. expect(removed).not.toHaveBeenCalled()
  414. expect(process.signalCode).toBe('SIGTERM')
  415. expect(process.kill('SIGTERM')).toBe(false)
  416. })
  417. it('emits spawn errors and rejects handles without the required pipes', async () => {
  418. const child = fakeChild()
  419. const process = new ManagedClaudeCodeProcess(child.handle)
  420. const errorListener = vi.fn()
  421. const removed = vi.fn()
  422. process.once('error', errorListener)
  423. process.on('error', removed)
  424. process.off('error', removed)
  425. child.fail(new Error('spawn boom'))
  426. await nextTask()
  427. expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({
  428. message: 'spawn boom',
  429. }))
  430. expect(removed).not.toHaveBeenCalled()
  431. const missingStdin = fakeChild({ stdin: undefined })
  432. Object.defineProperty(missingStdin.handle, 'stdin', { value: undefined })
  433. expect(() => new ManagedClaudeCodeProcess(missingStdin.handle))
  434. .toThrow('requires piped stdin and stdout')
  435. const missingStdout = fakeChild({ stdout: undefined })
  436. Object.defineProperty(missingStdout.handle, 'stdout', { value: undefined })
  437. expect(() => new ManagedClaudeCodeProcess(missingStdout.handle))
  438. .toThrow('requires piped stdin and stdout')
  439. })
  440. it('exposes a settled direct-child exit code', async () => {
  441. const child = fakeChild()
  442. const process = new ManagedClaudeCodeProcess(child.handle)
  443. child.settle({ exitCode: 7, signal: null })
  444. await nextTask()
  445. expect(process.exitCode).toBe(7)
  446. expect(process.signalCode).toBeNull()
  447. expect(process.kill('SIGTERM')).toBe(false)
  448. })
  449. })
  450. describe('query options and result mapping', () => {
  451. it('builds the fixed unattended options over the scrubbed environment', () => {
  452. vi.stubEnv('HOST_VISIBLE', 'visible')
  453. vi.stubEnv('HOST_SECRET_TOKEN', 'must-not-leak')
  454. vi.stubEnv('DSH_INTERNAL', 'must-not-leak')
  455. const child = fakeChild()
  456. const spawn = vi.fn(() => child.handle)
  457. const captured: SubprocessHandle[] = []
  458. const spec: ClaudeCodeRunSpec = {
  459. cwd: '/workspace',
  460. env: {
  461. HOST_VISIBLE: 'overridden',
  462. ANTHROPIC_API_KEY: 'explicit-fake-key',
  463. },
  464. disposeGraceMs: 17,
  465. spawn,
  466. }
  467. const controller = new AbortController()
  468. const options = claudeQueryOptions(spec, controller, (value) => {
  469. captured.push(value)
  470. })
  471. expect(options).toMatchObject({
  472. abortController: controller,
  473. cwd: '/workspace',
  474. persistSession: false,
  475. disallowedTools: ['AskUserQuestion'],
  476. })
  477. expect(options.env).toMatchObject({
  478. HOST_VISIBLE: 'overridden',
  479. ANTHROPIC_API_KEY: 'explicit-fake-key',
  480. })
  481. expect(options.env).not.toHaveProperty('HOST_SECRET_TOKEN')
  482. expect(options.env).not.toHaveProperty('DSH_INTERNAL')
  483. for (const omitted of [
  484. 'settingSources',
  485. 'canUseTool',
  486. 'onElicitation',
  487. 'onUserDialog',
  488. 'supportedDialogKinds',
  489. ]) {
  490. expect(options).not.toHaveProperty(omitted)
  491. }
  492. const spawned = options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  493. expect(spawned).toBeInstanceOf(ManagedClaudeCodeProcess)
  494. expect(captured).toEqual([child.handle])
  495. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  496. argv: ['/sdk/claude', '--output-format', 'stream-json'],
  497. cwd: '/workspace',
  498. graceMs: 17,
  499. }))
  500. })
  501. it('accepts only a non-error success with a non-blank final result', () => {
  502. expect(successfulResult(success('exact final'))).toBe('exact final')
  503. expect(() => successfulResult(success('answer', true)))
  504. .toThrow('marked as an error')
  505. expect(() => successfulResult(success(' \n ')))
  506. .toThrow('contained no answer')
  507. expect(() => successfulResult(failure(
  508. 'error_during_execution',
  509. ['first', 'second'],
  510. ))).toThrow('first; second')
  511. expect(() => successfulResult(failure(
  512. 'error_max_turns',
  513. [],
  514. ))).toThrow('error_max_turns')
  515. })
  516. it('consumes the complete stream and keeps the latest strict success', async () => {
  517. const query = queryFrom([
  518. { type: 'system', subtype: 'init' } as SDKMessage,
  519. success('first'),
  520. success('last'),
  521. ])
  522. await expect(consumeClaudeQuery(query)).resolves.toEqual({
  523. output: [{ type: 'text', text: 'last' }],
  524. stopReason: 'completed',
  525. })
  526. await expect(consumeClaudeQuery(
  527. queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]),
  528. )).rejects.toThrow('ended without a result')
  529. })
  530. })
  531. describe('run publication, cancellation, and settlement', () => {
  532. it('publishes only after Query and managed child exist, then disposes once', async () => {
  533. const fixture = fakeRun([success('exact answer')])
  534. const run = await startClaudeCodeRun(
  535. request([
  536. { type: 'text', text: 'first' },
  537. { type: 'text', text: 'second' },
  538. ]),
  539. fixture.spec,
  540. )
  541. expect(fixture.options).toHaveLength(1)
  542. expect(fixture.spawnSpecs).toHaveLength(1)
  543. await expect(run.result).resolves.toEqual({
  544. output: [{ type: 'text', text: 'exact answer' }],
  545. stopReason: 'completed',
  546. })
  547. const first = run.dispose()
  548. const second = run.dispose()
  549. expect(second).toBe(first)
  550. await first
  551. expect(fixture.close).toHaveBeenCalledOnce()
  552. expect(fixture.child.terminate).toHaveBeenCalledOnce()
  553. })
  554. it('flattens every SDK error result without inventing shared stop reasons', async () => {
  555. const subtypes: ErrorSubtype[] = [
  556. 'error_during_execution',
  557. 'error_max_turns',
  558. 'error_max_budget_usd',
  559. 'error_max_structured_output_retries',
  560. ]
  561. for (const subtype of subtypes) {
  562. const fixture = fakeRun([failure(subtype)])
  563. const onError = vi.fn()
  564. const run = await startClaudeCodeRun(
  565. request(),
  566. { ...fixture.spec, onError },
  567. )
  568. await expect(run.result).resolves.toEqual({
  569. output: [],
  570. stopReason: 'error',
  571. })
  572. expect(onError).toHaveBeenCalledWith(
  573. expect.any(Error),
  574. 'error',
  575. )
  576. await run.dispose()
  577. }
  578. })
  579. it('fails closed when iteration rejects after a result', async () => {
  580. const fixture = fakeRun(
  581. [success('partial final')],
  582. new Error('iterator boom'),
  583. )
  584. const run = await startClaudeCodeRun(request(), fixture.spec)
  585. await expect(run.result).resolves.toEqual({
  586. output: [],
  587. stopReason: 'error',
  588. })
  589. await run.dispose()
  590. })
  591. it('maps invalid success and missing result to error', async () => {
  592. for (const messages of [
  593. [success('answer', true)],
  594. [success('')],
  595. [{ type: 'system', subtype: 'init' } as SDKMessage],
  596. ]) {
  597. const fixture = fakeRun(messages)
  598. const run = await startClaudeCodeRun(request(), fixture.spec)
  599. await expect(run.result).resolves.toMatchObject({
  600. stopReason: 'error',
  601. })
  602. await run.dispose()
  603. }
  604. })
  605. it('gives local cancellation precedence and isolates overlapping controllers', async () => {
  606. const firstChild = fakeChild()
  607. const secondChild = fakeChild()
  608. const children = [firstChild, secondChild]
  609. const controllers: AbortController[] = []
  610. let index = 0
  611. const spec: ClaudeCodeRunSpec = {
  612. cwd: '/workspace',
  613. env: {},
  614. disposeGraceMs: 5,
  615. spawn: () => children[index++]!.handle,
  616. }
  617. queryMock.mockImplementation(({ prompt, options }) => {
  618. controllers.push(options.abortController!)
  619. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  620. return prompt === 'wait'
  621. ? waitingQuery(options.abortController!.signal)
  622. : queryFrom([success('second answer')])
  623. })
  624. const firstAbort = new AbortController()
  625. const first = await startClaudeCodeRun(
  626. request([{ type: 'text', text: 'wait' }], firstAbort.signal),
  627. spec,
  628. )
  629. const second = await startClaudeCodeRun(
  630. request([{ type: 'text', text: 'finish' }]),
  631. spec,
  632. )
  633. expect(controllers).toHaveLength(2)
  634. expect(controllers[0]).not.toBe(controllers[1])
  635. firstAbort.abort(new Error('parent cancelled'))
  636. await expect(first.result).resolves.toEqual({
  637. output: [],
  638. stopReason: 'aborted',
  639. })
  640. await expect(second.result).resolves.toEqual({
  641. output: [{ type: 'text', text: 'second answer' }],
  642. stopReason: 'completed',
  643. })
  644. expect(controllers[1]!.signal.aborted).toBe(false)
  645. await Promise.all([first.dispose(), second.dispose()])
  646. })
  647. it('keeps local cancellation authoritative when the SDK iterator ends normally', async () => {
  648. const parentAbort = new AbortController()
  649. const child = fakeChild()
  650. async function* stream(): AsyncGenerator<SDKMessage, void> {
  651. yield success('candidate answer')
  652. parentAbort.abort(new Error('parent cancelled at iterator completion'))
  653. }
  654. queryMock.mockImplementation(({ options }) => {
  655. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  656. return Object.assign(stream(), { close: vi.fn() }) as unknown as Query
  657. })
  658. const run = await startClaudeCodeRun(
  659. request(undefined, parentAbort.signal),
  660. {
  661. cwd: '/workspace',
  662. env: {},
  663. disposeGraceMs: 5,
  664. spawn: () => child.handle,
  665. },
  666. )
  667. await expect(run.result).resolves.toEqual({
  668. output: [],
  669. stopReason: 'aborted',
  670. })
  671. await run.dispose()
  672. })
  673. it('rejects pre-abort and every incomplete startup transaction', async () => {
  674. const preAborted = new AbortController()
  675. preAborted.abort()
  676. const unused = fakeRun()
  677. await expect(startClaudeCodeRun(
  678. request(undefined, preAborted.signal),
  679. unused.spec,
  680. )).rejects.toThrow('aborted before SDK startup')
  681. expect(unused.options).toEqual([])
  682. const noChildClose = vi.fn()
  683. queryMock.mockImplementationOnce(
  684. () => queryFrom([], undefined, noChildClose),
  685. )
  686. await expect(startClaudeCodeRun(request(), {
  687. ...unused.spec,
  688. })).rejects.toThrow('did not publish a controllable')
  689. expect(noChildClose).toHaveBeenCalledOnce()
  690. const closeFailure = vi.fn(() => { throw new Error('close boom') })
  691. queryMock.mockImplementationOnce(
  692. () => queryFrom([], undefined, closeFailure),
  693. )
  694. const noChild = startClaudeCodeRun(request(), {
  695. ...unused.spec,
  696. })
  697. await expect(noChild).rejects.toBeInstanceOf(AggregateError)
  698. const startupAbort = new AbortController()
  699. const abortedChild = fakeChild()
  700. const abortedClose = vi.fn()
  701. queryMock.mockImplementationOnce(({ options }) => {
  702. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  703. startupAbort.abort(new Error('startup cancelled'))
  704. return queryFrom([], undefined, abortedClose)
  705. })
  706. const abortedDuringStartup = startClaudeCodeRun(
  707. request(undefined, startupAbort.signal),
  708. {
  709. ...unused.spec,
  710. spawn: () => abortedChild.handle,
  711. },
  712. )
  713. await expect(abortedDuringStartup)
  714. .rejects.toThrow('aborted before SDK startup')
  715. expect(abortedClose).toHaveBeenCalledOnce()
  716. expect(abortedChild.terminate).toHaveBeenCalledOnce()
  717. queryMock.mockImplementationOnce(() => {
  718. throw new Error('query failed before resource creation')
  719. })
  720. await expect(startClaudeCodeRun(request(), {
  721. ...unused.spec,
  722. })).rejects.toThrow('query failed before resource creation')
  723. const spawned = fakeChild()
  724. const spawnSpecs: SubprocessSpawnSpec[] = []
  725. let factoryController: AbortController | undefined
  726. queryMock.mockImplementationOnce(({ options }) => {
  727. factoryController = options.abortController
  728. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  729. throw new Error('query construction failed')
  730. })
  731. const factoryFailure = startClaudeCodeRun(request(), {
  732. ...unused.spec,
  733. spawn: (spawnSpec) => {
  734. spawnSpecs.push(spawnSpec)
  735. return spawned.handle
  736. },
  737. })
  738. await expect(factoryFailure).rejects.toThrow('query construction failed')
  739. expect(spawnSpecs).toHaveLength(1)
  740. expect(factoryController?.signal.aborted).toBe(true)
  741. expect(spawned.terminate).toHaveBeenCalledOnce()
  742. const failedSpawn = fakeChild({
  743. pid: -1,
  744. doneError: new Error('spawn failed'),
  745. })
  746. const failed = fakeRun([], undefined, failedSpawn)
  747. await expect(startClaudeCodeRun(request(), failed.spec))
  748. .rejects.toBeInstanceOf(AggregateError)
  749. expect(failed.close).toHaveBeenCalledOnce()
  750. })
  751. })
  752. describe('query and process disposal', () => {
  753. it('closes the query, terminates the tree, and waits for direct-child outcome', async () => {
  754. const child = fakeChild()
  755. const close = vi.fn()
  756. await disposeClaudeCodeChild({ close }, child.handle)
  757. expect(close).toHaveBeenCalledOnce()
  758. expect(child.terminate).toHaveBeenCalledOnce()
  759. expect(child.waitForExit).toHaveBeenCalledOnce()
  760. expect(child.waitForExit).toHaveBeenCalledWith()
  761. await expect(child.handle.done).resolves.toEqual({
  762. exitCode: 0,
  763. signal: null,
  764. })
  765. })
  766. it('does not finish disposal before the managed tree exits', async () => {
  767. const child = fakeChild({ exitOnTerminate: false })
  768. let disposed = false
  769. const disposal = disposeClaudeCodeChild(
  770. { close: vi.fn() },
  771. child.handle,
  772. ).then(() => {
  773. disposed = true
  774. })
  775. await nextTask()
  776. expect(disposed).toBe(false)
  777. child.settle()
  778. await disposal
  779. expect(disposed).toBe(true)
  780. })
  781. it('reports wait, close, and direct-child failures without skipping cleanup', async () => {
  782. const waitFailure = fakeChild({
  783. waitForExitError: new Error('wait boom'),
  784. })
  785. const closeFailure = vi.fn(() => { throw new Error('close boom') })
  786. await expect(disposeClaudeCodeChild(
  787. { close: closeFailure },
  788. waitFailure.handle,
  789. )).rejects.toBeInstanceOf(AggregateError)
  790. expect(waitFailure.terminate).toHaveBeenCalledOnce()
  791. const doneFailure = fakeChild({
  792. pid: -1,
  793. doneError: new Error('spawn boom'),
  794. })
  795. await expect(disposeClaudeCodeChild(
  796. { close: vi.fn() },
  797. doneFailure.handle,
  798. )).rejects.toThrow('spawn boom')
  799. const both = fakeChild({
  800. pid: -1,
  801. doneError: new Error('spawn boom'),
  802. })
  803. await expect(disposeClaudeCodeChild(
  804. { close: () => { throw new Error('close boom') } },
  805. both.handle,
  806. )).rejects.toBeInstanceOf(AggregateError)
  807. })
  808. })