subagent-claude-code.spec.ts 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343
  1. import { PassThrough } from 'node:stream'
  2. import type {
  3. Options,
  4. Query,
  5. SDKMessage,
  6. SDKPermissionDeniedMessage,
  7. SDKResultMessage,
  8. SpawnOptions,
  9. } from '@anthropic-ai/claude-agent-sdk'
  10. import { Context } from '@deepseek-ai/cordis'
  11. import Loader from '@deepseek-ai/cordis-plugin-loader'
  12. import {
  13. afterEach,
  14. beforeEach,
  15. describe,
  16. expect,
  17. it,
  18. type Mock,
  19. vi,
  20. } from 'vitest'
  21. import type { Agent } from '@deepseek-ai/dsh-agent'
  22. import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
  23. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  24. import SubagentRuntime from '@deepseek-ai/dsh-subagent'
  25. import type {
  26. SubprocessHandle,
  27. SubprocessOutcome,
  28. SubprocessSpawnSpec,
  29. } from '@deepseek-ai/dsh-subprocess'
  30. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  31. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  32. import * as claudeCode from '../src/index.ts'
  33. import * as invariant from '../src/invariant.ts'
  34. import {
  35. claudeSpawnSpec,
  36. ManagedClaudeCodeProcess,
  37. sdkEnvironmentOverlay,
  38. } from '../src/process.ts'
  39. import {
  40. CLAUDE_CODE_PERMISSION_MODES,
  41. DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
  42. claudeQueryOptions,
  43. consumeClaudeQuery,
  44. disposeClaudeCodeChild,
  45. startClaudeCodeRun,
  46. successfulResult,
  47. textTask,
  48. type ClaudeCodeRunSpec,
  49. } from '../src/run.ts'
  50. type QueryFactory = (params: {
  51. prompt: string
  52. options: Options
  53. }) => Query
  54. const queryMock = vi.hoisted(() => vi.fn<QueryFactory>())
  55. vi.mock('@anthropic-ai/claude-agent-sdk', async importOriginal => ({
  56. ...await importOriginal<typeof import('@anthropic-ai/claude-agent-sdk')>(),
  57. query: queryMock,
  58. }))
  59. const fakeParent = {
  60. id: 'parent',
  61. session: { header: { cwd: process.cwd() } },
  62. } as unknown as Agent
  63. function request(
  64. prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
  65. signal = new AbortController().signal,
  66. ) {
  67. return { prompt, parent: fakeParent, signal }
  68. }
  69. async function nextTask(): Promise<void> {
  70. await new Promise<void>((resolve) => { setImmediate(resolve) })
  71. }
  72. function errorCause(value: unknown): Error | undefined {
  73. return value instanceof Error && value.cause instanceof Error
  74. ? value.cause
  75. : undefined
  76. }
  77. interface FakeChildOptions {
  78. readonly pid?: number
  79. readonly exitOnTerminate?: boolean
  80. readonly waitForExitError?: Error
  81. readonly doneError?: Error
  82. }
  83. interface FakeChild {
  84. readonly handle: SubprocessHandle
  85. readonly stdin: PassThrough
  86. readonly stdout: PassThrough
  87. readonly settle: (outcome?: SubprocessOutcome) => void
  88. readonly fail: (error: Error) => void
  89. readonly terminate: Mock<SubprocessHandle['terminate']>
  90. readonly waitForExit: Mock<SubprocessHandle['waitForExit']>
  91. }
  92. function fakeChild(options: FakeChildOptions = {}): FakeChild {
  93. const stdin = new PassThrough()
  94. const stdout = new PassThrough()
  95. let exited = false
  96. let resolveDone!: (outcome: SubprocessOutcome) => void
  97. let rejectDone!: (error: Error) => void
  98. const done = new Promise<SubprocessOutcome>((resolve, reject) => {
  99. resolveDone = resolve
  100. rejectDone = reject
  101. })
  102. // Individual tests deliberately exercise rejected and still-pending handles.
  103. void done.catch(() => {})
  104. const settle = (
  105. outcome: SubprocessOutcome = { exitCode: 0, signal: null },
  106. ): void => {
  107. if (exited) return
  108. exited = true
  109. resolveDone(outcome)
  110. }
  111. const fail = (error: Error): void => {
  112. if (exited) return
  113. exited = true
  114. rejectDone(error)
  115. }
  116. if (options.doneError !== undefined) fail(options.doneError)
  117. const terminate = vi.fn<SubprocessHandle['terminate']>(() => {
  118. if (options.exitOnTerminate !== false) settle()
  119. })
  120. const waitForExit = vi.fn<SubprocessHandle['waitForExit']>(async (signal?: AbortSignal): Promise<boolean> => {
  121. if (options.waitForExitError !== undefined) {
  122. throw options.waitForExitError
  123. }
  124. if (exited) return true
  125. if (signal === undefined) {
  126. await done.catch(() => {})
  127. return true
  128. }
  129. return await new Promise<boolean>((resolve) => {
  130. const onAbort = (): void => { resolve(false) }
  131. signal.addEventListener('abort', onAbort, { once: true })
  132. void done.then(
  133. () => {
  134. signal.removeEventListener('abort', onAbort)
  135. resolve(true)
  136. },
  137. () => {
  138. signal.removeEventListener('abort', onAbort)
  139. resolve(true)
  140. },
  141. )
  142. })
  143. })
  144. const handle: SubprocessHandle = {
  145. pid: options.pid ?? 1234,
  146. stdin,
  147. stdout,
  148. stderr: undefined,
  149. collected: {},
  150. done,
  151. terminate,
  152. waitForExit,
  153. }
  154. return {
  155. handle,
  156. stdin,
  157. stdout,
  158. settle,
  159. fail,
  160. terminate,
  161. waitForExit,
  162. }
  163. }
  164. function success(
  165. result = 'answer',
  166. isError = false,
  167. ): SDKResultMessage {
  168. return {
  169. type: 'result',
  170. subtype: 'success',
  171. is_error: isError,
  172. result,
  173. } as SDKResultMessage
  174. }
  175. type ErrorSubtype = Exclude<SDKResultMessage['subtype'], 'success'>
  176. function failure(
  177. subtype: ErrorSubtype,
  178. errors: string[] = ['fixture failure'],
  179. ): SDKResultMessage {
  180. return {
  181. type: 'result',
  182. subtype,
  183. is_error: true,
  184. errors,
  185. } as SDKResultMessage
  186. }
  187. function expectedFailureDiagnostic(
  188. stage: 'query-start' | 'query-run' | 'process' | 'teardown',
  189. category: string,
  190. outcome?: Partial<SubprocessOutcome>,
  191. ): string {
  192. const fields = [
  193. 'product: Claude Code',
  194. `stage: ${stage}`,
  195. `category: ${category}`,
  196. ]
  197. if (outcome?.exitCode !== null && outcome?.exitCode !== undefined) {
  198. fields.push(`exit code: ${outcome.exitCode}`)
  199. }
  200. if (outcome?.signal !== null && outcome?.signal !== undefined) {
  201. fields.push(`signal: ${outcome.signal}`)
  202. }
  203. return `Product subagent failure (${fields.join('; ')})`
  204. }
  205. function permissionDenied(): SDKPermissionDeniedMessage {
  206. return {
  207. type: 'system',
  208. subtype: 'permission_denied',
  209. tool_name: 'Bash',
  210. tool_use_id: 'tool-secret',
  211. decision_reason_type: 'mode',
  212. decision_reason: 'contains /private/secret.txt',
  213. message: 'command with SECRET_TOKEN was denied',
  214. uuid: '00000000-0000-4000-8000-000000000001',
  215. session_id: 'session-secret',
  216. }
  217. }
  218. function queryFrom(
  219. messages: readonly SDKMessage[],
  220. after?: Error,
  221. close = vi.fn(),
  222. ): Query {
  223. async function* stream(): AsyncGenerator<SDKMessage, void> {
  224. for (const message of messages) yield message
  225. if (after !== undefined) throw after
  226. }
  227. return Object.assign(stream(), { close }) as unknown as Query
  228. }
  229. function waitingQuery(signal: AbortSignal, close = vi.fn()): Query {
  230. async function* stream(): AsyncGenerator<SDKMessage, void> {
  231. await new Promise<never>((_resolve, reject) => {
  232. const fail = (): void => {
  233. reject(signal.reason instanceof Error
  234. ? signal.reason
  235. : new Error(String(signal.reason)))
  236. }
  237. if (signal.aborted) fail()
  238. else signal.addEventListener('abort', fail, { once: true })
  239. })
  240. }
  241. return Object.assign(stream(), { close }) as unknown as Query
  242. }
  243. function sdkSpawnOptions(
  244. overrides: Partial<SpawnOptions> = {},
  245. ): SpawnOptions {
  246. return {
  247. command: '/sdk/claude',
  248. args: ['--output-format', 'stream-json'],
  249. cwd: '/workspace',
  250. env: { PATH: '/bin', OMITTED: undefined },
  251. signal: new AbortController().signal,
  252. ...overrides,
  253. }
  254. }
  255. interface FakeRun {
  256. readonly child: FakeChild
  257. readonly close: ReturnType<typeof vi.fn>
  258. readonly spawnSpecs: SubprocessSpawnSpec[]
  259. readonly options: Options[]
  260. readonly spec: ClaudeCodeRunSpec
  261. }
  262. function fakeRun(
  263. messages: readonly SDKMessage[] = [success()],
  264. after?: Error,
  265. child = fakeChild(),
  266. ): FakeRun {
  267. const close = vi.fn()
  268. const query = queryFrom(messages, after, close)
  269. const spawnSpecs: SubprocessSpawnSpec[] = []
  270. const options: FakeRun['options'] = []
  271. const spec: ClaudeCodeRunSpec = {
  272. cwd: '/workspace',
  273. executable: '/native/claude',
  274. permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
  275. env: { ANTHROPIC_API_KEY: 'fake-key' },
  276. disposeGraceMs: 5,
  277. spawn: (spawnSpec) => {
  278. spawnSpecs.push(spawnSpec)
  279. return child.handle
  280. },
  281. }
  282. queryMock.mockImplementation((params) => {
  283. options.push(params.options)
  284. params.options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  285. return query
  286. })
  287. return { child, close, spawnSpecs, options, spec }
  288. }
  289. beforeEach(() => {
  290. queryMock.mockImplementation(({ options }) => {
  291. options.spawnClaudeCodeProcess!(sdkSpawnOptions({
  292. cwd: options.cwd!,
  293. env: options.env!,
  294. signal: options.abortController!.signal,
  295. }))
  296. return queryFrom([])
  297. })
  298. })
  299. afterEach(() => {
  300. queryMock.mockReset()
  301. vi.restoreAllMocks()
  302. vi.unstubAllEnvs()
  303. })
  304. describe('task admission and package contracts', () => {
  305. it('preserves text sequences and rejects empty, blank, and non-text tasks', () => {
  306. expect(textTask([
  307. { type: 'text', text: 'one' },
  308. { type: 'text', text: 'two' },
  309. ])).toBe('onetwo')
  310. expect(() => textTask([])).toThrow('only text blocks')
  311. expect(() => textTask([{ type: 'reasoning', text: 'hidden' }]))
  312. .toThrow('only text blocks')
  313. expect(() => textTask([{ type: 'text', text: ' \n ' }]))
  314. .toThrow('must not be empty')
  315. })
  316. it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => {
  317. const ctx = new Context()
  318. await ctx.plugin(SubagentRuntime)
  319. await ctx.plugin(LocalSubprocessRuntime)
  320. const fiber = await ctx.plugin(claudeCode, {})
  321. expect(ctx.subagents.getProvider('claude-code')).toMatchObject({
  322. name: 'claude-code',
  323. capabilities: {
  324. outputSchema: false,
  325. depthLimit: false,
  326. toolFilter: false,
  327. persona: false,
  328. },
  329. inheritsParentContext: false,
  330. })
  331. expect(ctx.subagents.list()).toEqual(['claude-code'])
  332. await fiber.dispose()
  333. expect(ctx.subagents.list()).toEqual([])
  334. for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
  335. await expect(ctx.plugin(claudeCode, { disposeGraceMs }))
  336. .rejects.toThrow('disposeGraceMs must be a positive finite number')
  337. }
  338. await expect(ctx.plugin(claudeCode, {
  339. disposeGraceMs: MAX_TIMER_DELAY_MS + 1,
  340. })).rejects.toThrow(
  341. `disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
  342. )
  343. await ctx.fiber.dispose()
  344. })
  345. it('accepts only the five fixed non-interactive permission modes', () => {
  346. expect(claudeCode.Config({}).permissionMode)
  347. .toBe(DEFAULT_CLAUDE_CODE_PERMISSION_MODE)
  348. for (const permissionMode of CLAUDE_CODE_PERMISSION_MODES) {
  349. expect(claudeCode.Config({ permissionMode }).permissionMode)
  350. .toBe(permissionMode)
  351. }
  352. for (const permissionMode of ['default', 'interactive', 'future-mode']) {
  353. expect(() => claudeCode.Config({ permissionMode } as never)).toThrow()
  354. }
  355. })
  356. it('resolves the safe permission default when apply is called directly', async () => {
  357. const ctx = new Context()
  358. await ctx.plugin(SubagentRuntime)
  359. await ctx.plugin(LocalSubprocessRuntime)
  360. claudeCode.apply(ctx, { env: {}, disposeGraceMs: 3_000 })
  361. expect(ctx.subagents.getProvider('claude-code')).toBeDefined()
  362. await ctx.fiber.dispose()
  363. })
  364. it('starts through the registered provider with its resolved config and diagnostics', async () => {
  365. const ctx = new Context()
  366. await ctx.plugin(SubagentRuntime)
  367. await ctx.plugin(LocalSubprocessRuntime)
  368. const child = fakeChild()
  369. const spawn = vi.spyOn(ctx.subprocess, 'spawn')
  370. .mockImplementation(() => child.handle)
  371. const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable')
  372. .mockResolvedValue('/native/claude')
  373. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  374. await ctx.plugin(claudeCode, {
  375. env: {
  376. ANTHROPIC_API_KEY: 'provider-fake-key',
  377. CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config',
  378. HOME: '/private/tmp/dsh-claude-code-unit-home',
  379. },
  380. permissionMode: 'auto',
  381. disposeGraceMs: 29,
  382. })
  383. await expect(ctx.subagents.start('claude-code', {
  384. ...request(),
  385. parent: {
  386. id: 'parent-without-cwd',
  387. session: { header: {} },
  388. } as unknown as Agent,
  389. })).rejects.toThrow(
  390. 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one',
  391. )
  392. expect(queryMock).not.toHaveBeenCalled()
  393. resolveExecutable.mockRejectedValueOnce(new Error('claude missing from PATH'))
  394. const missingExecutable = ctx.subagents.start('claude-code', request())
  395. await expect(missingExecutable)
  396. .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
  397. await expect(missingExecutable).rejects.not.toThrow('claude missing from PATH')
  398. expect(warn).toHaveBeenCalledWith(
  399. 'subagent-claude-code: child start failed: %o',
  400. expect.any(Error),
  401. )
  402. expect(errorCause(warn.mock.calls[0]?.[1] as unknown)?.message)
  403. .toBe('claude missing from PATH')
  404. expect(queryMock).not.toHaveBeenCalled()
  405. const resolutionAbort = new AbortController()
  406. resolveExecutable.mockImplementationOnce(async () => {
  407. resolutionAbort.abort(new Error('parent cancelled executable resolution'))
  408. throw new Error('SECRET_TOKEN from executable resolution')
  409. })
  410. await expect(ctx.subagents.start(
  411. 'claude-code',
  412. request(undefined, resolutionAbort.signal),
  413. )).rejects.toThrow('aborted before SDK startup')
  414. expect(queryMock).not.toHaveBeenCalled()
  415. const run = await ctx.subagents.start('claude-code', request())
  416. child.settle({ exitCode: 9, signal: null })
  417. child.stdout.end()
  418. await expect(run.result).resolves.toEqual({
  419. output: [],
  420. diagnostic: expectedFailureDiagnostic('query-run', 'missing-result'),
  421. stopReason: 'error',
  422. })
  423. expect(warn).toHaveBeenCalledWith(
  424. expect.stringContaining('subagent-claude-code: child run failed (error):'),
  425. expect.any(Error),
  426. )
  427. expect(resolveExecutable).toHaveBeenCalledWith(
  428. 'claude',
  429. expect.objectContaining({ ANTHROPIC_API_KEY: 'provider-fake-key' }),
  430. expect.any(AbortSignal),
  431. )
  432. expect(queryMock.mock.calls[0]?.[0].options.pathToClaudeCodeExecutable)
  433. .toBe('/native/claude')
  434. expect(queryMock.mock.calls[0]?.[0].options.permissionMode).toBe('auto')
  435. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  436. cwd: process.cwd(),
  437. graceMs: 29,
  438. }))
  439. expect(spawn.mock.calls[0]?.[0].env).toMatchObject({
  440. ANTHROPIC_API_KEY: 'provider-fake-key',
  441. })
  442. await run.dispose()
  443. await ctx.fiber.dispose()
  444. })
  445. it('keeps the Loader namespace shape and package-owned empty invariant', async () => {
  446. expect('default' in claudeCode).toBe(false)
  447. expect(claudeCode.name).toBe('subagent-claude-code')
  448. expect(claudeCode.inject).toEqual(['subagents', 'subprocess'])
  449. const loader = Object.create(Loader.prototype) as Loader
  450. expect(loader.unwrapExports(claudeCode)).toBe(claudeCode)
  451. const dispose = vi.fn()
  452. const register = vi.fn((
  453. _packageName: string,
  454. _installer: InvariantInstaller,
  455. ) => dispose)
  456. const ctx = { invariants: { register } } as unknown as Context
  457. await expect(invariant.apply(ctx)).resolves.toBe(dispose)
  458. expect(register).toHaveBeenCalledWith(
  459. '@deepseek-ai/dsh-subagent-claude-code',
  460. expect.any(Function),
  461. )
  462. const install = register.mock.calls[0]![1]
  463. await install(new Context(), (message) => { throw new Error(message) })
  464. expect(invariant.name).toBe('subagent-claude-code-invariant')
  465. expect(invariant.inject).toEqual(['invariants'])
  466. })
  467. })
  468. describe('official spawn projection', () => {
  469. it('forwards command, arguments, cwd, environment, and signal exactly', () => {
  470. vi.stubEnv('SDK_REMOVED_AMBIENT', 'ambient-value')
  471. const signal = new AbortController().signal
  472. const options = sdkSpawnOptions({
  473. command: '/official/claude',
  474. args: ['--one', 'two'],
  475. cwd: '/parent/workspace',
  476. env: { A: 'one', B: undefined, C: 'three' },
  477. signal,
  478. })
  479. expect(sdkEnvironmentOverlay(options.env)).toEqual(expect.objectContaining({
  480. A: 'one',
  481. B: undefined,
  482. C: 'three',
  483. SDK_REMOVED_AMBIENT: undefined,
  484. }))
  485. const spawnSpec = claudeSpawnSpec(options, 321)
  486. expect(spawnSpec).toMatchObject({
  487. argv: ['/official/claude', '--one', 'two'],
  488. cwd: '/parent/workspace',
  489. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
  490. graceMs: 321,
  491. signal,
  492. })
  493. expect(spawnSpec.env).toEqual(expect.objectContaining({
  494. A: 'one',
  495. B: undefined,
  496. C: 'three',
  497. SDK_REMOVED_AMBIENT: undefined,
  498. }))
  499. const missingCwd = sdkSpawnOptions()
  500. delete missingCwd.cwd
  501. expect(() => claudeSpawnSpec(
  502. missingCwd,
  503. 321,
  504. )).toThrow('SDK spawn request omitted its workspace')
  505. expect(() => claudeSpawnSpec(
  506. sdkSpawnOptions({ cwd: '' }),
  507. 321,
  508. )).toThrow('SDK spawn request omitted its workspace')
  509. })
  510. it.each(['cmd', 'bat'])('routes a Windows .%s shim through cmd.exe', (extension) => {
  511. const command = String.raw`C:\Program Files\Claude\claude.${extension}`
  512. const spec = claudeSpawnSpec(sdkSpawnOptions({
  513. command,
  514. args: ['--output-format', 'stream-json'],
  515. }), 7, 'win32')
  516. expect(spec.argv).toEqual([
  517. 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%',
  518. '--output-format', 'stream-json',
  519. ])
  520. expect(spec.env).toEqual(expect.objectContaining({
  521. DSH_CLAUDE_CODE_EXECUTABLE: `"${command}"`,
  522. }))
  523. })
  524. it('projects streams, exit facts, listeners, and idempotent tree termination', async () => {
  525. const child = fakeChild({ exitOnTerminate: false })
  526. const process = new ManagedClaudeCodeProcess(child.handle)
  527. expect(process.stdin).toBe(child.stdin)
  528. expect(process.stdout).toBe(child.stdout)
  529. expect(process.killed).toBe(false)
  530. expect(process.exitCode).toBeNull()
  531. expect(process.signalCode).toBeNull()
  532. expect(process.outcome).toBeUndefined()
  533. const exit = vi.fn()
  534. const once = vi.fn()
  535. const removed = vi.fn()
  536. process.on('exit', exit)
  537. process.once('exit', once)
  538. process.on('exit', removed)
  539. process.off('exit', removed)
  540. expect(process.kill('SIGTERM')).toBe(true)
  541. expect(process.killed).toBe(true)
  542. expect(process.kill('SIGKILL')).toBe(false)
  543. expect(child.terminate).toHaveBeenCalledOnce()
  544. child.settle({ exitCode: null, signal: 'SIGTERM' })
  545. await nextTask()
  546. expect(exit).toHaveBeenCalledWith(null, 'SIGTERM')
  547. expect(once).toHaveBeenCalledOnce()
  548. expect(removed).not.toHaveBeenCalled()
  549. expect(process.signalCode).toBe('SIGTERM')
  550. expect(process.outcome).toEqual({ exitCode: null, signal: 'SIGTERM' })
  551. expect(process.kill('SIGTERM')).toBe(false)
  552. })
  553. it('emits spawn errors', async () => {
  554. const child = fakeChild()
  555. const process = new ManagedClaudeCodeProcess(child.handle)
  556. const errorListener = vi.fn()
  557. const removed = vi.fn()
  558. process.once('error', errorListener)
  559. process.on('error', removed)
  560. process.off('error', removed)
  561. child.fail(new Error('spawn boom'))
  562. await nextTask()
  563. expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({
  564. message: 'spawn boom',
  565. }))
  566. expect(removed).not.toHaveBeenCalled()
  567. })
  568. it('exposes a settled direct-child exit code', async () => {
  569. const child = fakeChild()
  570. const process = new ManagedClaudeCodeProcess(child.handle)
  571. child.settle({ exitCode: 7, signal: null })
  572. await nextTask()
  573. expect(process.exitCode).toBe(7)
  574. expect(process.signalCode).toBeNull()
  575. expect(process.outcome).toEqual({ exitCode: 7, signal: null })
  576. expect(process.kill('SIGTERM')).toBe(false)
  577. })
  578. })
  579. describe('query options and result mapping', () => {
  580. it('builds the fixed unattended options over the scrubbed environment', async () => {
  581. vi.stubEnv('HOST_VISIBLE', 'visible')
  582. vi.stubEnv('HOST_SECRET_TOKEN', 'must-not-leak')
  583. vi.stubEnv('DSH_INTERNAL', 'must-not-leak')
  584. const child = fakeChild()
  585. const spawn = vi.fn(() => child.handle)
  586. const captured: SubprocessHandle[] = []
  587. const diagnostics: string[] = []
  588. const spec: ClaudeCodeRunSpec = {
  589. cwd: '/workspace',
  590. executable: '/native/claude',
  591. permissionMode: 'acceptEdits',
  592. env: {
  593. HOST_VISIBLE: 'overridden',
  594. ANTHROPIC_API_KEY: 'explicit-fake-key',
  595. },
  596. disposeGraceMs: 17,
  597. spawn,
  598. }
  599. const controller = new AbortController()
  600. const options = claudeQueryOptions(
  601. spec,
  602. controller,
  603. (value) => {
  604. captured.push(value)
  605. },
  606. value => diagnostics.push(value),
  607. )
  608. expect(options).toMatchObject({
  609. abortController: controller,
  610. cwd: '/workspace',
  611. pathToClaudeCodeExecutable: '/native/claude',
  612. persistSession: false,
  613. disallowedTools: ['AskUserQuestion'],
  614. permissionMode: 'acceptEdits',
  615. supportedDialogKinds: ['refusal_fallback_prompt'],
  616. })
  617. expect(options).not.toHaveProperty('allowDangerouslySkipPermissions')
  618. expect(options.env).toMatchObject({
  619. HOST_VISIBLE: 'overridden',
  620. ANTHROPIC_API_KEY: 'explicit-fake-key',
  621. })
  622. expect(options.env).not.toHaveProperty('HOST_SECRET_TOKEN')
  623. expect(options.env).not.toHaveProperty('DSH_INTERNAL')
  624. expect(options).not.toHaveProperty('settingSources')
  625. const callbackSignal = new AbortController().signal
  626. await expect(options.canUseTool!(
  627. 'Bash',
  628. { command: 'cat /private/secret.txt', token: 'SECRET_TOKEN' },
  629. {
  630. signal: callbackSignal,
  631. toolUseID: 'tool-1',
  632. requestId: 'request-1',
  633. blockedPath: '/private/secret.txt',
  634. decisionReason: 'SECRET_TOKEN in /private/secret.txt',
  635. },
  636. )).resolves.toEqual({
  637. behavior: 'deny',
  638. message: 'This unattended Claude Code subagent cannot request human approval.',
  639. })
  640. await expect(options.onElicitation!(
  641. {
  642. serverName: 'private-server',
  643. message: 'enter SECRET_TOKEN',
  644. requestedSchema: { secret: true },
  645. },
  646. { signal: callbackSignal },
  647. )).resolves.toEqual({ action: 'decline' })
  648. await expect(options.onUserDialog!(
  649. {
  650. dialogKind: 'refusal_fallback_prompt',
  651. payload: { path: '/private/secret.txt', token: 'SECRET_TOKEN' },
  652. },
  653. { signal: callbackSignal },
  654. )).resolves.toEqual({ behavior: 'cancelled' })
  655. expect(diagnostics).toEqual([
  656. 'Claude Code unattended decision (mode: acceptEdits; request: tool permission; decision: denied): the provider does not request human approval',
  657. 'Claude Code unattended decision (mode: acceptEdits; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input',
  658. 'Claude Code unattended decision (mode: acceptEdits; request: user dialog; decision: cancelled): the provider does not render blocking dialogs',
  659. ])
  660. expect(diagnostics.join('\n')).not.toContain('SECRET_TOKEN')
  661. expect(diagnostics.join('\n')).not.toContain('/private/secret.txt')
  662. const spawned = options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  663. expect(spawned).toBeInstanceOf(ManagedClaudeCodeProcess)
  664. expect(captured).toEqual([child.handle])
  665. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  666. argv: ['/sdk/claude', '--output-format', 'stream-json'],
  667. cwd: '/workspace',
  668. graceMs: 17,
  669. }))
  670. })
  671. it.each(CLAUDE_CODE_PERMISSION_MODES)(
  672. 'maps the %s mode and only confirms the dangerous bypass',
  673. (permissionMode) => {
  674. const child = fakeChild()
  675. const options = claudeQueryOptions({
  676. cwd: '/workspace',
  677. executable: '/native/claude',
  678. permissionMode,
  679. env: {},
  680. disposeGraceMs: 17,
  681. spawn: () => child.handle,
  682. }, new AbortController(), () => {}, () => {})
  683. expect(options.permissionMode).toBe(permissionMode)
  684. expect(options.disallowedTools).toEqual(permissionMode === 'plan'
  685. ? ['AskUserQuestion', 'ExitPlanMode']
  686. : ['AskUserQuestion'])
  687. if (permissionMode === 'bypassPermissions') {
  688. expect(options.allowDangerouslySkipPermissions).toBe(true)
  689. expect(options).not.toHaveProperty('canUseTool')
  690. } else {
  691. expect(options).not.toHaveProperty('allowDangerouslySkipPermissions')
  692. expect(options.canUseTool).toBeTypeOf('function')
  693. }
  694. },
  695. )
  696. it('disallows ExitPlanMode before native plan-mode allow rules', () => {
  697. const child = fakeChild()
  698. const options = claudeQueryOptions({
  699. cwd: '/workspace',
  700. executable: '/native/claude',
  701. permissionMode: 'plan',
  702. env: {},
  703. disposeGraceMs: 17,
  704. spawn: () => child.handle,
  705. }, new AbortController(), () => {}, () => {})
  706. expect(options.disallowedTools).toEqual([
  707. 'AskUserQuestion',
  708. 'ExitPlanMode',
  709. ])
  710. })
  711. it('accepts only a non-error success with a non-blank final result', () => {
  712. expect(successfulResult(success('exact final'))).toBe('exact final')
  713. expect(() => successfulResult(success('answer', true)))
  714. .toThrow(expectedFailureDiagnostic('query-run', 'invalid-success'))
  715. expect(() => successfulResult(success(' \n ')))
  716. .toThrow(expectedFailureDiagnostic('query-run', 'invalid-success'))
  717. const sdkFailure = () => successfulResult(failure(
  718. 'error_during_execution',
  719. ['SECRET_TOKEN', '/private/secret.txt'],
  720. ))
  721. expect(sdkFailure).toThrow(expectedFailureDiagnostic(
  722. 'query-run',
  723. 'error_during_execution',
  724. ))
  725. expect(sdkFailure).not.toThrow('SECRET_TOKEN')
  726. expect(sdkFailure).not.toThrow('/private/secret.txt')
  727. expect(() => successfulResult(failure(
  728. 'error_max_turns',
  729. [],
  730. ))).toThrow(expectedFailureDiagnostic('query-run', 'error_max_turns'))
  731. const unknown = {
  732. type: 'result',
  733. subtype: 'future_failure',
  734. is_error: true,
  735. errors: ['SECRET_TOKEN'],
  736. } as unknown as SDKResultMessage
  737. expect(() => successfulResult(unknown))
  738. .toThrow(expectedFailureDiagnostic('query-run', 'unknown'))
  739. expect(() => successfulResult(unknown)).not.toThrow('future_failure')
  740. expect(() => successfulResult(unknown)).not.toThrow('SECRET_TOKEN')
  741. })
  742. it('consumes the complete stream and keeps the latest strict success', async () => {
  743. const query = queryFrom([
  744. { type: 'system', subtype: 'init' } as SDKMessage,
  745. success('first'),
  746. success('last'),
  747. ])
  748. await expect(consumeClaudeQuery(query)).resolves.toEqual({
  749. output: [{ type: 'text', text: 'last' }],
  750. stopReason: 'completed',
  751. })
  752. await expect(consumeClaudeQuery(
  753. queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]),
  754. )).rejects.toThrow(expectedFailureDiagnostic('query-run', 'missing-result'))
  755. const onPermissionDenied = vi.fn()
  756. await expect(consumeClaudeQuery(queryFrom([
  757. permissionDenied(),
  758. success('after denial'),
  759. ]), onPermissionDenied)).resolves.toEqual({
  760. output: [{ type: 'text', text: 'after denial' }],
  761. stopReason: 'completed',
  762. })
  763. expect(onPermissionDenied).toHaveBeenCalledOnce()
  764. })
  765. })
  766. describe('run publication, cancellation, and settlement', () => {
  767. it('publishes only after Query and managed child exist, then disposes once', async () => {
  768. const fixture = fakeRun([success('exact answer')])
  769. const run = await startClaudeCodeRun(
  770. request([
  771. { type: 'text', text: 'first' },
  772. { type: 'text', text: 'second' },
  773. ]),
  774. fixture.spec,
  775. )
  776. expect(fixture.options).toHaveLength(1)
  777. expect(fixture.spawnSpecs).toHaveLength(1)
  778. await expect(run.result).resolves.toEqual({
  779. output: [{ type: 'text', text: 'exact answer' }],
  780. stopReason: 'completed',
  781. })
  782. const first = run.dispose()
  783. const second = run.dispose()
  784. expect(second).toBe(first)
  785. await first
  786. expect(fixture.close).toHaveBeenCalledOnce()
  787. expect(fixture.child.terminate).toHaveBeenCalledOnce()
  788. })
  789. it('flattens every SDK error result without inventing shared stop reasons', async () => {
  790. const subtypes: ErrorSubtype[] = [
  791. 'error_during_execution',
  792. 'error_max_turns',
  793. 'error_max_budget_usd',
  794. 'error_max_structured_output_retries',
  795. ]
  796. for (const subtype of subtypes) {
  797. const fixture = fakeRun([failure(subtype)])
  798. const onError = vi.fn()
  799. const run = await startClaudeCodeRun(
  800. request(),
  801. { ...fixture.spec, onError },
  802. )
  803. await expect(run.result).resolves.toEqual({
  804. output: [],
  805. diagnostic: expectedFailureDiagnostic('query-run', subtype),
  806. stopReason: 'error',
  807. })
  808. expect(onError).toHaveBeenCalledWith(
  809. expect.any(Error),
  810. 'error',
  811. )
  812. await run.dispose()
  813. }
  814. })
  815. it('attaches a safe diagnostic when a permission denial precedes failure', async () => {
  816. const fixture = fakeRun([
  817. permissionDenied(),
  818. failure('error_during_execution'),
  819. ])
  820. const run = await startClaudeCodeRun(request(), fixture.spec)
  821. const result = await run.result
  822. expect(result).toEqual({
  823. output: [],
  824. diagnostic: `${expectedFailureDiagnostic('query-run', 'error_during_execution')}\nClaude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt`,
  825. stopReason: 'error',
  826. })
  827. expect(result.diagnostic).not.toContain('SECRET_TOKEN')
  828. expect(result.diagnostic).not.toContain('/private/secret.txt')
  829. await run.dispose()
  830. })
  831. it('omits captured diagnostics on success and isolates concurrent runs', async () => {
  832. const children = [fakeChild(), fakeChild()]
  833. let childIndex = 0
  834. const spec: ClaudeCodeRunSpec = {
  835. cwd: '/workspace',
  836. executable: '/native/claude',
  837. permissionMode: 'dontAsk',
  838. env: {},
  839. disposeGraceMs: 5,
  840. spawn: () => children[childIndex++]!.handle,
  841. }
  842. queryMock.mockImplementation(({ prompt, options }) => {
  843. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  844. return prompt === 'denied then completed'
  845. ? queryFrom([permissionDenied(), success('completed answer')])
  846. : queryFrom([failure('error_during_execution')])
  847. })
  848. const [completed, failed] = await Promise.all([
  849. startClaudeCodeRun(
  850. request([{ type: 'text', text: 'denied then completed' }]),
  851. spec,
  852. ),
  853. startClaudeCodeRun(
  854. request([{ type: 'text', text: 'unrelated failure' }]),
  855. spec,
  856. ),
  857. ])
  858. await expect(completed.result).resolves.toEqual({
  859. output: [{ type: 'text', text: 'completed answer' }],
  860. stopReason: 'completed',
  861. })
  862. await expect(failed.result).resolves.toEqual({
  863. output: [],
  864. diagnostic: expectedFailureDiagnostic(
  865. 'query-run',
  866. 'error_during_execution',
  867. ),
  868. stopReason: 'error',
  869. })
  870. await Promise.all([completed.dispose(), failed.dispose()])
  871. })
  872. it('fails closed when iteration rejects after a result', async () => {
  873. const child = fakeChild()
  874. const outcome = { exitCode: 31, signal: null } as const
  875. async function* stream(): AsyncGenerator<SDKMessage, void> {
  876. yield success('partial final')
  877. child.settle(outcome)
  878. await Promise.resolve()
  879. throw new Error('iterator boom')
  880. }
  881. queryMock.mockImplementation(({ options }) => {
  882. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  883. return Object.assign(stream(), { close: vi.fn() }) as unknown as Query
  884. })
  885. const run = await startClaudeCodeRun(request(), {
  886. cwd: '/workspace',
  887. executable: '/native/claude',
  888. permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
  889. env: {},
  890. disposeGraceMs: 5,
  891. spawn: () => child.handle,
  892. })
  893. await expect(run.result).resolves.toEqual({
  894. output: [],
  895. diagnostic: expectedFailureDiagnostic('query-run', 'unknown', outcome),
  896. stopReason: 'error',
  897. })
  898. await run.dispose()
  899. })
  900. it('maps invalid success and missing result to fixed query-run facts', async () => {
  901. for (const [messages, category] of [
  902. [[success('answer', true)], 'invalid-success'],
  903. [[success('')], 'invalid-success'],
  904. [[{ type: 'system', subtype: 'init' } as SDKMessage], 'missing-result'],
  905. ] as const) {
  906. const fixture = fakeRun(messages)
  907. const run = await startClaudeCodeRun(request(), fixture.spec)
  908. await expect(run.result).resolves.toEqual({
  909. output: [],
  910. diagnostic: expectedFailureDiagnostic('query-run', category),
  911. stopReason: 'error',
  912. })
  913. await run.dispose()
  914. }
  915. })
  916. it('reports an early process exit with independent code and signal facts', async () => {
  917. const outcomes: SubprocessOutcome[] = [
  918. { exitCode: 23, signal: null },
  919. { exitCode: null, signal: 'SIGABRT' },
  920. { exitCode: 23, signal: 'SIGABRT' },
  921. { exitCode: null, signal: null },
  922. ]
  923. for (const outcome of outcomes) {
  924. const child = fakeChild()
  925. async function* stream(): AsyncGenerator<SDKMessage, void> {
  926. child.settle(outcome)
  927. await Promise.resolve()
  928. throw new Error('SECRET_TOKEN from process transport')
  929. }
  930. queryMock.mockImplementation(({ options }) => {
  931. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  932. return Object.assign(stream(), { close: vi.fn() }) as unknown as Query
  933. })
  934. const run = await startClaudeCodeRun(request(), {
  935. cwd: '/workspace',
  936. executable: '/native/claude',
  937. permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
  938. env: {},
  939. disposeGraceMs: 5,
  940. spawn: () => child.handle,
  941. })
  942. const result = await run.result
  943. expect(result).toEqual({
  944. output: [],
  945. diagnostic: expectedFailureDiagnostic(
  946. 'process',
  947. 'process-exit',
  948. outcome,
  949. ),
  950. stopReason: 'error',
  951. })
  952. expect(result.diagnostic).not.toContain('SECRET_TOKEN')
  953. await run.dispose()
  954. }
  955. })
  956. it('gives local cancellation precedence and isolates overlapping controllers', async () => {
  957. const firstChild = fakeChild()
  958. const secondChild = fakeChild()
  959. const children = [firstChild, secondChild]
  960. const controllers: AbortController[] = []
  961. let index = 0
  962. const spec: ClaudeCodeRunSpec = {
  963. cwd: '/workspace',
  964. executable: '/native/claude',
  965. permissionMode: 'dontAsk',
  966. env: {},
  967. disposeGraceMs: 5,
  968. spawn: () => children[index++]!.handle,
  969. }
  970. queryMock.mockImplementation(({ prompt, options }) => {
  971. controllers.push(options.abortController!)
  972. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  973. return prompt === 'wait'
  974. ? waitingQuery(options.abortController!.signal)
  975. : queryFrom([success('second answer')])
  976. })
  977. const firstAbort = new AbortController()
  978. const first = await startClaudeCodeRun(
  979. request([{ type: 'text', text: 'wait' }], firstAbort.signal),
  980. spec,
  981. )
  982. const second = await startClaudeCodeRun(
  983. request([{ type: 'text', text: 'finish' }]),
  984. spec,
  985. )
  986. expect(controllers).toHaveLength(2)
  987. expect(controllers[0]).not.toBe(controllers[1])
  988. firstAbort.abort(new Error('parent cancelled'))
  989. await expect(first.result).resolves.toEqual({
  990. output: [],
  991. stopReason: 'aborted',
  992. })
  993. await expect(second.result).resolves.toEqual({
  994. output: [{ type: 'text', text: 'second answer' }],
  995. stopReason: 'completed',
  996. })
  997. expect(controllers[1]!.signal.aborted).toBe(false)
  998. await Promise.all([first.dispose(), second.dispose()])
  999. })
  1000. it('keeps local cancellation authoritative when the SDK iterator ends normally', async () => {
  1001. const parentAbort = new AbortController()
  1002. const child = fakeChild()
  1003. async function* stream(): AsyncGenerator<SDKMessage, void> {
  1004. yield success('candidate answer')
  1005. parentAbort.abort(new Error('parent cancelled at iterator completion'))
  1006. }
  1007. queryMock.mockImplementation(({ options }) => {
  1008. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1009. return Object.assign(stream(), { close: vi.fn() }) as unknown as Query
  1010. })
  1011. const run = await startClaudeCodeRun(
  1012. request(undefined, parentAbort.signal),
  1013. {
  1014. cwd: '/workspace',
  1015. executable: '/native/claude',
  1016. permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
  1017. env: {},
  1018. disposeGraceMs: 5,
  1019. spawn: () => child.handle,
  1020. },
  1021. )
  1022. await expect(run.result).resolves.toEqual({
  1023. output: [],
  1024. stopReason: 'aborted',
  1025. })
  1026. await run.dispose()
  1027. })
  1028. it('rejects pre-abort and every incomplete startup transaction', async () => {
  1029. const preAborted = new AbortController()
  1030. preAborted.abort()
  1031. const unused = fakeRun()
  1032. await expect(startClaudeCodeRun(
  1033. request(undefined, preAborted.signal),
  1034. unused.spec,
  1035. )).rejects.toThrow('aborted before SDK startup')
  1036. expect(unused.options).toEqual([])
  1037. const noChildClose = vi.fn()
  1038. queryMock.mockImplementationOnce(
  1039. () => queryFrom([], undefined, noChildClose),
  1040. )
  1041. await expect(startClaudeCodeRun(request(), {
  1042. ...unused.spec,
  1043. })).rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
  1044. expect(noChildClose).toHaveBeenCalledOnce()
  1045. const closeFailure = vi.fn(() => { throw new Error('close boom') })
  1046. queryMock.mockImplementationOnce(
  1047. () => queryFrom([], undefined, closeFailure),
  1048. )
  1049. const noChild = startClaudeCodeRun(request(), {
  1050. ...unused.spec,
  1051. })
  1052. await expect(noChild)
  1053. .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
  1054. await expect(noChild).rejects.toThrow(
  1055. `${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`,
  1056. )
  1057. await expect(noChild).rejects.toBeInstanceOf(AggregateError)
  1058. const startupAbort = new AbortController()
  1059. const abortedChild = fakeChild()
  1060. const abortedClose = vi.fn()
  1061. queryMock.mockImplementationOnce(({ options }) => {
  1062. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1063. startupAbort.abort(new Error('startup cancelled'))
  1064. return queryFrom([], undefined, abortedClose)
  1065. })
  1066. const abortedDuringStartup = startClaudeCodeRun(
  1067. request(undefined, startupAbort.signal),
  1068. {
  1069. ...unused.spec,
  1070. spawn: () => abortedChild.handle,
  1071. },
  1072. )
  1073. await expect(abortedDuringStartup)
  1074. .rejects.toThrow('aborted before SDK startup')
  1075. expect(abortedClose).toHaveBeenCalledOnce()
  1076. expect(abortedChild.terminate).toHaveBeenCalledOnce()
  1077. const cleanupAbort = new AbortController()
  1078. const cleanupFailedChild = fakeChild({
  1079. waitForExitError: new Error('SECRET_TOKEN cleanup wait failure'),
  1080. })
  1081. queryMock.mockImplementationOnce(({ options }) => {
  1082. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1083. cleanupAbort.abort(new Error('startup cancelled'))
  1084. return queryFrom([])
  1085. })
  1086. const cancelledCleanupFailure = startClaudeCodeRun(
  1087. request(undefined, cleanupAbort.signal),
  1088. {
  1089. ...unused.spec,
  1090. spawn: () => cleanupFailedChild.handle,
  1091. },
  1092. )
  1093. await expect(cancelledCleanupFailure)
  1094. .rejects.toBeInstanceOf(AggregateError)
  1095. await expect(cancelledCleanupFailure)
  1096. .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
  1097. await expect(cancelledCleanupFailure).rejects.toThrow(
  1098. `${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown', { exitCode: 0, signal: null })}`,
  1099. )
  1100. await expect(cancelledCleanupFailure)
  1101. .rejects.not.toThrow('SECRET_TOKEN')
  1102. queryMock.mockImplementationOnce(() => {
  1103. throw new Error('query failed before resource creation')
  1104. })
  1105. const queryFailureOnError = vi.fn<
  1106. NonNullable<ClaudeCodeRunSpec['onError']>
  1107. >()
  1108. const queryFailure = startClaudeCodeRun(request(), {
  1109. ...unused.spec,
  1110. onError: queryFailureOnError,
  1111. })
  1112. await expect(queryFailure)
  1113. .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
  1114. await expect(queryFailure).rejects.not.toThrow(
  1115. 'query failed before resource creation',
  1116. )
  1117. expect(queryFailureOnError).toHaveBeenCalledWith(
  1118. expect.any(Error),
  1119. 'error',
  1120. )
  1121. expect(errorCause(queryFailureOnError.mock.calls[0]?.[0])?.message)
  1122. .toBe('query failed before resource creation')
  1123. const spawned = fakeChild()
  1124. const spawnSpecs: SubprocessSpawnSpec[] = []
  1125. let factoryController: AbortController | undefined
  1126. queryMock.mockImplementationOnce(({ options }) => {
  1127. factoryController = options.abortController
  1128. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1129. spawned.settle({ exitCode: 17, signal: 'SIGABRT' })
  1130. throw new Error('query construction failed')
  1131. })
  1132. const factoryFailure = startClaudeCodeRun(request(), {
  1133. ...unused.spec,
  1134. spawn: (spawnSpec) => {
  1135. spawnSpecs.push(spawnSpec)
  1136. return spawned.handle
  1137. },
  1138. })
  1139. await expect(factoryFailure).rejects.toThrow(expectedFailureDiagnostic(
  1140. 'query-start',
  1141. 'unknown',
  1142. { exitCode: 17, signal: 'SIGABRT' },
  1143. ))
  1144. await expect(factoryFailure).rejects.not.toThrow('query construction failed')
  1145. expect(spawnSpecs).toHaveLength(1)
  1146. expect(factoryController?.signal.aborted).toBe(true)
  1147. expect(spawned.terminate).toHaveBeenCalledOnce()
  1148. const cleanupRaceAbort = new AbortController()
  1149. const cleanupRaceChild = fakeChild({ exitOnTerminate: false })
  1150. queryMock.mockImplementationOnce(({ options }) => {
  1151. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1152. throw new Error('query failed before cleanup wait')
  1153. })
  1154. const cleanupRace = startClaudeCodeRun(
  1155. request(undefined, cleanupRaceAbort.signal),
  1156. {
  1157. ...unused.spec,
  1158. spawn: () => cleanupRaceChild.handle,
  1159. },
  1160. )
  1161. await nextTask()
  1162. cleanupRaceAbort.abort(new Error('cancelled during cleanup'))
  1163. cleanupRaceChild.settle()
  1164. await expect(cleanupRace).rejects.toThrow('aborted before SDK startup')
  1165. const failedSpawn = fakeChild({
  1166. pid: -1,
  1167. doneError: new Error('spawn failed'),
  1168. })
  1169. const failed = fakeRun([], undefined, failedSpawn)
  1170. const failedStartup = startClaudeCodeRun(request(), failed.spec)
  1171. await expect(failedStartup)
  1172. .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
  1173. await expect(failedStartup).rejects.toThrow(
  1174. `${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`,
  1175. )
  1176. await expect(failedStartup).rejects.toBeInstanceOf(AggregateError)
  1177. expect(failed.close).toHaveBeenCalledOnce()
  1178. })
  1179. })
  1180. describe('query and process disposal', () => {
  1181. it('closes the query, terminates the tree, and waits for direct-child outcome', async () => {
  1182. const child = fakeChild()
  1183. const close = vi.fn()
  1184. await disposeClaudeCodeChild({ close }, child.handle)
  1185. expect(close).toHaveBeenCalledOnce()
  1186. expect(child.terminate).toHaveBeenCalledOnce()
  1187. expect(child.waitForExit).toHaveBeenCalledOnce()
  1188. expect(child.waitForExit).toHaveBeenCalledWith()
  1189. await expect(child.handle.done).resolves.toEqual({
  1190. exitCode: 0,
  1191. signal: null,
  1192. })
  1193. })
  1194. it('reports a published teardown failure to the Host diagnostic sink', async () => {
  1195. const fixture = fakeRun([success('exact answer')])
  1196. const onError = vi.fn<NonNullable<ClaudeCodeRunSpec['onError']>>()
  1197. const run = await startClaudeCodeRun(request(), {
  1198. ...fixture.spec,
  1199. onError,
  1200. })
  1201. await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
  1202. fixture.close.mockImplementationOnce(() => {
  1203. throw new Error('SECRET_TOKEN close failure')
  1204. })
  1205. await expect(run.dispose()).rejects.toThrow(
  1206. expectedFailureDiagnostic('teardown', 'unknown', {
  1207. exitCode: 0,
  1208. signal: null,
  1209. }),
  1210. )
  1211. expect(onError).toHaveBeenCalledWith(expect.any(Error), 'error')
  1212. expect(errorCause(onError.mock.calls[0]?.[0])?.message)
  1213. .toBe('SECRET_TOKEN close failure')
  1214. })
  1215. it('does not finish disposal before the managed tree exits', async () => {
  1216. const child = fakeChild({ exitOnTerminate: false })
  1217. let disposed = false
  1218. const disposal = disposeClaudeCodeChild(
  1219. { close: vi.fn() },
  1220. child.handle,
  1221. ).then(() => {
  1222. disposed = true
  1223. })
  1224. await nextTask()
  1225. expect(disposed).toBe(false)
  1226. child.settle()
  1227. await disposal
  1228. expect(disposed).toBe(true)
  1229. })
  1230. it('reports wait, close, and direct-child failures without skipping cleanup', async () => {
  1231. const waitFailure = fakeChild({
  1232. waitForExitError: new Error('wait boom'),
  1233. })
  1234. const closeFailure = vi.fn(() => { throw new Error('close boom') })
  1235. const waitAndClose = disposeClaudeCodeChild(
  1236. { close: closeFailure },
  1237. waitFailure.handle,
  1238. )
  1239. await expect(waitAndClose).rejects.toThrow(expectedFailureDiagnostic(
  1240. 'teardown',
  1241. 'unknown',
  1242. { exitCode: 0, signal: null },
  1243. ))
  1244. await expect(waitAndClose).rejects.toBeInstanceOf(AggregateError)
  1245. expect(waitFailure.terminate).toHaveBeenCalledOnce()
  1246. const doneFailure = fakeChild({
  1247. pid: -1,
  1248. doneError: new Error('spawn boom'),
  1249. })
  1250. await expect(disposeClaudeCodeChild(
  1251. { close: vi.fn() },
  1252. doneFailure.handle,
  1253. )).rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown'))
  1254. const both = fakeChild({
  1255. pid: -1,
  1256. doneError: new Error('spawn boom'),
  1257. })
  1258. const bothFailures = disposeClaudeCodeChild(
  1259. { close: () => { throw new Error('close boom') } },
  1260. both.handle,
  1261. )
  1262. await expect(bothFailures)
  1263. .rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown'))
  1264. await expect(bothFailures).rejects.toBeInstanceOf(AggregateError)
  1265. })
  1266. })