subagent-claude-code.spec.ts 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074
  1. import { readFileSync } from 'node:fs'
  2. import { dirname, resolve } from 'node:path'
  3. import { PassThrough } from 'node:stream'
  4. import { fileURLToPath } from 'node:url'
  5. import type {
  6. Options,
  7. Query,
  8. SDKMessage,
  9. SDKResultMessage,
  10. SpawnOptions,
  11. } from '@anthropic-ai/claude-agent-sdk'
  12. import { Context } from '@deepseek-ai/cordis'
  13. import Loader from '@deepseek-ai/cordis-plugin-loader'
  14. import * as yaml from 'js-yaml'
  15. import {
  16. afterEach,
  17. beforeEach,
  18. describe,
  19. expect,
  20. it,
  21. type Mock,
  22. vi,
  23. } from 'vitest'
  24. import type { Agent } from '@deepseek-ai/dsh-agent'
  25. import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
  26. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  27. import SubagentRuntime from '@deepseek-ai/dsh-subagent'
  28. import type {
  29. SubprocessHandle,
  30. SubprocessOutcome,
  31. SubprocessSpawnSpec,
  32. } from '@deepseek-ai/dsh-subprocess'
  33. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  34. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  35. import * as claudeCode from '../src/index.ts'
  36. import * as invariant from '../src/invariant.ts'
  37. import {
  38. claudeSpawnSpec,
  39. ManagedClaudeCodeProcess,
  40. sdkEnvironmentOverlay,
  41. } from '../src/process.ts'
  42. import {
  43. claudeQueryOptions,
  44. consumeClaudeQuery,
  45. disposeClaudeCodeChild,
  46. startClaudeCodeRun,
  47. successfulResult,
  48. textTask,
  49. type ClaudeCodeRunSpec,
  50. } from '../src/run.ts'
  51. type QueryFactory = (params: {
  52. prompt: string
  53. options: Options
  54. }) => Query
  55. const queryMock = vi.hoisted(() => vi.fn<QueryFactory>())
  56. const CLAUDE_AGENT_SDK_VERSION = '0.3.220'
  57. const CLAUDE_CODE_VERSION = '2.1.220'
  58. const CLAUDE_PLATFORM_PACKAGES = [
  59. '@anthropic-ai/claude-agent-sdk-darwin-arm64',
  60. '@anthropic-ai/claude-agent-sdk-darwin-x64',
  61. '@anthropic-ai/claude-agent-sdk-linux-arm64',
  62. '@anthropic-ai/claude-agent-sdk-linux-arm64-musl',
  63. '@anthropic-ai/claude-agent-sdk-linux-x64',
  64. '@anthropic-ai/claude-agent-sdk-linux-x64-musl',
  65. '@anthropic-ai/claude-agent-sdk-win32-arm64',
  66. '@anthropic-ai/claude-agent-sdk-win32-x64',
  67. ] as const
  68. vi.mock('@anthropic-ai/claude-agent-sdk', async importOriginal => ({
  69. ...await importOriginal<typeof import('@anthropic-ai/claude-agent-sdk')>(),
  70. query: queryMock,
  71. }))
  72. const fakeParent = {
  73. id: 'parent',
  74. session: { header: { cwd: process.cwd() } },
  75. } as unknown as Agent
  76. function request(
  77. prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
  78. signal = new AbortController().signal,
  79. ) {
  80. return { prompt, parent: fakeParent, signal }
  81. }
  82. async function nextTask(): Promise<void> {
  83. await new Promise<void>((resolve) => { setImmediate(resolve) })
  84. }
  85. interface FakeChildOptions {
  86. readonly pid?: number
  87. readonly exitOnTerminate?: boolean
  88. readonly waitForExitError?: Error
  89. readonly doneError?: Error
  90. }
  91. interface FakeChild {
  92. readonly handle: SubprocessHandle
  93. readonly stdin: PassThrough
  94. readonly stdout: PassThrough
  95. readonly settle: (outcome?: SubprocessOutcome) => void
  96. readonly fail: (error: Error) => void
  97. readonly terminate: Mock<SubprocessHandle['terminate']>
  98. readonly waitForExit: Mock<SubprocessHandle['waitForExit']>
  99. }
  100. function fakeChild(options: FakeChildOptions = {}): FakeChild {
  101. const stdin = new PassThrough()
  102. const stdout = new PassThrough()
  103. let exited = false
  104. let resolveDone!: (outcome: SubprocessOutcome) => void
  105. let rejectDone!: (error: Error) => void
  106. const done = new Promise<SubprocessOutcome>((resolve, reject) => {
  107. resolveDone = resolve
  108. rejectDone = reject
  109. })
  110. // Individual tests deliberately exercise rejected and still-pending handles.
  111. void done.catch(() => {})
  112. const settle = (
  113. outcome: SubprocessOutcome = { exitCode: 0, signal: null },
  114. ): void => {
  115. if (exited) return
  116. exited = true
  117. resolveDone(outcome)
  118. }
  119. const fail = (error: Error): void => {
  120. if (exited) return
  121. exited = true
  122. rejectDone(error)
  123. }
  124. if (options.doneError !== undefined) fail(options.doneError)
  125. const terminate = vi.fn<SubprocessHandle['terminate']>(() => {
  126. if (options.exitOnTerminate !== false) settle()
  127. })
  128. const waitForExit = vi.fn<SubprocessHandle['waitForExit']>(async (signal?: AbortSignal): Promise<boolean> => {
  129. if (options.waitForExitError !== undefined) {
  130. throw options.waitForExitError
  131. }
  132. if (exited) return true
  133. if (signal === undefined) {
  134. await done.catch(() => {})
  135. return true
  136. }
  137. return await new Promise<boolean>((resolve) => {
  138. const onAbort = (): void => { resolve(false) }
  139. signal.addEventListener('abort', onAbort, { once: true })
  140. void done.then(
  141. () => {
  142. signal.removeEventListener('abort', onAbort)
  143. resolve(true)
  144. },
  145. () => {
  146. signal.removeEventListener('abort', onAbort)
  147. resolve(true)
  148. },
  149. )
  150. })
  151. })
  152. const handle: SubprocessHandle = {
  153. pid: options.pid ?? 1234,
  154. stdin,
  155. stdout,
  156. stderr: undefined,
  157. collected: {},
  158. done,
  159. terminate,
  160. waitForExit,
  161. }
  162. return {
  163. handle,
  164. stdin,
  165. stdout,
  166. settle,
  167. fail,
  168. terminate,
  169. waitForExit,
  170. }
  171. }
  172. function success(
  173. result = 'answer',
  174. isError = false,
  175. ): SDKResultMessage {
  176. return {
  177. type: 'result',
  178. subtype: 'success',
  179. is_error: isError,
  180. result,
  181. } as SDKResultMessage
  182. }
  183. type ErrorSubtype = Exclude<SDKResultMessage['subtype'], 'success'>
  184. function failure(
  185. subtype: ErrorSubtype,
  186. errors: string[] = ['fixture failure'],
  187. ): SDKResultMessage {
  188. return {
  189. type: 'result',
  190. subtype,
  191. is_error: true,
  192. errors,
  193. } as SDKResultMessage
  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. env: { ANTHROPIC_API_KEY: 'fake-key' },
  251. disposeGraceMs: 5,
  252. spawn: (spawnSpec) => {
  253. spawnSpecs.push(spawnSpec)
  254. return child.handle
  255. },
  256. }
  257. queryMock.mockImplementation((params) => {
  258. options.push(params.options)
  259. params.options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  260. return query
  261. })
  262. return { child, close, spawnSpecs, options, spec }
  263. }
  264. beforeEach(() => {
  265. queryMock.mockImplementation(({ options }) => {
  266. options.spawnClaudeCodeProcess!(sdkSpawnOptions({
  267. cwd: options.cwd!,
  268. env: options.env!,
  269. signal: options.abortController!.signal,
  270. }))
  271. return queryFrom([])
  272. })
  273. })
  274. afterEach(() => {
  275. queryMock.mockReset()
  276. vi.restoreAllMocks()
  277. vi.unstubAllEnvs()
  278. })
  279. describe('task admission and package contracts', () => {
  280. it('ships one independently installable provider-only Bundle patch', () => {
  281. const root = fileURLToPath(new URL('..', import.meta.url))
  282. const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as {
  283. dependencies?: Record<string, string>
  284. files?: string[]
  285. dsh?: { bundle?: { patch?: string } }
  286. }
  287. expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml')
  288. expect(manifest.files).toContain('cordis.patch.yml')
  289. expect(manifest.dependencies).toHaveProperty(
  290. '@anthropic-ai/claude-agent-sdk',
  291. CLAUDE_AGENT_SDK_VERSION,
  292. )
  293. expect(manifest.dependencies).toHaveProperty(
  294. '@modelcontextprotocol/sdk',
  295. '^1.29.0',
  296. )
  297. expect(manifest.dependencies).toHaveProperty('zod', '^4.4.3')
  298. expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex')
  299. const sdkRoot = dirname(fileURLToPath(
  300. import.meta.resolve('@anthropic-ai/claude-agent-sdk'),
  301. ))
  302. const sdkManifest = JSON.parse(readFileSync(
  303. resolve(sdkRoot, 'package.json'),
  304. 'utf8',
  305. )) as {
  306. version: string
  307. claudeCodeVersion: string
  308. optionalDependencies: Record<string, string>
  309. }
  310. expect(sdkManifest.version).toBe(CLAUDE_AGENT_SDK_VERSION)
  311. expect(sdkManifest.claudeCodeVersion).toBe(CLAUDE_CODE_VERSION)
  312. expect(sdkManifest.optionalDependencies).toEqual(Object.fromEntries(
  313. CLAUDE_PLATFORM_PACKAGES.map(packageName => [
  314. packageName,
  315. CLAUDE_AGENT_SDK_VERSION,
  316. ]),
  317. ))
  318. const lockfile = readFileSync(resolve(root, '../../../pnpm-lock.yaml'), 'utf8')
  319. for (const packageName of CLAUDE_PLATFORM_PACKAGES) {
  320. expect(lockfile).toContain(
  321. ` '${packageName}@${CLAUDE_AGENT_SDK_VERSION}':`,
  322. )
  323. expect(lockfile).toContain(
  324. ` '${packageName}': ${CLAUDE_AGENT_SDK_VERSION}`,
  325. )
  326. }
  327. const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'))
  328. const rows = Array.isArray(parsed)
  329. ? (parsed as Array<{ insert?: Array<{ id?: string; name?: string }> }>).flatMap(entry => entry.insert ?? [])
  330. : []
  331. expect(rows).toEqual([{
  332. id: 'subagent-claude-code',
  333. name: '@deepseek-ai/dsh-subagent-claude-code',
  334. }])
  335. expect(JSON.stringify(rows)).not.toContain('tool-subagent')
  336. })
  337. it('preserves text sequences and rejects empty, blank, and non-text tasks', () => {
  338. expect(textTask([
  339. { type: 'text', text: 'one' },
  340. { type: 'text', text: 'two' },
  341. ])).toBe('onetwo')
  342. expect(() => textTask([])).toThrow('only text blocks')
  343. expect(() => textTask([{ type: 'reasoning', text: 'hidden' }]))
  344. .toThrow('only text blocks')
  345. expect(() => textTask([{ type: 'text', text: ' \n ' }]))
  346. .toThrow('must not be empty')
  347. })
  348. it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => {
  349. const ctx = new Context()
  350. await ctx.plugin(SubagentRuntime)
  351. await ctx.plugin(LocalSubprocessRuntime)
  352. const fiber = await ctx.plugin(claudeCode, {})
  353. expect(ctx.subagents.getProvider('claude-code')).toMatchObject({
  354. name: 'claude-code',
  355. capabilities: {
  356. outputSchema: false,
  357. depthLimit: false,
  358. toolFilter: false,
  359. persona: false,
  360. },
  361. inheritsParentContext: false,
  362. })
  363. expect(ctx.subagents.list()).toEqual(['claude-code'])
  364. await fiber.dispose()
  365. expect(ctx.subagents.list()).toEqual([])
  366. for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
  367. await expect(ctx.plugin(claudeCode, { disposeGraceMs }))
  368. .rejects.toThrow('disposeGraceMs must be a positive finite number')
  369. }
  370. await expect(ctx.plugin(claudeCode, {
  371. disposeGraceMs: MAX_TIMER_DELAY_MS + 1,
  372. })).rejects.toThrow(
  373. `disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
  374. )
  375. await ctx.fiber.dispose()
  376. })
  377. it('starts through the registered provider with its resolved config and diagnostics', async () => {
  378. const ctx = new Context()
  379. await ctx.plugin(SubagentRuntime)
  380. await ctx.plugin(LocalSubprocessRuntime)
  381. const child = fakeChild()
  382. const spawn = vi.spyOn(ctx.subprocess, 'spawn')
  383. .mockImplementation(() => child.handle)
  384. const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable')
  385. .mockResolvedValue('/host/bin/claude')
  386. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  387. await ctx.plugin(claudeCode, {
  388. env: {
  389. ANTHROPIC_API_KEY: 'provider-fake-key',
  390. CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config',
  391. HOME: '/private/tmp/dsh-claude-code-unit-home',
  392. },
  393. disposeGraceMs: 29,
  394. })
  395. await expect(ctx.subagents.start('claude-code', {
  396. ...request(),
  397. parent: {
  398. id: 'parent-without-cwd',
  399. session: { header: {} },
  400. } as unknown as Agent,
  401. })).rejects.toThrow(
  402. 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one',
  403. )
  404. expect(queryMock).not.toHaveBeenCalled()
  405. vi.stubEnv('PATH', '/host/bin')
  406. queryMock.mockImplementationOnce(() => {
  407. throw new Error(
  408. 'Native CLI binary for fixture-platform not found. Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set options.pathToClaudeCodeExecutable.',
  409. )
  410. })
  411. await expect(ctx.subagents.start('claude-code', request()))
  412. .rejects.toThrow('Native CLI binary for fixture-platform not found')
  413. expect(resolveExecutable).not.toHaveBeenCalled()
  414. const run = await ctx.subagents.start('claude-code', request())
  415. child.settle({ exitCode: 9, signal: null })
  416. child.stdout.end()
  417. await expect(run.result).resolves.toEqual({
  418. output: [],
  419. stopReason: 'error',
  420. })
  421. expect(warn).toHaveBeenCalledWith(expect.stringContaining(
  422. 'subagent-claude-code: child run failed (error):',
  423. ))
  424. expect(resolveExecutable).not.toHaveBeenCalled()
  425. expect(queryMock.mock.calls[1]?.[0].options)
  426. .not.toHaveProperty('pathToClaudeCodeExecutable')
  427. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  428. cwd: process.cwd(),
  429. graceMs: 29,
  430. }))
  431. expect(spawn.mock.calls[0]?.[0].env).toMatchObject({
  432. ANTHROPIC_API_KEY: 'provider-fake-key',
  433. })
  434. await run.dispose()
  435. await ctx.fiber.dispose()
  436. })
  437. it('keeps the Loader namespace shape and package-owned empty invariant', async () => {
  438. expect('default' in claudeCode).toBe(false)
  439. expect(claudeCode.name).toBe('subagent-claude-code')
  440. expect(claudeCode.inject).toEqual(['subagents', 'subprocess'])
  441. const loader = Object.create(Loader.prototype) as Loader
  442. expect(loader.unwrapExports(claudeCode)).toBe(claudeCode)
  443. const dispose = vi.fn()
  444. const register = vi.fn((
  445. _packageName: string,
  446. _installer: InvariantInstaller,
  447. ) => dispose)
  448. const ctx = { invariants: { register } } as unknown as Context
  449. await expect(invariant.apply(ctx)).resolves.toBe(dispose)
  450. expect(register).toHaveBeenCalledWith(
  451. '@deepseek-ai/dsh-subagent-claude-code',
  452. expect.any(Function),
  453. )
  454. const install = register.mock.calls[0]![1]
  455. await install(new Context(), (message) => { throw new Error(message) })
  456. expect(invariant.name).toBe('subagent-claude-code-invariant')
  457. expect(invariant.inject).toEqual(['invariants'])
  458. })
  459. })
  460. describe('official spawn projection', () => {
  461. it('forwards command, arguments, cwd, environment, and signal exactly', () => {
  462. vi.stubEnv('SDK_REMOVED_AMBIENT', 'ambient-value')
  463. const signal = new AbortController().signal
  464. const options = sdkSpawnOptions({
  465. command: '/official/claude',
  466. args: ['--one', 'two'],
  467. cwd: '/parent/workspace',
  468. env: { A: 'one', B: undefined, C: 'three' },
  469. signal,
  470. })
  471. expect(sdkEnvironmentOverlay(options.env)).toEqual(expect.objectContaining({
  472. A: 'one',
  473. B: undefined,
  474. C: 'three',
  475. SDK_REMOVED_AMBIENT: undefined,
  476. }))
  477. const spawnSpec = claudeSpawnSpec(options, 321)
  478. expect(spawnSpec).toMatchObject({
  479. argv: ['/official/claude', '--one', 'two'],
  480. cwd: '/parent/workspace',
  481. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
  482. graceMs: 321,
  483. signal,
  484. })
  485. expect(spawnSpec.env).toEqual(expect.objectContaining({
  486. A: 'one',
  487. B: undefined,
  488. C: 'three',
  489. SDK_REMOVED_AMBIENT: undefined,
  490. }))
  491. const missingCwd = sdkSpawnOptions()
  492. delete missingCwd.cwd
  493. expect(() => claudeSpawnSpec(
  494. missingCwd,
  495. 321,
  496. )).toThrow('SDK spawn request omitted its workspace')
  497. expect(() => claudeSpawnSpec(
  498. sdkSpawnOptions({ cwd: '' }),
  499. 321,
  500. )).toThrow('SDK spawn request omitted its workspace')
  501. })
  502. it('forwards the SDK-selected Windows native executable without a batch shim', () => {
  503. const command = String.raw`C:\Program Files\Claude\claude.exe`
  504. const spec = claudeSpawnSpec(sdkSpawnOptions({
  505. command,
  506. args: ['--output-format', 'stream-json'],
  507. }), 7)
  508. expect(spec.argv).toEqual([
  509. command, '--output-format', 'stream-json',
  510. ])
  511. expect(spec.env).not.toHaveProperty('DSH_CLAUDE_CODE_EXECUTABLE')
  512. })
  513. it('projects streams, exit facts, listeners, and idempotent tree termination', async () => {
  514. const child = fakeChild({ exitOnTerminate: false })
  515. const process = new ManagedClaudeCodeProcess(child.handle)
  516. expect(process.stdin).toBe(child.stdin)
  517. expect(process.stdout).toBe(child.stdout)
  518. expect(process.killed).toBe(false)
  519. expect(process.exitCode).toBeNull()
  520. expect(process.signalCode).toBeNull()
  521. const exit = vi.fn()
  522. const once = vi.fn()
  523. const removed = vi.fn()
  524. process.on('exit', exit)
  525. process.once('exit', once)
  526. process.on('exit', removed)
  527. process.off('exit', removed)
  528. expect(process.kill('SIGTERM')).toBe(true)
  529. expect(process.killed).toBe(true)
  530. expect(process.kill('SIGKILL')).toBe(false)
  531. expect(child.terminate).toHaveBeenCalledOnce()
  532. child.settle({ exitCode: null, signal: 'SIGTERM' })
  533. await nextTask()
  534. expect(exit).toHaveBeenCalledWith(null, 'SIGTERM')
  535. expect(once).toHaveBeenCalledOnce()
  536. expect(removed).not.toHaveBeenCalled()
  537. expect(process.signalCode).toBe('SIGTERM')
  538. expect(process.kill('SIGTERM')).toBe(false)
  539. })
  540. it('emits spawn errors', async () => {
  541. const child = fakeChild()
  542. const process = new ManagedClaudeCodeProcess(child.handle)
  543. const errorListener = vi.fn()
  544. const removed = vi.fn()
  545. process.once('error', errorListener)
  546. process.on('error', removed)
  547. process.off('error', removed)
  548. child.fail(new Error('spawn boom'))
  549. await nextTask()
  550. expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({
  551. message: 'spawn boom',
  552. }))
  553. expect(removed).not.toHaveBeenCalled()
  554. })
  555. it('exposes a settled direct-child exit code', async () => {
  556. const child = fakeChild()
  557. const process = new ManagedClaudeCodeProcess(child.handle)
  558. child.settle({ exitCode: 7, signal: null })
  559. await nextTask()
  560. expect(process.exitCode).toBe(7)
  561. expect(process.signalCode).toBeNull()
  562. expect(process.kill('SIGTERM')).toBe(false)
  563. })
  564. })
  565. describe('query options and result mapping', () => {
  566. it('builds the fixed unattended options over the scrubbed environment', () => {
  567. vi.stubEnv('HOST_VISIBLE', 'visible')
  568. vi.stubEnv('HOST_SECRET_TOKEN', 'must-not-leak')
  569. vi.stubEnv('DSH_INTERNAL', 'must-not-leak')
  570. const child = fakeChild()
  571. const spawn = vi.fn(() => child.handle)
  572. const captured: SubprocessHandle[] = []
  573. const spec: ClaudeCodeRunSpec = {
  574. cwd: '/workspace',
  575. env: {
  576. HOST_VISIBLE: 'overridden',
  577. ANTHROPIC_API_KEY: 'explicit-fake-key',
  578. },
  579. disposeGraceMs: 17,
  580. spawn,
  581. }
  582. const controller = new AbortController()
  583. const options = claudeQueryOptions(spec, controller, (value) => {
  584. captured.push(value)
  585. })
  586. expect(options).toMatchObject({
  587. abortController: controller,
  588. cwd: '/workspace',
  589. persistSession: false,
  590. disallowedTools: ['AskUserQuestion'],
  591. })
  592. expect(options).not.toHaveProperty('pathToClaudeCodeExecutable')
  593. expect(options.env).toMatchObject({
  594. HOST_VISIBLE: 'overridden',
  595. ANTHROPIC_API_KEY: 'explicit-fake-key',
  596. })
  597. expect(options.env).not.toHaveProperty('HOST_SECRET_TOKEN')
  598. expect(options.env).not.toHaveProperty('DSH_INTERNAL')
  599. for (const omitted of [
  600. 'settingSources',
  601. 'canUseTool',
  602. 'onElicitation',
  603. 'onUserDialog',
  604. 'supportedDialogKinds',
  605. ]) {
  606. expect(options).not.toHaveProperty(omitted)
  607. }
  608. const spawned = options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  609. expect(spawned).toBeInstanceOf(ManagedClaudeCodeProcess)
  610. expect(captured).toEqual([child.handle])
  611. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  612. argv: ['/sdk/claude', '--output-format', 'stream-json'],
  613. cwd: '/workspace',
  614. graceMs: 17,
  615. }))
  616. })
  617. it('accepts only a non-error success with a non-blank final result', () => {
  618. expect(successfulResult(success('exact final'))).toBe('exact final')
  619. expect(() => successfulResult(success('answer', true)))
  620. .toThrow('marked as an error')
  621. expect(() => successfulResult(success(' \n ')))
  622. .toThrow('contained no answer')
  623. expect(() => successfulResult(failure(
  624. 'error_during_execution',
  625. ['first', 'second'],
  626. ))).toThrow('first; second')
  627. expect(() => successfulResult(failure(
  628. 'error_max_turns',
  629. [],
  630. ))).toThrow('error_max_turns')
  631. })
  632. it('consumes the complete stream and keeps the latest strict success', async () => {
  633. const query = queryFrom([
  634. { type: 'system', subtype: 'init' } as SDKMessage,
  635. success('first'),
  636. success('last'),
  637. ])
  638. await expect(consumeClaudeQuery(query)).resolves.toEqual({
  639. output: [{ type: 'text', text: 'last' }],
  640. stopReason: 'completed',
  641. })
  642. await expect(consumeClaudeQuery(
  643. queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]),
  644. )).rejects.toThrow('ended without a result')
  645. })
  646. })
  647. describe('run publication, cancellation, and settlement', () => {
  648. it('publishes only after Query and managed child exist, then disposes once', async () => {
  649. const fixture = fakeRun([success('exact answer')])
  650. const run = await startClaudeCodeRun(
  651. request([
  652. { type: 'text', text: 'first' },
  653. { type: 'text', text: 'second' },
  654. ]),
  655. fixture.spec,
  656. )
  657. expect(fixture.options).toHaveLength(1)
  658. expect(fixture.spawnSpecs).toHaveLength(1)
  659. await expect(run.result).resolves.toEqual({
  660. output: [{ type: 'text', text: 'exact answer' }],
  661. stopReason: 'completed',
  662. })
  663. const first = run.dispose()
  664. const second = run.dispose()
  665. expect(second).toBe(first)
  666. await first
  667. expect(fixture.close).toHaveBeenCalledOnce()
  668. expect(fixture.child.terminate).toHaveBeenCalledOnce()
  669. })
  670. it('flattens every SDK error result without inventing shared stop reasons', async () => {
  671. const subtypes: ErrorSubtype[] = [
  672. 'error_during_execution',
  673. 'error_max_turns',
  674. 'error_max_budget_usd',
  675. 'error_max_structured_output_retries',
  676. ]
  677. for (const subtype of subtypes) {
  678. const fixture = fakeRun([failure(subtype)])
  679. const onError = vi.fn()
  680. const run = await startClaudeCodeRun(
  681. request(),
  682. { ...fixture.spec, onError },
  683. )
  684. await expect(run.result).resolves.toEqual({
  685. output: [],
  686. stopReason: 'error',
  687. })
  688. expect(onError).toHaveBeenCalledWith(
  689. expect.any(Error),
  690. 'error',
  691. )
  692. await run.dispose()
  693. }
  694. })
  695. it('fails closed when iteration rejects after a result', async () => {
  696. const fixture = fakeRun(
  697. [success('partial final')],
  698. new Error('iterator boom'),
  699. )
  700. const run = await startClaudeCodeRun(request(), fixture.spec)
  701. await expect(run.result).resolves.toEqual({
  702. output: [],
  703. stopReason: 'error',
  704. })
  705. await run.dispose()
  706. })
  707. it('maps invalid success and missing result to error', async () => {
  708. for (const messages of [
  709. [success('answer', true)],
  710. [success('')],
  711. [{ type: 'system', subtype: 'init' } as SDKMessage],
  712. ]) {
  713. const fixture = fakeRun(messages)
  714. const run = await startClaudeCodeRun(request(), fixture.spec)
  715. await expect(run.result).resolves.toMatchObject({
  716. stopReason: 'error',
  717. })
  718. await run.dispose()
  719. }
  720. })
  721. it('gives local cancellation precedence and isolates overlapping controllers', async () => {
  722. const firstChild = fakeChild()
  723. const secondChild = fakeChild()
  724. const children = [firstChild, secondChild]
  725. const controllers: AbortController[] = []
  726. let index = 0
  727. const spec: ClaudeCodeRunSpec = {
  728. cwd: '/workspace',
  729. env: {},
  730. disposeGraceMs: 5,
  731. spawn: () => children[index++]!.handle,
  732. }
  733. queryMock.mockImplementation(({ prompt, options }) => {
  734. controllers.push(options.abortController!)
  735. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  736. return prompt === 'wait'
  737. ? waitingQuery(options.abortController!.signal)
  738. : queryFrom([success('second answer')])
  739. })
  740. const firstAbort = new AbortController()
  741. const first = await startClaudeCodeRun(
  742. request([{ type: 'text', text: 'wait' }], firstAbort.signal),
  743. spec,
  744. )
  745. const second = await startClaudeCodeRun(
  746. request([{ type: 'text', text: 'finish' }]),
  747. spec,
  748. )
  749. expect(controllers).toHaveLength(2)
  750. expect(controllers[0]).not.toBe(controllers[1])
  751. firstAbort.abort(new Error('parent cancelled'))
  752. await expect(first.result).resolves.toEqual({
  753. output: [],
  754. stopReason: 'aborted',
  755. })
  756. await expect(second.result).resolves.toEqual({
  757. output: [{ type: 'text', text: 'second answer' }],
  758. stopReason: 'completed',
  759. })
  760. expect(controllers[1]!.signal.aborted).toBe(false)
  761. await Promise.all([first.dispose(), second.dispose()])
  762. })
  763. it('keeps local cancellation authoritative when the SDK iterator ends normally', async () => {
  764. const parentAbort = new AbortController()
  765. const child = fakeChild()
  766. async function* stream(): AsyncGenerator<SDKMessage, void> {
  767. yield success('candidate answer')
  768. parentAbort.abort(new Error('parent cancelled at iterator completion'))
  769. }
  770. queryMock.mockImplementation(({ options }) => {
  771. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  772. return Object.assign(stream(), { close: vi.fn() }) as unknown as Query
  773. })
  774. const run = await startClaudeCodeRun(
  775. request(undefined, parentAbort.signal),
  776. {
  777. cwd: '/workspace',
  778. env: {},
  779. disposeGraceMs: 5,
  780. spawn: () => child.handle,
  781. },
  782. )
  783. await expect(run.result).resolves.toEqual({
  784. output: [],
  785. stopReason: 'aborted',
  786. })
  787. await run.dispose()
  788. })
  789. it('rejects pre-abort and every incomplete startup transaction', async () => {
  790. const preAborted = new AbortController()
  791. preAborted.abort()
  792. const unused = fakeRun()
  793. await expect(startClaudeCodeRun(
  794. request(undefined, preAborted.signal),
  795. unused.spec,
  796. )).rejects.toThrow('aborted before SDK startup')
  797. expect(unused.options).toEqual([])
  798. const noChildClose = vi.fn()
  799. queryMock.mockImplementationOnce(
  800. () => queryFrom([], undefined, noChildClose),
  801. )
  802. await expect(startClaudeCodeRun(request(), {
  803. ...unused.spec,
  804. })).rejects.toThrow('did not publish a controllable')
  805. expect(noChildClose).toHaveBeenCalledOnce()
  806. const closeFailure = vi.fn(() => { throw new Error('close boom') })
  807. queryMock.mockImplementationOnce(
  808. () => queryFrom([], undefined, closeFailure),
  809. )
  810. const noChild = startClaudeCodeRun(request(), {
  811. ...unused.spec,
  812. })
  813. await expect(noChild).rejects.toBeInstanceOf(AggregateError)
  814. const startupAbort = new AbortController()
  815. const abortedChild = fakeChild()
  816. const abortedClose = vi.fn()
  817. queryMock.mockImplementationOnce(({ options }) => {
  818. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  819. startupAbort.abort(new Error('startup cancelled'))
  820. return queryFrom([], undefined, abortedClose)
  821. })
  822. const abortedDuringStartup = startClaudeCodeRun(
  823. request(undefined, startupAbort.signal),
  824. {
  825. ...unused.spec,
  826. spawn: () => abortedChild.handle,
  827. },
  828. )
  829. await expect(abortedDuringStartup)
  830. .rejects.toThrow('aborted before SDK startup')
  831. expect(abortedClose).toHaveBeenCalledOnce()
  832. expect(abortedChild.terminate).toHaveBeenCalledOnce()
  833. queryMock.mockImplementationOnce(() => {
  834. throw new Error('query failed before resource creation')
  835. })
  836. await expect(startClaudeCodeRun(request(), {
  837. ...unused.spec,
  838. })).rejects.toThrow('query failed before resource creation')
  839. const spawned = fakeChild()
  840. const spawnSpecs: SubprocessSpawnSpec[] = []
  841. let factoryController: AbortController | undefined
  842. queryMock.mockImplementationOnce(({ options }) => {
  843. factoryController = options.abortController
  844. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  845. throw new Error('query construction failed')
  846. })
  847. const factoryFailure = startClaudeCodeRun(request(), {
  848. ...unused.spec,
  849. spawn: (spawnSpec) => {
  850. spawnSpecs.push(spawnSpec)
  851. return spawned.handle
  852. },
  853. })
  854. await expect(factoryFailure).rejects.toThrow('query construction failed')
  855. expect(spawnSpecs).toHaveLength(1)
  856. expect(factoryController?.signal.aborted).toBe(true)
  857. expect(spawned.terminate).toHaveBeenCalledOnce()
  858. const spawnError = Object.assign(
  859. new Error('spawn /sdk/claude EACCES'),
  860. { code: 'EACCES', path: '/sdk/claude' },
  861. )
  862. const failedSpawn = fakeChild({
  863. pid: -1,
  864. doneError: spawnError,
  865. })
  866. const failed = fakeRun([], undefined, failedSpawn)
  867. await expect(startClaudeCodeRun(request(), failed.spec))
  868. .rejects.toBe(spawnError)
  869. expect(failed.close).toHaveBeenCalledOnce()
  870. expect(failedSpawn.terminate).not.toHaveBeenCalled()
  871. expect(failedSpawn.waitForExit).not.toHaveBeenCalled()
  872. const failedSpawnAbort = new AbortController()
  873. const cancelledFailedSpawn = fakeChild({
  874. pid: -1,
  875. doneError: spawnError,
  876. })
  877. const cancelledFailedClose = vi.fn()
  878. queryMock.mockImplementationOnce(({ options }) => {
  879. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  880. failedSpawnAbort.abort(new Error('startup cancelled'))
  881. return queryFrom([], undefined, cancelledFailedClose)
  882. })
  883. await expect(startClaudeCodeRun(
  884. request(undefined, failedSpawnAbort.signal),
  885. { ...unused.spec, spawn: () => cancelledFailedSpawn.handle },
  886. )).rejects.toThrow('aborted before SDK startup')
  887. expect(cancelledFailedClose).toHaveBeenCalledOnce()
  888. const cancelledFailedSpawnCloseError = new Error('cancelled query close failed')
  889. const cancelledFailedSpawnClose = vi.fn(() => {
  890. throw cancelledFailedSpawnCloseError
  891. })
  892. const cancelledFailedSpawnWithCloseFailure = fakeChild({
  893. pid: -1,
  894. doneError: spawnError,
  895. })
  896. const failedSpawnAbortWithCloseFailure = new AbortController()
  897. queryMock.mockImplementationOnce(({ options }) => {
  898. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  899. failedSpawnAbortWithCloseFailure.abort(new Error('startup cancelled'))
  900. return queryFrom([], undefined, cancelledFailedSpawnClose)
  901. })
  902. const cancelledWithCloseFailure = startClaudeCodeRun(
  903. request(undefined, failedSpawnAbortWithCloseFailure.signal),
  904. { ...unused.spec, spawn: () => cancelledFailedSpawnWithCloseFailure.handle },
  905. )
  906. await expect(cancelledWithCloseFailure).rejects.toMatchObject({
  907. message: 'subagent-claude-code: request was aborted before SDK startup; Claude Code process startup also failed: spawn /sdk/claude EACCES; query cleanup also failed',
  908. errors: [
  909. expect.objectContaining({ message: 'subagent-claude-code: request was aborted before SDK startup' }),
  910. spawnError,
  911. cancelledFailedSpawnCloseError,
  912. ],
  913. })
  914. expect(cancelledFailedSpawnClose).toHaveBeenCalledOnce()
  915. const failedSpawnCloseError = new Error('query close failed')
  916. const failedSpawnClose = vi.fn(() => { throw failedSpawnCloseError })
  917. const failedSpawnWithCloseFailure = fakeChild({
  918. pid: -1,
  919. doneError: spawnError,
  920. })
  921. queryMock.mockImplementationOnce(({ options }) => {
  922. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  923. return queryFrom([], undefined, failedSpawnClose)
  924. })
  925. const failedWithCloseFailure = startClaudeCodeRun(request(), {
  926. ...unused.spec,
  927. spawn: () => failedSpawnWithCloseFailure.handle,
  928. })
  929. await expect(failedWithCloseFailure)
  930. .rejects.toThrow('spawn /sdk/claude EACCES')
  931. await expect(failedWithCloseFailure).rejects.toMatchObject({
  932. errors: [spawnError, failedSpawnCloseError],
  933. })
  934. const cleanupError = new Error('live child cleanup failed')
  935. const constructionError = new Error(
  936. 'query construction failed with a live child',
  937. )
  938. const liveChildCleanupFailure = fakeChild({ doneError: cleanupError })
  939. queryMock.mockImplementationOnce(({ options }) => {
  940. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  941. throw constructionError
  942. })
  943. await expect(startClaudeCodeRun(request(), {
  944. ...unused.spec,
  945. spawn: () => liveChildCleanupFailure.handle,
  946. })).rejects.toMatchObject({
  947. errors: [constructionError, cleanupError],
  948. })
  949. })
  950. })
  951. describe('query and process disposal', () => {
  952. it('closes the query, terminates the tree, and waits for direct-child outcome', async () => {
  953. const child = fakeChild()
  954. const close = vi.fn()
  955. await disposeClaudeCodeChild({ close }, child.handle)
  956. expect(close).toHaveBeenCalledOnce()
  957. expect(child.terminate).toHaveBeenCalledOnce()
  958. expect(child.waitForExit).toHaveBeenCalledOnce()
  959. expect(child.waitForExit).toHaveBeenCalledWith()
  960. await expect(child.handle.done).resolves.toEqual({
  961. exitCode: 0,
  962. signal: null,
  963. })
  964. })
  965. it('does not finish disposal before the managed tree exits', async () => {
  966. const child = fakeChild({ exitOnTerminate: false })
  967. let disposed = false
  968. const disposal = disposeClaudeCodeChild(
  969. { close: vi.fn() },
  970. child.handle,
  971. ).then(() => {
  972. disposed = true
  973. })
  974. await nextTask()
  975. expect(disposed).toBe(false)
  976. child.settle()
  977. await disposal
  978. expect(disposed).toBe(true)
  979. })
  980. it('reports wait, close, and direct-child failures without skipping cleanup', async () => {
  981. const waitFailure = fakeChild({
  982. waitForExitError: new Error('wait boom'),
  983. })
  984. const closeFailure = vi.fn(() => { throw new Error('close boom') })
  985. await expect(disposeClaudeCodeChild(
  986. { close: closeFailure },
  987. waitFailure.handle,
  988. )).rejects.toBeInstanceOf(AggregateError)
  989. expect(waitFailure.terminate).toHaveBeenCalledOnce()
  990. const doneFailure = fakeChild({
  991. pid: -1,
  992. doneError: new Error('spawn boom'),
  993. })
  994. await expect(disposeClaudeCodeChild(
  995. { close: vi.fn() },
  996. doneFailure.handle,
  997. )).rejects.toThrow('spawn boom')
  998. const both = fakeChild({
  999. pid: -1,
  1000. doneError: new Error('spawn boom'),
  1001. })
  1002. await expect(disposeClaudeCodeChild(
  1003. { close: () => { throw new Error('close boom') } },
  1004. both.handle,
  1005. )).rejects.toBeInstanceOf(AggregateError)
  1006. })
  1007. })