subagent-claude-code.spec.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107
  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. expect(options.disallowedTools).toEqual(permissionMode === 'plan'
  639. ? ['AskUserQuestion', 'ExitPlanMode']
  640. : ['AskUserQuestion'])
  641. if (permissionMode === 'bypassPermissions') {
  642. expect(options.allowDangerouslySkipPermissions).toBe(true)
  643. expect(options).not.toHaveProperty('canUseTool')
  644. } else {
  645. expect(options).not.toHaveProperty('allowDangerouslySkipPermissions')
  646. expect(options.canUseTool).toBeTypeOf('function')
  647. }
  648. },
  649. )
  650. it('disallows ExitPlanMode before native plan-mode allow rules', () => {
  651. const child = fakeChild()
  652. const options = claudeQueryOptions({
  653. cwd: '/workspace',
  654. executable: '/native/claude',
  655. permissionMode: 'plan',
  656. env: {},
  657. disposeGraceMs: 17,
  658. spawn: () => child.handle,
  659. }, new AbortController(), () => {}, () => {})
  660. expect(options.disallowedTools).toEqual([
  661. 'AskUserQuestion',
  662. 'ExitPlanMode',
  663. ])
  664. })
  665. it('accepts only a non-error success with a non-blank final result', () => {
  666. expect(successfulResult(success('exact final'))).toBe('exact final')
  667. expect(() => successfulResult(success('answer', true)))
  668. .toThrow('marked as an error')
  669. expect(() => successfulResult(success(' \n ')))
  670. .toThrow('contained no answer')
  671. expect(() => successfulResult(failure(
  672. 'error_during_execution',
  673. ['first', 'second'],
  674. ))).toThrow('first; second')
  675. expect(() => successfulResult(failure(
  676. 'error_max_turns',
  677. [],
  678. ))).toThrow('error_max_turns')
  679. })
  680. it('consumes the complete stream and keeps the latest strict success', async () => {
  681. const query = queryFrom([
  682. { type: 'system', subtype: 'init' } as SDKMessage,
  683. success('first'),
  684. success('last'),
  685. ])
  686. await expect(consumeClaudeQuery(query)).resolves.toEqual({
  687. output: [{ type: 'text', text: 'last' }],
  688. stopReason: 'completed',
  689. })
  690. await expect(consumeClaudeQuery(
  691. queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]),
  692. )).rejects.toThrow('ended without a result')
  693. const onPermissionDenied = vi.fn()
  694. await expect(consumeClaudeQuery(queryFrom([
  695. permissionDenied(),
  696. success('after denial'),
  697. ]), onPermissionDenied)).resolves.toEqual({
  698. output: [{ type: 'text', text: 'after denial' }],
  699. stopReason: 'completed',
  700. })
  701. expect(onPermissionDenied).toHaveBeenCalledOnce()
  702. })
  703. })
  704. describe('run publication, cancellation, and settlement', () => {
  705. it('publishes only after Query and managed child exist, then disposes once', async () => {
  706. const fixture = fakeRun([success('exact answer')])
  707. const run = await startClaudeCodeRun(
  708. request([
  709. { type: 'text', text: 'first' },
  710. { type: 'text', text: 'second' },
  711. ]),
  712. fixture.spec,
  713. )
  714. expect(fixture.options).toHaveLength(1)
  715. expect(fixture.spawnSpecs).toHaveLength(1)
  716. await expect(run.result).resolves.toEqual({
  717. output: [{ type: 'text', text: 'exact answer' }],
  718. stopReason: 'completed',
  719. })
  720. const first = run.dispose()
  721. const second = run.dispose()
  722. expect(second).toBe(first)
  723. await first
  724. expect(fixture.close).toHaveBeenCalledOnce()
  725. expect(fixture.child.terminate).toHaveBeenCalledOnce()
  726. })
  727. it('flattens every SDK error result without inventing shared stop reasons', async () => {
  728. const subtypes: ErrorSubtype[] = [
  729. 'error_during_execution',
  730. 'error_max_turns',
  731. 'error_max_budget_usd',
  732. 'error_max_structured_output_retries',
  733. ]
  734. for (const subtype of subtypes) {
  735. const fixture = fakeRun([failure(subtype)])
  736. const onError = vi.fn()
  737. const run = await startClaudeCodeRun(
  738. request(),
  739. { ...fixture.spec, onError },
  740. )
  741. await expect(run.result).resolves.toEqual({
  742. output: [],
  743. stopReason: 'error',
  744. })
  745. expect(onError).toHaveBeenCalledWith(
  746. expect.any(Error),
  747. 'error',
  748. )
  749. await run.dispose()
  750. }
  751. })
  752. it('attaches a safe diagnostic when a permission denial precedes failure', async () => {
  753. const fixture = fakeRun([
  754. permissionDenied(),
  755. failure('error_during_execution'),
  756. ])
  757. const run = await startClaudeCodeRun(request(), fixture.spec)
  758. const result = await run.result
  759. expect(result).toEqual({
  760. output: [],
  761. diagnostic: 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt',
  762. stopReason: 'error',
  763. })
  764. expect(result.diagnostic).not.toContain('SECRET_TOKEN')
  765. expect(result.diagnostic).not.toContain('/private/secret.txt')
  766. await run.dispose()
  767. })
  768. it('omits captured diagnostics on success and isolates concurrent runs', async () => {
  769. const children = [fakeChild(), fakeChild()]
  770. let childIndex = 0
  771. const spec: ClaudeCodeRunSpec = {
  772. cwd: '/workspace',
  773. executable: '/native/claude',
  774. permissionMode: 'dontAsk',
  775. env: {},
  776. disposeGraceMs: 5,
  777. spawn: () => children[childIndex++]!.handle,
  778. }
  779. queryMock.mockImplementation(({ prompt, options }) => {
  780. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  781. return prompt === 'denied then completed'
  782. ? queryFrom([permissionDenied(), success('completed answer')])
  783. : queryFrom([failure('error_during_execution')])
  784. })
  785. const [completed, failed] = await Promise.all([
  786. startClaudeCodeRun(
  787. request([{ type: 'text', text: 'denied then completed' }]),
  788. spec,
  789. ),
  790. startClaudeCodeRun(
  791. request([{ type: 'text', text: 'unrelated failure' }]),
  792. spec,
  793. ),
  794. ])
  795. await expect(completed.result).resolves.toEqual({
  796. output: [{ type: 'text', text: 'completed answer' }],
  797. stopReason: 'completed',
  798. })
  799. await expect(failed.result).resolves.toEqual({
  800. output: [],
  801. stopReason: 'error',
  802. })
  803. await Promise.all([completed.dispose(), failed.dispose()])
  804. })
  805. it('fails closed when iteration rejects after a result', async () => {
  806. const fixture = fakeRun(
  807. [success('partial final')],
  808. new Error('iterator boom'),
  809. )
  810. const run = await startClaudeCodeRun(request(), fixture.spec)
  811. await expect(run.result).resolves.toEqual({
  812. output: [],
  813. stopReason: 'error',
  814. })
  815. await run.dispose()
  816. })
  817. it('maps invalid success and missing result to error', async () => {
  818. for (const messages of [
  819. [success('answer', true)],
  820. [success('')],
  821. [{ type: 'system', subtype: 'init' } as SDKMessage],
  822. ]) {
  823. const fixture = fakeRun(messages)
  824. const run = await startClaudeCodeRun(request(), fixture.spec)
  825. await expect(run.result).resolves.toMatchObject({
  826. stopReason: 'error',
  827. })
  828. await run.dispose()
  829. }
  830. })
  831. it('gives local cancellation precedence and isolates overlapping controllers', async () => {
  832. const firstChild = fakeChild()
  833. const secondChild = fakeChild()
  834. const children = [firstChild, secondChild]
  835. const controllers: AbortController[] = []
  836. let index = 0
  837. const spec: ClaudeCodeRunSpec = {
  838. cwd: '/workspace',
  839. executable: '/native/claude',
  840. permissionMode: 'dontAsk',
  841. env: {},
  842. disposeGraceMs: 5,
  843. spawn: () => children[index++]!.handle,
  844. }
  845. queryMock.mockImplementation(({ prompt, options }) => {
  846. controllers.push(options.abortController!)
  847. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  848. return prompt === 'wait'
  849. ? waitingQuery(options.abortController!.signal)
  850. : queryFrom([success('second answer')])
  851. })
  852. const firstAbort = new AbortController()
  853. const first = await startClaudeCodeRun(
  854. request([{ type: 'text', text: 'wait' }], firstAbort.signal),
  855. spec,
  856. )
  857. const second = await startClaudeCodeRun(
  858. request([{ type: 'text', text: 'finish' }]),
  859. spec,
  860. )
  861. expect(controllers).toHaveLength(2)
  862. expect(controllers[0]).not.toBe(controllers[1])
  863. firstAbort.abort(new Error('parent cancelled'))
  864. await expect(first.result).resolves.toEqual({
  865. output: [],
  866. stopReason: 'aborted',
  867. })
  868. await expect(second.result).resolves.toEqual({
  869. output: [{ type: 'text', text: 'second answer' }],
  870. stopReason: 'completed',
  871. })
  872. expect(controllers[1]!.signal.aborted).toBe(false)
  873. await Promise.all([first.dispose(), second.dispose()])
  874. })
  875. it('keeps local cancellation authoritative when the SDK iterator ends normally', async () => {
  876. const parentAbort = new AbortController()
  877. const child = fakeChild()
  878. async function* stream(): AsyncGenerator<SDKMessage, void> {
  879. yield success('candidate answer')
  880. parentAbort.abort(new Error('parent cancelled at iterator completion'))
  881. }
  882. queryMock.mockImplementation(({ options }) => {
  883. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  884. return Object.assign(stream(), { close: vi.fn() }) as unknown as Query
  885. })
  886. const run = await startClaudeCodeRun(
  887. request(undefined, parentAbort.signal),
  888. {
  889. cwd: '/workspace',
  890. executable: '/native/claude',
  891. permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
  892. env: {},
  893. disposeGraceMs: 5,
  894. spawn: () => child.handle,
  895. },
  896. )
  897. await expect(run.result).resolves.toEqual({
  898. output: [],
  899. stopReason: 'aborted',
  900. })
  901. await run.dispose()
  902. })
  903. it('rejects pre-abort and every incomplete startup transaction', async () => {
  904. const preAborted = new AbortController()
  905. preAborted.abort()
  906. const unused = fakeRun()
  907. await expect(startClaudeCodeRun(
  908. request(undefined, preAborted.signal),
  909. unused.spec,
  910. )).rejects.toThrow('aborted before SDK startup')
  911. expect(unused.options).toEqual([])
  912. const noChildClose = vi.fn()
  913. queryMock.mockImplementationOnce(
  914. () => queryFrom([], undefined, noChildClose),
  915. )
  916. await expect(startClaudeCodeRun(request(), {
  917. ...unused.spec,
  918. })).rejects.toThrow('did not publish a controllable')
  919. expect(noChildClose).toHaveBeenCalledOnce()
  920. const closeFailure = vi.fn(() => { throw new Error('close boom') })
  921. queryMock.mockImplementationOnce(
  922. () => queryFrom([], undefined, closeFailure),
  923. )
  924. const noChild = startClaudeCodeRun(request(), {
  925. ...unused.spec,
  926. })
  927. await expect(noChild).rejects.toBeInstanceOf(AggregateError)
  928. const startupAbort = new AbortController()
  929. const abortedChild = fakeChild()
  930. const abortedClose = vi.fn()
  931. queryMock.mockImplementationOnce(({ options }) => {
  932. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  933. startupAbort.abort(new Error('startup cancelled'))
  934. return queryFrom([], undefined, abortedClose)
  935. })
  936. const abortedDuringStartup = startClaudeCodeRun(
  937. request(undefined, startupAbort.signal),
  938. {
  939. ...unused.spec,
  940. spawn: () => abortedChild.handle,
  941. },
  942. )
  943. await expect(abortedDuringStartup)
  944. .rejects.toThrow('aborted before SDK startup')
  945. expect(abortedClose).toHaveBeenCalledOnce()
  946. expect(abortedChild.terminate).toHaveBeenCalledOnce()
  947. queryMock.mockImplementationOnce(() => {
  948. throw new Error('query failed before resource creation')
  949. })
  950. await expect(startClaudeCodeRun(request(), {
  951. ...unused.spec,
  952. })).rejects.toThrow('query failed before resource creation')
  953. const spawned = fakeChild()
  954. const spawnSpecs: SubprocessSpawnSpec[] = []
  955. let factoryController: AbortController | undefined
  956. queryMock.mockImplementationOnce(({ options }) => {
  957. factoryController = options.abortController
  958. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  959. throw new Error('query construction failed')
  960. })
  961. const factoryFailure = startClaudeCodeRun(request(), {
  962. ...unused.spec,
  963. spawn: (spawnSpec) => {
  964. spawnSpecs.push(spawnSpec)
  965. return spawned.handle
  966. },
  967. })
  968. await expect(factoryFailure).rejects.toThrow('query construction failed')
  969. expect(spawnSpecs).toHaveLength(1)
  970. expect(factoryController?.signal.aborted).toBe(true)
  971. expect(spawned.terminate).toHaveBeenCalledOnce()
  972. const failedSpawn = fakeChild({
  973. pid: -1,
  974. doneError: new Error('spawn failed'),
  975. })
  976. const failed = fakeRun([], undefined, failedSpawn)
  977. await expect(startClaudeCodeRun(request(), failed.spec))
  978. .rejects.toBeInstanceOf(AggregateError)
  979. expect(failed.close).toHaveBeenCalledOnce()
  980. })
  981. })
  982. describe('query and process disposal', () => {
  983. it('closes the query, terminates the tree, and waits for direct-child outcome', async () => {
  984. const child = fakeChild()
  985. const close = vi.fn()
  986. await disposeClaudeCodeChild({ close }, child.handle)
  987. expect(close).toHaveBeenCalledOnce()
  988. expect(child.terminate).toHaveBeenCalledOnce()
  989. expect(child.waitForExit).toHaveBeenCalledOnce()
  990. expect(child.waitForExit).toHaveBeenCalledWith()
  991. await expect(child.handle.done).resolves.toEqual({
  992. exitCode: 0,
  993. signal: null,
  994. })
  995. })
  996. it('does not finish disposal before the managed tree exits', async () => {
  997. const child = fakeChild({ exitOnTerminate: false })
  998. let disposed = false
  999. const disposal = disposeClaudeCodeChild(
  1000. { close: vi.fn() },
  1001. child.handle,
  1002. ).then(() => {
  1003. disposed = true
  1004. })
  1005. await nextTask()
  1006. expect(disposed).toBe(false)
  1007. child.settle()
  1008. await disposal
  1009. expect(disposed).toBe(true)
  1010. })
  1011. it('reports wait, close, and direct-child failures without skipping cleanup', async () => {
  1012. const waitFailure = fakeChild({
  1013. waitForExitError: new Error('wait boom'),
  1014. })
  1015. const closeFailure = vi.fn(() => { throw new Error('close boom') })
  1016. await expect(disposeClaudeCodeChild(
  1017. { close: closeFailure },
  1018. waitFailure.handle,
  1019. )).rejects.toBeInstanceOf(AggregateError)
  1020. expect(waitFailure.terminate).toHaveBeenCalledOnce()
  1021. const doneFailure = fakeChild({
  1022. pid: -1,
  1023. doneError: new Error('spawn boom'),
  1024. })
  1025. await expect(disposeClaudeCodeChild(
  1026. { close: vi.fn() },
  1027. doneFailure.handle,
  1028. )).rejects.toThrow('spawn boom')
  1029. const both = fakeChild({
  1030. pid: -1,
  1031. doneError: new Error('spawn boom'),
  1032. })
  1033. await expect(disposeClaudeCodeChild(
  1034. { close: () => { throw new Error('close boom') } },
  1035. both.handle,
  1036. )).rejects.toBeInstanceOf(AggregateError)
  1037. })
  1038. })