subagent-claude-code.spec.ts 29 KB

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