subagent-claude-code.spec.ts 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088
  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 one fixed 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('accepts only the five fixed non-interactive permission modes', () => {
  323. expect(claudeCode.Config({}).permissionMode)
  324. .toBe(DEFAULT_CLAUDE_CODE_PERMISSION_MODE)
  325. for (const permissionMode of CLAUDE_CODE_PERMISSION_MODES) {
  326. expect(claudeCode.Config({ permissionMode }).permissionMode)
  327. .toBe(permissionMode)
  328. }
  329. for (const permissionMode of ['default', 'interactive', 'future-mode']) {
  330. expect(() => claudeCode.Config({ permissionMode } as never)).toThrow()
  331. }
  332. })
  333. it('resolves the safe permission default when apply is called directly', async () => {
  334. const ctx = new Context()
  335. await ctx.plugin(SubagentRuntime)
  336. await ctx.plugin(LocalSubprocessRuntime)
  337. claudeCode.apply(ctx, { env: {}, disposeGraceMs: 3_000 })
  338. expect(ctx.subagents.getProvider('claude-code')).toBeDefined()
  339. await ctx.fiber.dispose()
  340. })
  341. it('starts through the registered provider with its resolved config and diagnostics', async () => {
  342. const ctx = new Context()
  343. await ctx.plugin(SubagentRuntime)
  344. await ctx.plugin(LocalSubprocessRuntime)
  345. const child = fakeChild()
  346. const spawn = vi.spyOn(ctx.subprocess, 'spawn')
  347. .mockImplementation(() => child.handle)
  348. const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable')
  349. .mockResolvedValue('/native/claude')
  350. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  351. await ctx.plugin(claudeCode, {
  352. env: {
  353. ANTHROPIC_API_KEY: 'provider-fake-key',
  354. CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config',
  355. HOME: '/private/tmp/dsh-claude-code-unit-home',
  356. },
  357. permissionMode: 'auto',
  358. disposeGraceMs: 29,
  359. })
  360. await expect(ctx.subagents.start('claude-code', {
  361. ...request(),
  362. parent: {
  363. id: 'parent-without-cwd',
  364. session: { header: {} },
  365. } as unknown as Agent,
  366. })).rejects.toThrow(
  367. 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one',
  368. )
  369. expect(queryMock).not.toHaveBeenCalled()
  370. resolveExecutable.mockRejectedValueOnce(new Error('claude missing from PATH'))
  371. await expect(ctx.subagents.start('claude-code', request()))
  372. .rejects.toThrow('claude missing from PATH')
  373. expect(queryMock).not.toHaveBeenCalled()
  374. const run = await ctx.subagents.start('claude-code', request())
  375. child.settle({ exitCode: 9, signal: null })
  376. child.stdout.end()
  377. await expect(run.result).resolves.toEqual({
  378. output: [],
  379. stopReason: 'error',
  380. })
  381. expect(warn).toHaveBeenCalledWith(expect.stringContaining(
  382. 'subagent-claude-code: child run failed (error):',
  383. ))
  384. expect(resolveExecutable).toHaveBeenCalledWith(
  385. 'claude',
  386. expect.objectContaining({ ANTHROPIC_API_KEY: 'provider-fake-key' }),
  387. expect.any(AbortSignal),
  388. )
  389. expect(queryMock.mock.calls[0]?.[0].options.pathToClaudeCodeExecutable)
  390. .toBe('/native/claude')
  391. expect(queryMock.mock.calls[0]?.[0].options.permissionMode).toBe('auto')
  392. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  393. cwd: process.cwd(),
  394. graceMs: 29,
  395. }))
  396. expect(spawn.mock.calls[0]?.[0].env).toMatchObject({
  397. ANTHROPIC_API_KEY: 'provider-fake-key',
  398. })
  399. await run.dispose()
  400. await ctx.fiber.dispose()
  401. })
  402. it('keeps the Loader namespace shape and package-owned empty invariant', async () => {
  403. expect('default' in claudeCode).toBe(false)
  404. expect(claudeCode.name).toBe('subagent-claude-code')
  405. expect(claudeCode.inject).toEqual(['subagents', 'subprocess'])
  406. const loader = Object.create(Loader.prototype) as Loader
  407. expect(loader.unwrapExports(claudeCode)).toBe(claudeCode)
  408. const dispose = vi.fn()
  409. const register = vi.fn((
  410. _packageName: string,
  411. _installer: InvariantInstaller,
  412. ) => dispose)
  413. const ctx = { invariants: { register } } as unknown as Context
  414. await expect(invariant.apply(ctx)).resolves.toBe(dispose)
  415. expect(register).toHaveBeenCalledWith(
  416. '@deepseek-ai/dsh-subagent-claude-code',
  417. expect.any(Function),
  418. )
  419. const install = register.mock.calls[0]![1]
  420. await install(new Context(), (message) => { throw new Error(message) })
  421. expect(invariant.name).toBe('subagent-claude-code-invariant')
  422. expect(invariant.inject).toEqual(['invariants'])
  423. })
  424. })
  425. describe('official spawn projection', () => {
  426. it('forwards command, arguments, cwd, environment, and signal exactly', () => {
  427. vi.stubEnv('SDK_REMOVED_AMBIENT', 'ambient-value')
  428. const signal = new AbortController().signal
  429. const options = sdkSpawnOptions({
  430. command: '/official/claude',
  431. args: ['--one', 'two'],
  432. cwd: '/parent/workspace',
  433. env: { A: 'one', B: undefined, C: 'three' },
  434. signal,
  435. })
  436. expect(sdkEnvironmentOverlay(options.env)).toEqual(expect.objectContaining({
  437. A: 'one',
  438. B: undefined,
  439. C: 'three',
  440. SDK_REMOVED_AMBIENT: undefined,
  441. }))
  442. const spawnSpec = claudeSpawnSpec(options, 321)
  443. expect(spawnSpec).toMatchObject({
  444. argv: ['/official/claude', '--one', 'two'],
  445. cwd: '/parent/workspace',
  446. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
  447. graceMs: 321,
  448. signal,
  449. })
  450. expect(spawnSpec.env).toEqual(expect.objectContaining({
  451. A: 'one',
  452. B: undefined,
  453. C: 'three',
  454. SDK_REMOVED_AMBIENT: undefined,
  455. }))
  456. const missingCwd = sdkSpawnOptions()
  457. delete missingCwd.cwd
  458. expect(() => claudeSpawnSpec(
  459. missingCwd,
  460. 321,
  461. )).toThrow('SDK spawn request omitted its workspace')
  462. expect(() => claudeSpawnSpec(
  463. sdkSpawnOptions({ cwd: '' }),
  464. 321,
  465. )).toThrow('SDK spawn request omitted its workspace')
  466. })
  467. it.each(['cmd', 'bat'])('routes a Windows .%s shim through cmd.exe', (extension) => {
  468. const command = String.raw`C:\Program Files\Claude\claude.${extension}`
  469. const spec = claudeSpawnSpec(sdkSpawnOptions({
  470. command,
  471. args: ['--output-format', 'stream-json'],
  472. }), 7, 'win32')
  473. expect(spec.argv).toEqual([
  474. 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%',
  475. '--output-format', 'stream-json',
  476. ])
  477. expect(spec.env).toEqual(expect.objectContaining({
  478. DSH_CLAUDE_CODE_EXECUTABLE: `"${command}"`,
  479. }))
  480. })
  481. it('projects streams, exit facts, listeners, and idempotent tree termination', async () => {
  482. const child = fakeChild({ exitOnTerminate: false })
  483. const process = new ManagedClaudeCodeProcess(child.handle)
  484. expect(process.stdin).toBe(child.stdin)
  485. expect(process.stdout).toBe(child.stdout)
  486. expect(process.killed).toBe(false)
  487. expect(process.exitCode).toBeNull()
  488. expect(process.signalCode).toBeNull()
  489. const exit = vi.fn()
  490. const once = vi.fn()
  491. const removed = vi.fn()
  492. process.on('exit', exit)
  493. process.once('exit', once)
  494. process.on('exit', removed)
  495. process.off('exit', removed)
  496. expect(process.kill('SIGTERM')).toBe(true)
  497. expect(process.killed).toBe(true)
  498. expect(process.kill('SIGKILL')).toBe(false)
  499. expect(child.terminate).toHaveBeenCalledOnce()
  500. child.settle({ exitCode: null, signal: 'SIGTERM' })
  501. await nextTask()
  502. expect(exit).toHaveBeenCalledWith(null, 'SIGTERM')
  503. expect(once).toHaveBeenCalledOnce()
  504. expect(removed).not.toHaveBeenCalled()
  505. expect(process.signalCode).toBe('SIGTERM')
  506. expect(process.kill('SIGTERM')).toBe(false)
  507. })
  508. it('emits spawn errors', async () => {
  509. const child = fakeChild()
  510. const process = new ManagedClaudeCodeProcess(child.handle)
  511. const errorListener = vi.fn()
  512. const removed = vi.fn()
  513. process.once('error', errorListener)
  514. process.on('error', removed)
  515. process.off('error', removed)
  516. child.fail(new Error('spawn boom'))
  517. await nextTask()
  518. expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({
  519. message: 'spawn boom',
  520. }))
  521. expect(removed).not.toHaveBeenCalled()
  522. })
  523. it('exposes a settled direct-child exit code', async () => {
  524. const child = fakeChild()
  525. const process = new ManagedClaudeCodeProcess(child.handle)
  526. child.settle({ exitCode: 7, signal: null })
  527. await nextTask()
  528. expect(process.exitCode).toBe(7)
  529. expect(process.signalCode).toBeNull()
  530. expect(process.kill('SIGTERM')).toBe(false)
  531. })
  532. })
  533. describe('query options and result mapping', () => {
  534. it('builds the fixed unattended options over the scrubbed environment', async () => {
  535. vi.stubEnv('HOST_VISIBLE', 'visible')
  536. vi.stubEnv('HOST_SECRET_TOKEN', 'must-not-leak')
  537. vi.stubEnv('DSH_INTERNAL', 'must-not-leak')
  538. const child = fakeChild()
  539. const spawn = vi.fn(() => child.handle)
  540. const captured: SubprocessHandle[] = []
  541. const diagnostics: string[] = []
  542. const spec: ClaudeCodeRunSpec = {
  543. cwd: '/workspace',
  544. executable: '/native/claude',
  545. permissionMode: 'acceptEdits',
  546. env: {
  547. HOST_VISIBLE: 'overridden',
  548. ANTHROPIC_API_KEY: 'explicit-fake-key',
  549. },
  550. disposeGraceMs: 17,
  551. spawn,
  552. }
  553. const controller = new AbortController()
  554. const options = claudeQueryOptions(
  555. spec,
  556. controller,
  557. (value) => {
  558. captured.push(value)
  559. },
  560. value => diagnostics.push(value),
  561. )
  562. expect(options).toMatchObject({
  563. abortController: controller,
  564. cwd: '/workspace',
  565. pathToClaudeCodeExecutable: '/native/claude',
  566. persistSession: false,
  567. disallowedTools: ['AskUserQuestion'],
  568. permissionMode: 'acceptEdits',
  569. supportedDialogKinds: ['refusal_fallback_prompt'],
  570. })
  571. expect(options).not.toHaveProperty('allowDangerouslySkipPermissions')
  572. expect(options.env).toMatchObject({
  573. HOST_VISIBLE: 'overridden',
  574. ANTHROPIC_API_KEY: 'explicit-fake-key',
  575. })
  576. expect(options.env).not.toHaveProperty('HOST_SECRET_TOKEN')
  577. expect(options.env).not.toHaveProperty('DSH_INTERNAL')
  578. expect(options).not.toHaveProperty('settingSources')
  579. const callbackSignal = new AbortController().signal
  580. await expect(options.canUseTool!(
  581. 'Bash',
  582. { command: 'cat /private/secret.txt', token: 'SECRET_TOKEN' },
  583. {
  584. signal: callbackSignal,
  585. toolUseID: 'tool-1',
  586. requestId: 'request-1',
  587. blockedPath: '/private/secret.txt',
  588. decisionReason: 'SECRET_TOKEN in /private/secret.txt',
  589. },
  590. )).resolves.toEqual({
  591. behavior: 'deny',
  592. message: 'This unattended Claude Code subagent cannot request human approval.',
  593. })
  594. await expect(options.onElicitation!(
  595. {
  596. serverName: 'private-server',
  597. message: 'enter SECRET_TOKEN',
  598. requestedSchema: { secret: true },
  599. },
  600. { signal: callbackSignal },
  601. )).resolves.toEqual({ action: 'decline' })
  602. await expect(options.onUserDialog!(
  603. {
  604. dialogKind: 'refusal_fallback_prompt',
  605. payload: { path: '/private/secret.txt', token: 'SECRET_TOKEN' },
  606. },
  607. { signal: callbackSignal },
  608. )).resolves.toEqual({ behavior: 'cancelled' })
  609. expect(diagnostics).toEqual([
  610. 'Claude Code unattended decision (mode: acceptEdits; request: tool permission; decision: denied): the provider does not request human approval',
  611. 'Claude Code unattended decision (mode: acceptEdits; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input',
  612. 'Claude Code unattended decision (mode: acceptEdits; request: user dialog; decision: cancelled): the provider does not render blocking dialogs',
  613. ])
  614. expect(diagnostics.join('\n')).not.toContain('SECRET_TOKEN')
  615. expect(diagnostics.join('\n')).not.toContain('/private/secret.txt')
  616. const spawned = options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  617. expect(spawned).toBeInstanceOf(ManagedClaudeCodeProcess)
  618. expect(captured).toEqual([child.handle])
  619. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  620. argv: ['/sdk/claude', '--output-format', 'stream-json'],
  621. cwd: '/workspace',
  622. graceMs: 17,
  623. }))
  624. })
  625. it.each(CLAUDE_CODE_PERMISSION_MODES)(
  626. 'maps the %s mode and only confirms the dangerous bypass',
  627. (permissionMode) => {
  628. const child = fakeChild()
  629. const options = claudeQueryOptions({
  630. cwd: '/workspace',
  631. executable: '/native/claude',
  632. permissionMode,
  633. env: {},
  634. disposeGraceMs: 17,
  635. spawn: () => child.handle,
  636. }, new AbortController(), () => {}, () => {})
  637. expect(options.permissionMode).toBe(permissionMode)
  638. if (permissionMode === 'bypassPermissions') {
  639. expect(options.allowDangerouslySkipPermissions).toBe(true)
  640. expect(options).not.toHaveProperty('canUseTool')
  641. } else {
  642. expect(options).not.toHaveProperty('allowDangerouslySkipPermissions')
  643. expect(options.canUseTool).toBeTypeOf('function')
  644. }
  645. },
  646. )
  647. it('accepts only a non-error success with a non-blank final result', () => {
  648. expect(successfulResult(success('exact final'))).toBe('exact final')
  649. expect(() => successfulResult(success('answer', true)))
  650. .toThrow('marked as an error')
  651. expect(() => successfulResult(success(' \n ')))
  652. .toThrow('contained no answer')
  653. expect(() => successfulResult(failure(
  654. 'error_during_execution',
  655. ['first', 'second'],
  656. ))).toThrow('first; second')
  657. expect(() => successfulResult(failure(
  658. 'error_max_turns',
  659. [],
  660. ))).toThrow('error_max_turns')
  661. })
  662. it('consumes the complete stream and keeps the latest strict success', async () => {
  663. const query = queryFrom([
  664. { type: 'system', subtype: 'init' } as SDKMessage,
  665. success('first'),
  666. success('last'),
  667. ])
  668. await expect(consumeClaudeQuery(query)).resolves.toEqual({
  669. output: [{ type: 'text', text: 'last' }],
  670. stopReason: 'completed',
  671. })
  672. await expect(consumeClaudeQuery(
  673. queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]),
  674. )).rejects.toThrow('ended without a result')
  675. const onPermissionDenied = vi.fn()
  676. await expect(consumeClaudeQuery(queryFrom([
  677. permissionDenied(),
  678. success('after denial'),
  679. ]), onPermissionDenied)).resolves.toEqual({
  680. output: [{ type: 'text', text: 'after denial' }],
  681. stopReason: 'completed',
  682. })
  683. expect(onPermissionDenied).toHaveBeenCalledOnce()
  684. })
  685. })
  686. describe('run publication, cancellation, and settlement', () => {
  687. it('publishes only after Query and managed child exist, then disposes once', async () => {
  688. const fixture = fakeRun([success('exact answer')])
  689. const run = await startClaudeCodeRun(
  690. request([
  691. { type: 'text', text: 'first' },
  692. { type: 'text', text: 'second' },
  693. ]),
  694. fixture.spec,
  695. )
  696. expect(fixture.options).toHaveLength(1)
  697. expect(fixture.spawnSpecs).toHaveLength(1)
  698. await expect(run.result).resolves.toEqual({
  699. output: [{ type: 'text', text: 'exact answer' }],
  700. stopReason: 'completed',
  701. })
  702. const first = run.dispose()
  703. const second = run.dispose()
  704. expect(second).toBe(first)
  705. await first
  706. expect(fixture.close).toHaveBeenCalledOnce()
  707. expect(fixture.child.terminate).toHaveBeenCalledOnce()
  708. })
  709. it('flattens every SDK error result without inventing shared stop reasons', async () => {
  710. const subtypes: ErrorSubtype[] = [
  711. 'error_during_execution',
  712. 'error_max_turns',
  713. 'error_max_budget_usd',
  714. 'error_max_structured_output_retries',
  715. ]
  716. for (const subtype of subtypes) {
  717. const fixture = fakeRun([failure(subtype)])
  718. const onError = vi.fn()
  719. const run = await startClaudeCodeRun(
  720. request(),
  721. { ...fixture.spec, onError },
  722. )
  723. await expect(run.result).resolves.toEqual({
  724. output: [],
  725. stopReason: 'error',
  726. })
  727. expect(onError).toHaveBeenCalledWith(
  728. expect.any(Error),
  729. 'error',
  730. )
  731. await run.dispose()
  732. }
  733. })
  734. it('attaches a safe diagnostic when a permission denial precedes failure', async () => {
  735. const fixture = fakeRun([
  736. permissionDenied(),
  737. failure('error_during_execution'),
  738. ])
  739. const run = await startClaudeCodeRun(request(), fixture.spec)
  740. const result = await run.result
  741. expect(result).toEqual({
  742. output: [],
  743. diagnostic: 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt',
  744. stopReason: 'error',
  745. })
  746. expect(result.diagnostic).not.toContain('SECRET_TOKEN')
  747. expect(result.diagnostic).not.toContain('/private/secret.txt')
  748. await run.dispose()
  749. })
  750. it('omits captured diagnostics on success and isolates concurrent runs', async () => {
  751. const children = [fakeChild(), fakeChild()]
  752. let childIndex = 0
  753. const spec: ClaudeCodeRunSpec = {
  754. cwd: '/workspace',
  755. executable: '/native/claude',
  756. permissionMode: 'dontAsk',
  757. env: {},
  758. disposeGraceMs: 5,
  759. spawn: () => children[childIndex++]!.handle,
  760. }
  761. queryMock.mockImplementation(({ prompt, options }) => {
  762. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  763. return prompt === 'denied then completed'
  764. ? queryFrom([permissionDenied(), success('completed answer')])
  765. : queryFrom([failure('error_during_execution')])
  766. })
  767. const [completed, failed] = await Promise.all([
  768. startClaudeCodeRun(
  769. request([{ type: 'text', text: 'denied then completed' }]),
  770. spec,
  771. ),
  772. startClaudeCodeRun(
  773. request([{ type: 'text', text: 'unrelated failure' }]),
  774. spec,
  775. ),
  776. ])
  777. await expect(completed.result).resolves.toEqual({
  778. output: [{ type: 'text', text: 'completed answer' }],
  779. stopReason: 'completed',
  780. })
  781. await expect(failed.result).resolves.toEqual({
  782. output: [],
  783. stopReason: 'error',
  784. })
  785. await Promise.all([completed.dispose(), failed.dispose()])
  786. })
  787. it('fails closed when iteration rejects after a result', async () => {
  788. const fixture = fakeRun(
  789. [success('partial final')],
  790. new Error('iterator boom'),
  791. )
  792. const run = await startClaudeCodeRun(request(), fixture.spec)
  793. await expect(run.result).resolves.toEqual({
  794. output: [],
  795. stopReason: 'error',
  796. })
  797. await run.dispose()
  798. })
  799. it('maps invalid success and missing result to error', async () => {
  800. for (const messages of [
  801. [success('answer', true)],
  802. [success('')],
  803. [{ type: 'system', subtype: 'init' } as SDKMessage],
  804. ]) {
  805. const fixture = fakeRun(messages)
  806. const run = await startClaudeCodeRun(request(), fixture.spec)
  807. await expect(run.result).resolves.toMatchObject({
  808. stopReason: 'error',
  809. })
  810. await run.dispose()
  811. }
  812. })
  813. it('gives local cancellation precedence and isolates overlapping controllers', async () => {
  814. const firstChild = fakeChild()
  815. const secondChild = fakeChild()
  816. const children = [firstChild, secondChild]
  817. const controllers: AbortController[] = []
  818. let index = 0
  819. const spec: ClaudeCodeRunSpec = {
  820. cwd: '/workspace',
  821. executable: '/native/claude',
  822. permissionMode: 'dontAsk',
  823. env: {},
  824. disposeGraceMs: 5,
  825. spawn: () => children[index++]!.handle,
  826. }
  827. queryMock.mockImplementation(({ prompt, options }) => {
  828. controllers.push(options.abortController!)
  829. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  830. return prompt === 'wait'
  831. ? waitingQuery(options.abortController!.signal)
  832. : queryFrom([success('second answer')])
  833. })
  834. const firstAbort = new AbortController()
  835. const first = await startClaudeCodeRun(
  836. request([{ type: 'text', text: 'wait' }], firstAbort.signal),
  837. spec,
  838. )
  839. const second = await startClaudeCodeRun(
  840. request([{ type: 'text', text: 'finish' }]),
  841. spec,
  842. )
  843. expect(controllers).toHaveLength(2)
  844. expect(controllers[0]).not.toBe(controllers[1])
  845. firstAbort.abort(new Error('parent cancelled'))
  846. await expect(first.result).resolves.toEqual({
  847. output: [],
  848. stopReason: 'aborted',
  849. })
  850. await expect(second.result).resolves.toEqual({
  851. output: [{ type: 'text', text: 'second answer' }],
  852. stopReason: 'completed',
  853. })
  854. expect(controllers[1]!.signal.aborted).toBe(false)
  855. await Promise.all([first.dispose(), second.dispose()])
  856. })
  857. it('keeps local cancellation authoritative when the SDK iterator ends normally', async () => {
  858. const parentAbort = new AbortController()
  859. const child = fakeChild()
  860. async function* stream(): AsyncGenerator<SDKMessage, void> {
  861. yield success('candidate answer')
  862. parentAbort.abort(new Error('parent cancelled at iterator completion'))
  863. }
  864. queryMock.mockImplementation(({ options }) => {
  865. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  866. return Object.assign(stream(), { close: vi.fn() }) as unknown as Query
  867. })
  868. const run = await startClaudeCodeRun(
  869. request(undefined, parentAbort.signal),
  870. {
  871. cwd: '/workspace',
  872. executable: '/native/claude',
  873. permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
  874. env: {},
  875. disposeGraceMs: 5,
  876. spawn: () => child.handle,
  877. },
  878. )
  879. await expect(run.result).resolves.toEqual({
  880. output: [],
  881. stopReason: 'aborted',
  882. })
  883. await run.dispose()
  884. })
  885. it('rejects pre-abort and every incomplete startup transaction', async () => {
  886. const preAborted = new AbortController()
  887. preAborted.abort()
  888. const unused = fakeRun()
  889. await expect(startClaudeCodeRun(
  890. request(undefined, preAborted.signal),
  891. unused.spec,
  892. )).rejects.toThrow('aborted before SDK startup')
  893. expect(unused.options).toEqual([])
  894. const noChildClose = vi.fn()
  895. queryMock.mockImplementationOnce(
  896. () => queryFrom([], undefined, noChildClose),
  897. )
  898. await expect(startClaudeCodeRun(request(), {
  899. ...unused.spec,
  900. })).rejects.toThrow('did not publish a controllable')
  901. expect(noChildClose).toHaveBeenCalledOnce()
  902. const closeFailure = vi.fn(() => { throw new Error('close boom') })
  903. queryMock.mockImplementationOnce(
  904. () => queryFrom([], undefined, closeFailure),
  905. )
  906. const noChild = startClaudeCodeRun(request(), {
  907. ...unused.spec,
  908. })
  909. await expect(noChild).rejects.toBeInstanceOf(AggregateError)
  910. const startupAbort = new AbortController()
  911. const abortedChild = fakeChild()
  912. const abortedClose = vi.fn()
  913. queryMock.mockImplementationOnce(({ options }) => {
  914. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  915. startupAbort.abort(new Error('startup cancelled'))
  916. return queryFrom([], undefined, abortedClose)
  917. })
  918. const abortedDuringStartup = startClaudeCodeRun(
  919. request(undefined, startupAbort.signal),
  920. {
  921. ...unused.spec,
  922. spawn: () => abortedChild.handle,
  923. },
  924. )
  925. await expect(abortedDuringStartup)
  926. .rejects.toThrow('aborted before SDK startup')
  927. expect(abortedClose).toHaveBeenCalledOnce()
  928. expect(abortedChild.terminate).toHaveBeenCalledOnce()
  929. queryMock.mockImplementationOnce(() => {
  930. throw new Error('query failed before resource creation')
  931. })
  932. await expect(startClaudeCodeRun(request(), {
  933. ...unused.spec,
  934. })).rejects.toThrow('query failed before resource creation')
  935. const spawned = fakeChild()
  936. const spawnSpecs: SubprocessSpawnSpec[] = []
  937. let factoryController: AbortController | undefined
  938. queryMock.mockImplementationOnce(({ options }) => {
  939. factoryController = options.abortController
  940. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  941. throw new Error('query construction failed')
  942. })
  943. const factoryFailure = startClaudeCodeRun(request(), {
  944. ...unused.spec,
  945. spawn: (spawnSpec) => {
  946. spawnSpecs.push(spawnSpec)
  947. return spawned.handle
  948. },
  949. })
  950. await expect(factoryFailure).rejects.toThrow('query construction failed')
  951. expect(spawnSpecs).toHaveLength(1)
  952. expect(factoryController?.signal.aborted).toBe(true)
  953. expect(spawned.terminate).toHaveBeenCalledOnce()
  954. const failedSpawn = fakeChild({
  955. pid: -1,
  956. doneError: new Error('spawn failed'),
  957. })
  958. const failed = fakeRun([], undefined, failedSpawn)
  959. await expect(startClaudeCodeRun(request(), failed.spec))
  960. .rejects.toBeInstanceOf(AggregateError)
  961. expect(failed.close).toHaveBeenCalledOnce()
  962. })
  963. })
  964. describe('query and process disposal', () => {
  965. it('closes the query, terminates the tree, and waits for direct-child outcome', async () => {
  966. const child = fakeChild()
  967. const close = vi.fn()
  968. await disposeClaudeCodeChild({ close }, child.handle)
  969. expect(close).toHaveBeenCalledOnce()
  970. expect(child.terminate).toHaveBeenCalledOnce()
  971. expect(child.waitForExit).toHaveBeenCalledOnce()
  972. expect(child.waitForExit).toHaveBeenCalledWith()
  973. await expect(child.handle.done).resolves.toEqual({
  974. exitCode: 0,
  975. signal: null,
  976. })
  977. })
  978. it('does not finish disposal before the managed tree exits', async () => {
  979. const child = fakeChild({ exitOnTerminate: false })
  980. let disposed = false
  981. const disposal = disposeClaudeCodeChild(
  982. { close: vi.fn() },
  983. child.handle,
  984. ).then(() => {
  985. disposed = true
  986. })
  987. await nextTask()
  988. expect(disposed).toBe(false)
  989. child.settle()
  990. await disposal
  991. expect(disposed).toBe(true)
  992. })
  993. it('reports wait, close, and direct-child failures without skipping cleanup', async () => {
  994. const waitFailure = fakeChild({
  995. waitForExitError: new Error('wait boom'),
  996. })
  997. const closeFailure = vi.fn(() => { throw new Error('close boom') })
  998. await expect(disposeClaudeCodeChild(
  999. { close: closeFailure },
  1000. waitFailure.handle,
  1001. )).rejects.toBeInstanceOf(AggregateError)
  1002. expect(waitFailure.terminate).toHaveBeenCalledOnce()
  1003. const doneFailure = fakeChild({
  1004. pid: -1,
  1005. doneError: new Error('spawn boom'),
  1006. })
  1007. await expect(disposeClaudeCodeChild(
  1008. { close: vi.fn() },
  1009. doneFailure.handle,
  1010. )).rejects.toThrow('spawn boom')
  1011. const both = fakeChild({
  1012. pid: -1,
  1013. doneError: new Error('spawn boom'),
  1014. })
  1015. await expect(disposeClaudeCodeChild(
  1016. { close: () => { throw new Error('close boom') } },
  1017. both.handle,
  1018. )).rejects.toBeInstanceOf(AggregateError)
  1019. })
  1020. })