subagent-claude-code.spec.ts 41 KB

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