subagent-claude-code.spec.ts 61 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720
  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. SDKPermissionDeniedMessage,
  10. SDKResultMessage,
  11. SpawnOptions,
  12. } from '@anthropic-ai/claude-agent-sdk'
  13. import { Context } from '@deepseek-ai/cordis'
  14. import Loader from '@deepseek-ai/cordis-plugin-loader'
  15. import * as yaml from 'js-yaml'
  16. import {
  17. afterEach,
  18. beforeEach,
  19. describe,
  20. expect,
  21. it,
  22. type Mock,
  23. vi,
  24. } from 'vitest'
  25. import type { Agent } from '@deepseek-ai/dsh-agent'
  26. import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
  27. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  28. import SubagentRuntime from '@deepseek-ai/dsh-subagent'
  29. import type {
  30. SubprocessHandle,
  31. SubprocessOutcome,
  32. SubprocessSpawnSpec,
  33. } from '@deepseek-ai/dsh-subprocess'
  34. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  35. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  36. import * as claudeCode from '../src/index.ts'
  37. import * as invariant from '../src/invariant.ts'
  38. import {
  39. claudeSpawnSpec,
  40. ManagedClaudeCodeProcess,
  41. sdkEnvironmentOverlay,
  42. } from '../src/process.ts'
  43. import {
  44. CLAUDE_CODE_PERMISSION_MODES,
  45. DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
  46. claudeQueryOptions,
  47. consumeClaudeQuery,
  48. disposeClaudeCodeChild,
  49. startClaudeCodeRun,
  50. successfulResult,
  51. textTask,
  52. type ClaudeCodeRunSpec,
  53. } from '../src/run.ts'
  54. type QueryFactory = (params: {
  55. prompt: string
  56. options: Options
  57. }) => Query
  58. const queryMock = vi.hoisted(() => vi.fn<QueryFactory>())
  59. const CLAUDE_AGENT_SDK_VERSION = '0.3.241'
  60. const CLAUDE_CODE_VERSION = '2.1.241'
  61. const CLAUDE_PLATFORM_PACKAGES = [
  62. '@anthropic-ai/claude-agent-sdk-darwin-arm64',
  63. '@anthropic-ai/claude-agent-sdk-darwin-x64',
  64. '@anthropic-ai/claude-agent-sdk-linux-arm64',
  65. '@anthropic-ai/claude-agent-sdk-linux-arm64-musl',
  66. '@anthropic-ai/claude-agent-sdk-linux-x64',
  67. '@anthropic-ai/claude-agent-sdk-linux-x64-musl',
  68. '@anthropic-ai/claude-agent-sdk-win32-arm64',
  69. '@anthropic-ai/claude-agent-sdk-win32-x64',
  70. ] as const
  71. vi.mock('@anthropic-ai/claude-agent-sdk', async importOriginal => ({
  72. ...await importOriginal<typeof import('@anthropic-ai/claude-agent-sdk')>(),
  73. query: queryMock,
  74. }))
  75. const fakeParent = {
  76. id: 'parent',
  77. session: { header: { cwd: process.cwd() } },
  78. } as unknown as Agent
  79. function request(
  80. prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
  81. signal = new AbortController().signal,
  82. ) {
  83. return { prompt, parent: fakeParent, signal }
  84. }
  85. async function nextTask(): Promise<void> {
  86. await new Promise<void>((resolve) => { setImmediate(resolve) })
  87. }
  88. function errorCause(value: unknown): Error | undefined {
  89. return value instanceof Error && value.cause instanceof Error
  90. ? value.cause
  91. : undefined
  92. }
  93. interface FakeChildOptions {
  94. readonly pid?: number | undefined
  95. readonly exitOnTerminate?: boolean
  96. readonly waitForExitError?: Error
  97. readonly doneError?: Error
  98. }
  99. interface FakeChild {
  100. readonly handle: SubprocessHandle
  101. readonly stdin: PassThrough
  102. readonly stdout: PassThrough
  103. readonly settle: (outcome?: SubprocessOutcome) => void
  104. readonly fail: (error: Error) => void
  105. readonly terminate: Mock<SubprocessHandle['terminate']>
  106. readonly waitForExit: Mock<SubprocessHandle['waitForExit']>
  107. }
  108. function fakeChild(options: FakeChildOptions = {}): FakeChild {
  109. const stdin = new PassThrough()
  110. const stdout = new PassThrough()
  111. let exited = false
  112. let resolveDone!: (outcome: SubprocessOutcome) => void
  113. let rejectDone!: (error: Error) => void
  114. const done = new Promise<SubprocessOutcome>((resolve, reject) => {
  115. resolveDone = resolve
  116. rejectDone = reject
  117. })
  118. // Individual tests deliberately exercise rejected and still-pending handles.
  119. void done.catch(() => {})
  120. const settle = (
  121. outcome: SubprocessOutcome = { exitCode: 0, signal: null },
  122. ): void => {
  123. if (exited) return
  124. exited = true
  125. resolveDone(outcome)
  126. }
  127. const fail = (error: Error): void => {
  128. if (exited) return
  129. exited = true
  130. rejectDone(error)
  131. }
  132. if (options.doneError !== undefined) fail(options.doneError)
  133. const terminate = vi.fn<SubprocessHandle['terminate']>(() => {
  134. if (options.exitOnTerminate !== false) settle()
  135. })
  136. const waitForExit = vi.fn<SubprocessHandle['waitForExit']>(async (signal?: AbortSignal): Promise<boolean> => {
  137. if (options.waitForExitError !== undefined) {
  138. throw options.waitForExitError
  139. }
  140. if (exited) return true
  141. if (signal === undefined) {
  142. await done.catch(() => {})
  143. return true
  144. }
  145. return await new Promise<boolean>((resolve) => {
  146. const onAbort = (): void => { resolve(false) }
  147. signal.addEventListener('abort', onAbort, { once: true })
  148. void done.then(
  149. () => {
  150. signal.removeEventListener('abort', onAbort)
  151. resolve(true)
  152. },
  153. () => {
  154. signal.removeEventListener('abort', onAbort)
  155. resolve(true)
  156. },
  157. )
  158. })
  159. })
  160. const handle: SubprocessHandle = {
  161. pid: Object.hasOwn(options, 'pid') ? options.pid : 1234,
  162. stdin,
  163. stdout,
  164. stderr: undefined,
  165. collected: {},
  166. done,
  167. terminate,
  168. waitForExit,
  169. }
  170. return {
  171. handle,
  172. stdin,
  173. stdout,
  174. settle,
  175. fail,
  176. terminate,
  177. waitForExit,
  178. }
  179. }
  180. function success(
  181. result = 'answer',
  182. isError = false,
  183. ): SDKResultMessage {
  184. return {
  185. type: 'result',
  186. subtype: 'success',
  187. is_error: isError,
  188. result,
  189. } as SDKResultMessage
  190. }
  191. type ErrorSubtype = Exclude<SDKResultMessage['subtype'], 'success'>
  192. function failure(
  193. subtype: ErrorSubtype,
  194. errors: string[] = ['fixture failure'],
  195. ): SDKResultMessage {
  196. return {
  197. type: 'result',
  198. subtype,
  199. is_error: true,
  200. errors,
  201. } as SDKResultMessage
  202. }
  203. function expectedFailureDiagnostic(
  204. stage: 'query-start' | 'query-run' | 'process' | 'teardown',
  205. category: string,
  206. outcome?: Partial<SubprocessOutcome>,
  207. ): string {
  208. const fields = [
  209. 'product: Claude Code',
  210. `stage: ${stage}`,
  211. `category: ${category}`,
  212. ]
  213. if (outcome?.exitCode !== null && outcome?.exitCode !== undefined) {
  214. fields.push(`exit code: ${outcome.exitCode}`)
  215. }
  216. if (outcome?.signal !== null && outcome?.signal !== undefined) {
  217. fields.push(`signal: ${outcome.signal}`)
  218. }
  219. return `Product subagent failure (${fields.join('; ')})`
  220. }
  221. function permissionDenied(): SDKPermissionDeniedMessage {
  222. return {
  223. type: 'system',
  224. subtype: 'permission_denied',
  225. tool_name: 'Bash',
  226. tool_use_id: 'tool-secret',
  227. decision_reason_type: 'mode',
  228. decision_reason: 'contains /private/secret.txt',
  229. message: 'command with SECRET_TOKEN was denied',
  230. uuid: '00000000-0000-4000-8000-000000000001',
  231. session_id: 'session-secret',
  232. }
  233. }
  234. function queryFrom(
  235. messages: readonly SDKMessage[],
  236. after?: Error,
  237. close = vi.fn(),
  238. ): Query {
  239. async function* stream(): AsyncGenerator<SDKMessage, void> {
  240. for (const message of messages) yield message
  241. if (after !== undefined) throw after
  242. }
  243. return Object.assign(stream(), { close }) as unknown as Query
  244. }
  245. function waitingQuery(signal: AbortSignal, close = vi.fn()): Query {
  246. async function* stream(): AsyncGenerator<SDKMessage, void> {
  247. await new Promise<never>((_resolve, reject) => {
  248. const fail = (): void => {
  249. reject(signal.reason instanceof Error
  250. ? signal.reason
  251. : new Error(String(signal.reason)))
  252. }
  253. if (signal.aborted) fail()
  254. else signal.addEventListener('abort', fail, { once: true })
  255. })
  256. }
  257. return Object.assign(stream(), { close }) as unknown as Query
  258. }
  259. function sdkSpawnOptions(
  260. overrides: Partial<SpawnOptions> = {},
  261. ): SpawnOptions {
  262. return {
  263. command: '/sdk/claude',
  264. args: ['--output-format', 'stream-json'],
  265. cwd: '/workspace',
  266. env: { PATH: '/bin', OMITTED: undefined },
  267. signal: new AbortController().signal,
  268. ...overrides,
  269. }
  270. }
  271. interface FakeRun {
  272. readonly child: FakeChild
  273. readonly close: ReturnType<typeof vi.fn>
  274. readonly spawnSpecs: SubprocessSpawnSpec[]
  275. readonly options: Options[]
  276. readonly spec: ClaudeCodeRunSpec
  277. }
  278. function fakeRun(
  279. messages: readonly SDKMessage[] = [success()],
  280. after?: Error,
  281. child = fakeChild(),
  282. ): FakeRun {
  283. const close = vi.fn()
  284. const query = queryFrom(messages, after, close)
  285. const spawnSpecs: SubprocessSpawnSpec[] = []
  286. const options: FakeRun['options'] = []
  287. const spec: ClaudeCodeRunSpec = {
  288. cwd: '/workspace',
  289. permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
  290. env: { ANTHROPIC_API_KEY: 'fake-key' },
  291. disposeGraceMs: 5,
  292. spawn: (spawnSpec) => {
  293. spawnSpecs.push(spawnSpec)
  294. return child.handle
  295. },
  296. }
  297. queryMock.mockImplementation((params) => {
  298. options.push(params.options)
  299. params.options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  300. return query
  301. })
  302. return { child, close, spawnSpecs, options, spec }
  303. }
  304. beforeEach(() => {
  305. queryMock.mockImplementation(({ options }) => {
  306. options.spawnClaudeCodeProcess!(sdkSpawnOptions({
  307. cwd: options.cwd!,
  308. env: options.env!,
  309. signal: options.abortController!.signal,
  310. }))
  311. return queryFrom([])
  312. })
  313. })
  314. afterEach(() => {
  315. queryMock.mockReset()
  316. vi.restoreAllMocks()
  317. vi.unstubAllEnvs()
  318. })
  319. describe('task admission and package contracts', () => {
  320. it('ships one independently installable provider-only Bundle patch', () => {
  321. const root = fileURLToPath(new URL('..', import.meta.url))
  322. const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as {
  323. dependencies?: Record<string, string>
  324. files?: string[]
  325. dsh?: { bundle?: { patch?: string } }
  326. }
  327. expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml')
  328. expect(manifest.files).toContain('cordis.patch.yml')
  329. expect(manifest.dependencies).toHaveProperty(
  330. '@anthropic-ai/claude-agent-sdk',
  331. CLAUDE_AGENT_SDK_VERSION,
  332. )
  333. expect(manifest.dependencies).toHaveProperty(
  334. '@modelcontextprotocol/sdk',
  335. '^1.29.0',
  336. )
  337. expect(manifest.dependencies).toHaveProperty('zod', '^4.4.3')
  338. expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex')
  339. const sdkRoot = dirname(fileURLToPath(
  340. import.meta.resolve('@anthropic-ai/claude-agent-sdk'),
  341. ))
  342. const sdkManifest = JSON.parse(readFileSync(
  343. resolve(sdkRoot, 'package.json'),
  344. 'utf8',
  345. )) as {
  346. version: string
  347. claudeCodeVersion: string
  348. optionalDependencies: Record<string, string>
  349. }
  350. expect(sdkManifest.version).toBe(CLAUDE_AGENT_SDK_VERSION)
  351. expect(sdkManifest.claudeCodeVersion).toBe(CLAUDE_CODE_VERSION)
  352. expect(sdkManifest.optionalDependencies).toEqual(Object.fromEntries(
  353. CLAUDE_PLATFORM_PACKAGES.map(packageName => [
  354. packageName,
  355. CLAUDE_AGENT_SDK_VERSION,
  356. ]),
  357. ))
  358. const lockfile = readFileSync(resolve(root, '../../../pnpm-lock.yaml'), 'utf8')
  359. for (const packageName of CLAUDE_PLATFORM_PACKAGES) {
  360. expect(lockfile).toContain(
  361. ` '${packageName}@${CLAUDE_AGENT_SDK_VERSION}':`,
  362. )
  363. expect(lockfile).toContain(
  364. ` '${packageName}': ${CLAUDE_AGENT_SDK_VERSION}`,
  365. )
  366. }
  367. const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'))
  368. const rows = Array.isArray(parsed)
  369. ? (parsed as Array<{ insert?: Array<{ id?: string; name?: string }> }>).flatMap(entry => entry.insert ?? [])
  370. : []
  371. expect(rows).toEqual([{
  372. id: 'subagent-claude-code',
  373. name: '@deepseek-ai/dsh-subagent-claude-code',
  374. }])
  375. expect(JSON.stringify(rows)).not.toContain('tool-subagent')
  376. })
  377. it('preserves text sequences and rejects empty, blank, and non-text tasks', () => {
  378. expect(textTask([
  379. { type: 'text', text: 'one' },
  380. { type: 'text', text: 'two' },
  381. ])).toBe('onetwo')
  382. expect(() => textTask([])).toThrow('only text blocks')
  383. expect(() => textTask([{ type: 'reasoning', text: 'hidden' }]))
  384. .toThrow('only text blocks')
  385. expect(() => textTask([{ type: 'text', text: ' \n ' }]))
  386. .toThrow('must not be empty')
  387. })
  388. it('registers the default descriptor, validates config, and unregisters on HMR', async () => {
  389. const ctx = new Context()
  390. await ctx.plugin(SubagentRuntime)
  391. await ctx.plugin(LocalSubprocessRuntime)
  392. const fiber = await ctx.plugin(claudeCode, {})
  393. expect(ctx.subagents.getProvider('claude-code')).toMatchObject({
  394. name: 'claude-code',
  395. capabilities: {
  396. outputSchema: false,
  397. depthLimit: false,
  398. toolFilter: false,
  399. persona: false,
  400. },
  401. inheritsParentContext: false,
  402. })
  403. expect(ctx.subagents.list()).toEqual(['claude-code'])
  404. await fiber.dispose()
  405. expect(ctx.subagents.list()).toEqual([])
  406. for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
  407. await expect(ctx.plugin(claudeCode, { disposeGraceMs }))
  408. .rejects.toThrow('disposeGraceMs must be a positive finite number')
  409. }
  410. await expect(ctx.plugin(claudeCode, {
  411. disposeGraceMs: MAX_TIMER_DELAY_MS + 1,
  412. })).rejects.toThrow(
  413. `disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
  414. )
  415. await ctx.fiber.dispose()
  416. })
  417. it('keeps named instances, runs, and HMR ownership isolated', async () => {
  418. const ctx = new Context()
  419. await ctx.plugin(SubagentRuntime)
  420. await ctx.plugin(LocalSubprocessRuntime)
  421. const safeChild = fakeChild()
  422. const bypassChild = fakeChild()
  423. const spawnSpecs: SubprocessSpawnSpec[] = []
  424. vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
  425. spawnSpecs.push(spec)
  426. return spec.env?.DSH_CLAUDE_INSTANCE === 'safe'
  427. ? safeChild.handle
  428. : bypassChild.handle
  429. })
  430. const queryOptions: Options[] = []
  431. queryMock.mockImplementation(({ options }) => {
  432. queryOptions.push(options)
  433. options.spawnClaudeCodeProcess!(sdkSpawnOptions({
  434. cwd: options.cwd!,
  435. env: options.env!,
  436. signal: options.abortController!.signal,
  437. }))
  438. return options.permissionMode === 'dontAsk'
  439. ? waitingQuery(options.abortController!.signal)
  440. : queryFrom([success('bypass answer')])
  441. })
  442. const added: string[] = []
  443. const started: string[] = []
  444. const ended: string[] = []
  445. const removed: string[] = []
  446. ctx.on('subagent/provider-added', provider => void added.push(provider.name))
  447. ctx.on('subagent/start', info => void started.push(info.provider))
  448. ctx.on('subagent/end', info => void ended.push(info.provider))
  449. ctx.on('subagent/provider-removed', providerName => void removed.push(providerName))
  450. const safeFiber = await ctx.plugin(claudeCode, {
  451. providerName: 'claude-safe',
  452. model: 'claude-safe-model',
  453. env: { DSH_CLAUDE_INSTANCE: 'safe' },
  454. permissionMode: 'dontAsk',
  455. disposeGraceMs: 11,
  456. })
  457. const bypassFiber = await ctx.plugin(claudeCode, {
  458. providerName: 'claude-bypass',
  459. model: 'claude-bypass-model',
  460. env: { DSH_CLAUDE_INSTANCE: 'bypass' },
  461. permissionMode: 'bypassPermissions',
  462. disposeGraceMs: 29,
  463. })
  464. expect(ctx.subagents.list()).toEqual(['claude-safe', 'claude-bypass'])
  465. expect(added).toEqual(['claude-safe', 'claude-bypass'])
  466. const safeController = new AbortController()
  467. const [safeRun, bypassRun] = await Promise.all([
  468. ctx.subagents.start('claude-safe', request(undefined, safeController.signal)),
  469. ctx.subagents.start('claude-bypass', request()),
  470. ])
  471. await safeFiber.dispose()
  472. expect(ctx.subagents.list()).toEqual(['claude-bypass'])
  473. expect(removed).toEqual(['claude-safe'])
  474. await expect(ctx.subagents.start('claude-safe', request()))
  475. .rejects.toMatchObject({ code: 'NO_PROVIDER' })
  476. await expect(bypassRun.result).resolves.toEqual({
  477. output: [{ type: 'text', text: 'bypass answer' }],
  478. stopReason: 'completed',
  479. })
  480. safeController.abort(new Error('stop only the safe instance'))
  481. await expect(safeRun.result).resolves.toEqual({
  482. output: [],
  483. stopReason: 'aborted',
  484. })
  485. expect(queryOptions.map(options => ({
  486. instance: options.env?.DSH_CLAUDE_INSTANCE,
  487. model: options.model,
  488. permissionMode: options.permissionMode,
  489. }))).toEqual([
  490. { instance: 'safe', model: 'claude-safe-model', permissionMode: 'dontAsk' },
  491. { instance: 'bypass', model: 'claude-bypass-model', permissionMode: 'bypassPermissions' },
  492. ])
  493. expect(spawnSpecs.map(spec => ({
  494. instance: spec.env?.DSH_CLAUDE_INSTANCE,
  495. graceMs: spec.graceMs,
  496. }))).toEqual([
  497. { instance: 'safe', graceMs: 11 },
  498. { instance: 'bypass', graceMs: 29 },
  499. ])
  500. await Promise.all([safeRun.dispose(), bypassRun.dispose()])
  501. expect([...started].sort()).toEqual(['claude-bypass', 'claude-safe'])
  502. expect([...ended].sort()).toEqual(['claude-bypass', 'claude-safe'])
  503. expect(safeChild.terminate).toHaveBeenCalledOnce()
  504. expect(bypassChild.terminate).toHaveBeenCalledOnce()
  505. await bypassFiber.dispose()
  506. expect(removed).toEqual(['claude-safe', 'claude-bypass'])
  507. await ctx.fiber.dispose()
  508. })
  509. it('rejects duplicate provider names without replacing the first instance', async () => {
  510. const ctx = new Context()
  511. await ctx.plugin(SubagentRuntime)
  512. await ctx.plugin(LocalSubprocessRuntime)
  513. const firstFiber = await ctx.plugin(claudeCode, {
  514. providerName: 'claude-duplicate',
  515. })
  516. const first = ctx.subagents.getProvider('claude-duplicate')
  517. await expect(ctx.plugin(claudeCode, {
  518. providerName: 'claude-duplicate',
  519. permissionMode: 'bypassPermissions',
  520. })).rejects.toMatchObject({ code: 'DUPLICATE_PROVIDER' })
  521. expect(ctx.subagents.getProvider('claude-duplicate')).toBe(first)
  522. expect(ctx.subagents.list()).toEqual(['claude-duplicate'])
  523. await firstFiber.dispose()
  524. await ctx.fiber.dispose()
  525. })
  526. it('accepts an optional non-empty model and the five fixed permission modes', () => {
  527. expect(claudeCode.Config({}).providerName).toBe('claude-code')
  528. expect(claudeCode.Config({}).model).toBeUndefined()
  529. expect(claudeCode.Config({ providerName: 'claude-safe' }).providerName)
  530. .toBe('claude-safe')
  531. expect(() => claudeCode.Config({ providerName: '' })).toThrow()
  532. expect(claudeCode.Config({ model: 'claude-opus' }).model).toBe('claude-opus')
  533. expect(() => claudeCode.Config({ model: '' })).toThrow()
  534. expect(claudeCode.Config({}).permissionMode)
  535. .toBe(DEFAULT_CLAUDE_CODE_PERMISSION_MODE)
  536. for (const permissionMode of CLAUDE_CODE_PERMISSION_MODES) {
  537. expect(claudeCode.Config({ permissionMode }).permissionMode)
  538. .toBe(permissionMode)
  539. }
  540. for (const permissionMode of ['default', 'interactive', 'future-mode']) {
  541. expect(() => claudeCode.Config({ permissionMode } as never)).toThrow()
  542. }
  543. })
  544. it('resolves the safe permission default when apply is called directly', async () => {
  545. const ctx = new Context()
  546. await ctx.plugin(SubagentRuntime)
  547. await ctx.plugin(LocalSubprocessRuntime)
  548. const child = fakeChild()
  549. vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle)
  550. queryMock.mockImplementation(({ options }) => {
  551. expect(options).not.toHaveProperty('model')
  552. expect(options.permissionMode).toBe(DEFAULT_CLAUDE_CODE_PERMISSION_MODE)
  553. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  554. return queryFrom([success('native model answer')])
  555. })
  556. claudeCode.apply(ctx, { env: {}, disposeGraceMs: 3_000 })
  557. expect(ctx.subagents.getProvider('claude-code')).toBeDefined()
  558. const run = await ctx.subagents.start('claude-code', request())
  559. await expect(run.result).resolves.toEqual({
  560. output: [{ type: 'text', text: 'native model answer' }],
  561. stopReason: 'completed',
  562. })
  563. await run.dispose()
  564. await ctx.fiber.dispose()
  565. })
  566. it('starts through the registered provider with its resolved config and diagnostics', async () => {
  567. const ctx = new Context()
  568. await ctx.plugin(SubagentRuntime)
  569. await ctx.plugin(LocalSubprocessRuntime)
  570. const child = fakeChild()
  571. const spawn = vi.spyOn(ctx.subprocess, 'spawn')
  572. .mockImplementation(() => child.handle)
  573. const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable')
  574. .mockResolvedValue('/host/bin/claude')
  575. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  576. await ctx.plugin(claudeCode, {
  577. providerName: 'claude-diagnostic',
  578. model: 'claude-diagnostic-model',
  579. env: {
  580. ANTHROPIC_API_KEY: 'provider-fake-key',
  581. CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config',
  582. HOME: '/private/tmp/dsh-claude-code-unit-home',
  583. },
  584. permissionMode: 'auto',
  585. disposeGraceMs: 29,
  586. })
  587. await expect(ctx.subagents.start('claude-diagnostic', {
  588. ...request(),
  589. parent: {
  590. id: 'parent-without-cwd',
  591. session: { header: {} },
  592. } as unknown as Agent,
  593. })).rejects.toThrow(
  594. 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one',
  595. )
  596. expect(queryMock).not.toHaveBeenCalled()
  597. const invalidCwdParent = {
  598. id: 'parent-with-invalid-cwd',
  599. session: { header: { cwd: 'relative/SECRET_TOKEN' } },
  600. } as unknown as Agent
  601. const invalidCwd = ctx.subagents.start('claude-diagnostic', {
  602. ...request(),
  603. parent: invalidCwdParent,
  604. })
  605. await expect(invalidCwd)
  606. .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
  607. await expect(invalidCwd).rejects.not.toThrow('relative/SECRET_TOKEN')
  608. expect(warn).toHaveBeenCalledWith(
  609. 'subagent-claude-code "claude-diagnostic": child start failed: %o',
  610. expect.any(Error),
  611. )
  612. expect(errorCause(warn.mock.calls[0]?.[1] as unknown)?.message)
  613. .toContain('relative/SECRET_TOKEN')
  614. const invalidCwdAbort = new AbortController()
  615. invalidCwdAbort.abort(new Error('cancel invalid cwd startup'))
  616. await expect(ctx.subagents.start('claude-diagnostic', {
  617. ...request(undefined, invalidCwdAbort.signal),
  618. parent: invalidCwdParent,
  619. })).rejects.toThrow('aborted before SDK startup')
  620. expect(queryMock).not.toHaveBeenCalled()
  621. warn.mockClear()
  622. vi.stubEnv('PATH', '/host/bin')
  623. queryMock.mockImplementationOnce(() => {
  624. throw new Error(
  625. 'Native CLI binary for fixture-platform not found. Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set options.pathToClaudeCodeExecutable.',
  626. )
  627. })
  628. const missingPayload = ctx.subagents.start('claude-diagnostic', request())
  629. await expect(missingPayload)
  630. .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
  631. await expect(missingPayload).rejects.not.toThrow('Native CLI binary')
  632. expect(warn).toHaveBeenCalledWith(
  633. expect.stringContaining(
  634. 'subagent-claude-code "claude-diagnostic": child run failed (error):',
  635. ),
  636. expect.any(Error),
  637. )
  638. expect(errorCause(warn.mock.calls[0]?.[1] as unknown)?.message)
  639. .toContain('Native CLI binary for fixture-platform not found')
  640. expect(resolveExecutable).not.toHaveBeenCalled()
  641. const run = await ctx.subagents.start('claude-diagnostic', request())
  642. child.settle({ exitCode: 9, signal: null })
  643. child.stdout.end()
  644. await expect(run.result).resolves.toEqual({
  645. output: [],
  646. diagnostic: expectedFailureDiagnostic('query-run', 'invalid-result'),
  647. stopReason: 'error',
  648. })
  649. expect(warn).toHaveBeenCalledWith(
  650. expect.stringContaining(
  651. 'subagent-claude-code "claude-diagnostic": child run failed (error):',
  652. ),
  653. expect.any(Error),
  654. )
  655. expect(resolveExecutable).not.toHaveBeenCalled()
  656. expect(queryMock.mock.calls[1]?.[0].options)
  657. .not.toHaveProperty('pathToClaudeCodeExecutable')
  658. expect(queryMock.mock.calls[1]?.[0].options.permissionMode).toBe('auto')
  659. expect(queryMock.mock.calls[1]?.[0].options.model)
  660. .toBe('claude-diagnostic-model')
  661. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  662. cwd: process.cwd(),
  663. graceMs: 29,
  664. }))
  665. expect(spawn.mock.calls[0]?.[0].env).toMatchObject({
  666. ANTHROPIC_API_KEY: 'provider-fake-key',
  667. })
  668. await run.dispose()
  669. await ctx.fiber.dispose()
  670. })
  671. it('keeps the Loader namespace shape and package-owned empty invariant', async () => {
  672. expect('default' in claudeCode).toBe(false)
  673. expect(claudeCode.name).toBe('subagent-claude-code')
  674. expect(claudeCode.inject).toEqual(['subagents', 'subprocess'])
  675. const loader = Object.create(Loader.prototype) as Loader
  676. expect(loader.unwrapExports(claudeCode)).toBe(claudeCode)
  677. const dispose = vi.fn()
  678. const register = vi.fn((
  679. _packageName: string,
  680. _installer: InvariantInstaller,
  681. ) => dispose)
  682. const ctx = { invariants: { register } } as unknown as Context
  683. await expect(invariant.apply(ctx)).resolves.toBe(dispose)
  684. expect(register).toHaveBeenCalledWith(
  685. '@deepseek-ai/dsh-subagent-claude-code',
  686. expect.any(Function),
  687. )
  688. const install = register.mock.calls[0]![1]
  689. await install(new Context(), (message) => { throw new Error(message) })
  690. expect(invariant.name).toBe('subagent-claude-code-invariant')
  691. expect(invariant.inject).toEqual(['invariants'])
  692. })
  693. })
  694. describe('official spawn projection', () => {
  695. it('forwards command, arguments, cwd, environment, and signal exactly', () => {
  696. vi.stubEnv('SDK_REMOVED_AMBIENT', 'ambient-value')
  697. const signal = new AbortController().signal
  698. const options = sdkSpawnOptions({
  699. command: '/official/claude',
  700. args: ['--one', 'two'],
  701. cwd: '/parent/workspace',
  702. env: { A: 'one', B: undefined, C: 'three' },
  703. signal,
  704. })
  705. expect(sdkEnvironmentOverlay(options.env)).toEqual(expect.objectContaining({
  706. A: 'one',
  707. B: undefined,
  708. C: 'three',
  709. SDK_REMOVED_AMBIENT: undefined,
  710. }))
  711. const spawnSpec = claudeSpawnSpec(options, 321)
  712. expect(spawnSpec).toMatchObject({
  713. argv: ['/official/claude', '--one', 'two'],
  714. cwd: '/parent/workspace',
  715. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
  716. graceMs: 321,
  717. signal,
  718. })
  719. expect(spawnSpec.env).toEqual(expect.objectContaining({
  720. A: 'one',
  721. B: undefined,
  722. C: 'three',
  723. SDK_REMOVED_AMBIENT: undefined,
  724. }))
  725. const missingCwd = sdkSpawnOptions()
  726. delete missingCwd.cwd
  727. expect(() => claudeSpawnSpec(
  728. missingCwd,
  729. 321,
  730. )).toThrow('SDK spawn request omitted its workspace')
  731. expect(() => claudeSpawnSpec(
  732. sdkSpawnOptions({ cwd: '' }),
  733. 321,
  734. )).toThrow('SDK spawn request omitted its workspace')
  735. })
  736. it('forwards the SDK-selected Windows native executable without a batch shim', () => {
  737. const command = String.raw`C:\Program Files\Claude\claude.exe`
  738. const spec = claudeSpawnSpec(sdkSpawnOptions({
  739. command,
  740. args: ['--output-format', 'stream-json'],
  741. }), 7)
  742. expect(spec.argv).toEqual([
  743. command, '--output-format', 'stream-json',
  744. ])
  745. })
  746. it('projects streams, exit facts, listeners, and idempotent tree termination', async () => {
  747. const child = fakeChild({ exitOnTerminate: false })
  748. const process = new ManagedClaudeCodeProcess(child.handle)
  749. expect(process.stdin).toBe(child.stdin)
  750. expect(process.stdout).toBe(child.stdout)
  751. expect(process.killed).toBe(false)
  752. expect(process.exitCode).toBeNull()
  753. expect(process.signalCode).toBeNull()
  754. expect(process.outcome).toBeUndefined()
  755. const exit = vi.fn()
  756. const once = vi.fn()
  757. const removed = vi.fn()
  758. process.on('exit', exit)
  759. process.once('exit', once)
  760. process.on('exit', removed)
  761. process.off('exit', removed)
  762. expect(process.kill('SIGTERM')).toBe(true)
  763. expect(process.killed).toBe(true)
  764. expect(process.kill('SIGKILL')).toBe(false)
  765. expect(child.terminate).toHaveBeenCalledOnce()
  766. child.settle({ exitCode: null, signal: 'SIGTERM' })
  767. await nextTask()
  768. expect(exit).toHaveBeenCalledWith(null, 'SIGTERM')
  769. expect(once).toHaveBeenCalledOnce()
  770. expect(removed).not.toHaveBeenCalled()
  771. expect(process.signalCode).toBe('SIGTERM')
  772. expect(process.outcome).toEqual({ exitCode: null, signal: 'SIGTERM' })
  773. expect(process.kill('SIGTERM')).toBe(false)
  774. })
  775. it('emits spawn errors', async () => {
  776. const child = fakeChild({ pid: undefined })
  777. const process = new ManagedClaudeCodeProcess(child.handle)
  778. const errorListener = vi.fn()
  779. const removed = vi.fn()
  780. process.once('error', errorListener)
  781. process.on('error', removed)
  782. process.off('error', removed)
  783. child.fail(new Error('spawn boom'))
  784. await nextTask()
  785. expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({
  786. message: 'spawn boom',
  787. }))
  788. expect(removed).not.toHaveBeenCalled()
  789. })
  790. it('exposes a settled direct-child exit code', async () => {
  791. const child = fakeChild()
  792. const process = new ManagedClaudeCodeProcess(child.handle)
  793. child.settle({ exitCode: 7, signal: null })
  794. await nextTask()
  795. expect(process.exitCode).toBe(7)
  796. expect(process.signalCode).toBeNull()
  797. expect(process.outcome).toEqual({ exitCode: 7, signal: null })
  798. expect(process.kill('SIGTERM')).toBe(false)
  799. })
  800. })
  801. describe('query options and result mapping', () => {
  802. it('builds the fixed unattended options over the scrubbed environment', async () => {
  803. vi.stubEnv('HOST_VISIBLE', 'visible')
  804. vi.stubEnv('HOST_SECRET_TOKEN', 'must-not-leak')
  805. vi.stubEnv('DSH_INTERNAL', 'must-not-leak')
  806. const child = fakeChild()
  807. const spawn = vi.fn(() => child.handle)
  808. const captured: SubprocessHandle[] = []
  809. const diagnostics: string[] = []
  810. const spec: ClaudeCodeRunSpec = {
  811. cwd: '/workspace',
  812. model: 'claude-explicit-model',
  813. permissionMode: 'acceptEdits',
  814. env: {
  815. HOST_VISIBLE: 'overridden',
  816. ANTHROPIC_API_KEY: 'explicit-fake-key',
  817. },
  818. disposeGraceMs: 17,
  819. spawn,
  820. }
  821. const controller = new AbortController()
  822. const options = claudeQueryOptions(
  823. spec,
  824. controller,
  825. (value) => {
  826. captured.push(value)
  827. },
  828. value => diagnostics.push(value),
  829. )
  830. expect(options).toMatchObject({
  831. abortController: controller,
  832. cwd: '/workspace',
  833. model: 'claude-explicit-model',
  834. persistSession: false,
  835. disallowedTools: ['AskUserQuestion'],
  836. permissionMode: 'acceptEdits',
  837. supportedDialogKinds: ['refusal_fallback_prompt'],
  838. })
  839. expect(options).not.toHaveProperty('pathToClaudeCodeExecutable')
  840. expect(options).not.toHaveProperty('allowDangerouslySkipPermissions')
  841. expect(options.env).toMatchObject({
  842. HOST_VISIBLE: 'overridden',
  843. ANTHROPIC_API_KEY: 'explicit-fake-key',
  844. })
  845. expect(options.env).not.toHaveProperty('HOST_SECRET_TOKEN')
  846. expect(options.env).not.toHaveProperty('DSH_INTERNAL')
  847. expect(options).not.toHaveProperty('settingSources')
  848. const callbackSignal = new AbortController().signal
  849. await expect(options.canUseTool!(
  850. 'Bash',
  851. { command: 'cat /private/secret.txt', token: 'SECRET_TOKEN' },
  852. {
  853. signal: callbackSignal,
  854. toolUseID: 'tool-1',
  855. requestId: 'request-1',
  856. blockedPath: '/private/secret.txt',
  857. decisionReason: 'SECRET_TOKEN in /private/secret.txt',
  858. },
  859. )).resolves.toEqual({
  860. behavior: 'deny',
  861. message: 'This unattended Claude Code subagent cannot request human approval.',
  862. })
  863. await expect(options.onElicitation!(
  864. {
  865. serverName: 'private-server',
  866. message: 'enter SECRET_TOKEN',
  867. requestedSchema: { secret: true },
  868. },
  869. { signal: callbackSignal, requestId: 'request-2' },
  870. )).resolves.toEqual({ action: 'decline' })
  871. await expect(options.onUserDialog!(
  872. {
  873. dialogKind: 'refusal_fallback_prompt',
  874. payload: { path: '/private/secret.txt', token: 'SECRET_TOKEN' },
  875. },
  876. { signal: callbackSignal, requestId: 'request-3' },
  877. )).resolves.toEqual({ behavior: 'cancelled' })
  878. expect(diagnostics).toEqual([
  879. 'Claude Code unattended decision (mode: acceptEdits; request: tool permission; decision: denied): the provider does not request human approval',
  880. 'Claude Code unattended decision (mode: acceptEdits; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input',
  881. 'Claude Code unattended decision (mode: acceptEdits; request: user dialog; decision: cancelled): the provider does not render blocking dialogs',
  882. ])
  883. expect(diagnostics.join('\n')).not.toContain('SECRET_TOKEN')
  884. expect(diagnostics.join('\n')).not.toContain('/private/secret.txt')
  885. const spawned = options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  886. expect(spawned).toBeInstanceOf(ManagedClaudeCodeProcess)
  887. expect(captured).toEqual([child.handle])
  888. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  889. argv: ['/sdk/claude', '--output-format', 'stream-json'],
  890. cwd: '/workspace',
  891. graceMs: 17,
  892. }))
  893. })
  894. it.each(CLAUDE_CODE_PERMISSION_MODES)(
  895. 'maps the %s mode and only confirms the dangerous bypass',
  896. (permissionMode) => {
  897. const child = fakeChild()
  898. const options = claudeQueryOptions({
  899. cwd: '/workspace',
  900. permissionMode,
  901. env: {},
  902. disposeGraceMs: 17,
  903. spawn: () => child.handle,
  904. }, new AbortController(), () => {}, () => {})
  905. expect(options.permissionMode).toBe(permissionMode)
  906. expect(options).not.toHaveProperty('model')
  907. expect(options.disallowedTools).toEqual(permissionMode === 'plan'
  908. ? ['AskUserQuestion', 'ExitPlanMode']
  909. : ['AskUserQuestion'])
  910. if (permissionMode === 'bypassPermissions') {
  911. expect(options.allowDangerouslySkipPermissions).toBe(true)
  912. expect(options).not.toHaveProperty('canUseTool')
  913. } else {
  914. expect(options).not.toHaveProperty('allowDangerouslySkipPermissions')
  915. expect(options.canUseTool).toBeTypeOf('function')
  916. }
  917. },
  918. )
  919. it('disallows ExitPlanMode before native plan-mode allow rules', () => {
  920. const child = fakeChild()
  921. const options = claudeQueryOptions({
  922. cwd: '/workspace',
  923. permissionMode: 'plan',
  924. env: {},
  925. disposeGraceMs: 17,
  926. spawn: () => child.handle,
  927. }, new AbortController(), () => {}, () => {})
  928. expect(options.disallowedTools).toEqual([
  929. 'AskUserQuestion',
  930. 'ExitPlanMode',
  931. ])
  932. })
  933. it('accepts only a non-error success with a non-blank final result', () => {
  934. expect(successfulResult(success('exact final'))).toBe('exact final')
  935. expect(() => successfulResult(success('answer', true)))
  936. .toThrow(expectedFailureDiagnostic('query-run', 'invalid-result'))
  937. expect(() => successfulResult(success(' \n ')))
  938. .toThrow(expectedFailureDiagnostic('query-run', 'invalid-result'))
  939. const sdkFailure = () => successfulResult(failure(
  940. 'error_during_execution',
  941. ['SECRET_TOKEN', '/private/secret.txt'],
  942. ))
  943. expect(sdkFailure).toThrow(expectedFailureDiagnostic(
  944. 'query-run',
  945. 'product-error',
  946. ))
  947. expect(sdkFailure).not.toThrow('SECRET_TOKEN')
  948. expect(sdkFailure).not.toThrow('/private/secret.txt')
  949. expect(() => successfulResult(failure(
  950. 'error_max_turns',
  951. [],
  952. ))).toThrow(expectedFailureDiagnostic('query-run', 'limit'))
  953. const unknown = {
  954. type: 'result',
  955. subtype: 'future_failure',
  956. is_error: true,
  957. errors: ['SECRET_TOKEN'],
  958. } as unknown as SDKResultMessage
  959. expect(() => successfulResult(unknown))
  960. .toThrow(expectedFailureDiagnostic('query-run', 'unknown'))
  961. expect(() => successfulResult(unknown)).not.toThrow('future_failure')
  962. expect(() => successfulResult(unknown)).not.toThrow('SECRET_TOKEN')
  963. })
  964. it('consumes the complete stream and keeps the latest strict success', async () => {
  965. const query = queryFrom([
  966. { type: 'system', subtype: 'init' } as SDKMessage,
  967. success('first'),
  968. success('last'),
  969. ])
  970. await expect(consumeClaudeQuery(query)).resolves.toEqual({
  971. output: [{ type: 'text', text: 'last' }],
  972. stopReason: 'completed',
  973. })
  974. await expect(consumeClaudeQuery(
  975. queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]),
  976. )).rejects.toThrow(expectedFailureDiagnostic('query-run', 'invalid-result'))
  977. const onPermissionDenied = vi.fn()
  978. await expect(consumeClaudeQuery(queryFrom([
  979. permissionDenied(),
  980. success('after denial'),
  981. ]), onPermissionDenied)).resolves.toEqual({
  982. output: [{ type: 'text', text: 'after denial' }],
  983. stopReason: 'completed',
  984. })
  985. expect(onPermissionDenied).toHaveBeenCalledOnce()
  986. })
  987. })
  988. describe('run publication, cancellation, and settlement', () => {
  989. it('publishes only after Query and managed child exist, then disposes once', async () => {
  990. const fixture = fakeRun([success('exact answer')])
  991. const run = await startClaudeCodeRun(
  992. request([
  993. { type: 'text', text: 'first' },
  994. { type: 'text', text: 'second' },
  995. ]),
  996. fixture.spec,
  997. )
  998. expect(fixture.options).toHaveLength(1)
  999. expect(fixture.spawnSpecs).toHaveLength(1)
  1000. await expect(run.result).resolves.toEqual({
  1001. output: [{ type: 'text', text: 'exact answer' }],
  1002. stopReason: 'completed',
  1003. })
  1004. const first = run.dispose()
  1005. const second = run.dispose()
  1006. expect(second).toBe(first)
  1007. await first
  1008. expect(fixture.close).toHaveBeenCalledOnce()
  1009. expect(fixture.child.terminate).toHaveBeenCalledOnce()
  1010. })
  1011. it('groups SDK errors by parent-action category without changing stop reasons', async () => {
  1012. const cases: Array<readonly [ErrorSubtype, string]> = [
  1013. ['error_during_execution', 'product-error'],
  1014. ['error_max_turns', 'limit'],
  1015. ['error_max_budget_usd', 'limit'],
  1016. ['error_max_structured_output_retries', 'limit'],
  1017. ]
  1018. for (const [subtype, category] of cases) {
  1019. const fixture = fakeRun([failure(subtype)])
  1020. const onError = vi.fn()
  1021. const run = await startClaudeCodeRun(
  1022. request(),
  1023. { ...fixture.spec, onError },
  1024. )
  1025. await expect(run.result).resolves.toEqual({
  1026. output: [],
  1027. diagnostic: expectedFailureDiagnostic('query-run', category),
  1028. stopReason: 'error',
  1029. })
  1030. expect(onError).toHaveBeenCalledWith(
  1031. expect.any(Error),
  1032. 'error',
  1033. )
  1034. await run.dispose()
  1035. }
  1036. })
  1037. it('attaches a safe diagnostic when a permission denial precedes failure', async () => {
  1038. const fixture = fakeRun([
  1039. permissionDenied(),
  1040. failure('error_during_execution'),
  1041. ])
  1042. const run = await startClaudeCodeRun(request(), fixture.spec)
  1043. const result = await run.result
  1044. expect(result).toEqual({
  1045. output: [],
  1046. diagnostic: `${expectedFailureDiagnostic('query-run', 'product-error')}\nClaude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt`,
  1047. stopReason: 'error',
  1048. })
  1049. expect(result.diagnostic).not.toContain('SECRET_TOKEN')
  1050. expect(result.diagnostic).not.toContain('/private/secret.txt')
  1051. await run.dispose()
  1052. })
  1053. it('omits captured diagnostics on success and isolates concurrent runs', async () => {
  1054. const children = [fakeChild(), fakeChild()]
  1055. let childIndex = 0
  1056. const spec: ClaudeCodeRunSpec = {
  1057. cwd: '/workspace',
  1058. permissionMode: 'dontAsk',
  1059. env: {},
  1060. disposeGraceMs: 5,
  1061. spawn: () => children[childIndex++]!.handle,
  1062. }
  1063. queryMock.mockImplementation(({ prompt, options }) => {
  1064. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1065. return prompt === 'denied then completed'
  1066. ? queryFrom([permissionDenied(), success('completed answer')])
  1067. : queryFrom([failure('error_during_execution')])
  1068. })
  1069. const [completed, failed] = await Promise.all([
  1070. startClaudeCodeRun(
  1071. request([{ type: 'text', text: 'denied then completed' }]),
  1072. spec,
  1073. ),
  1074. startClaudeCodeRun(
  1075. request([{ type: 'text', text: 'unrelated failure' }]),
  1076. spec,
  1077. ),
  1078. ])
  1079. await expect(completed.result).resolves.toEqual({
  1080. output: [{ type: 'text', text: 'completed answer' }],
  1081. stopReason: 'completed',
  1082. })
  1083. await expect(failed.result).resolves.toEqual({
  1084. output: [],
  1085. diagnostic: expectedFailureDiagnostic(
  1086. 'query-run',
  1087. 'product-error',
  1088. ),
  1089. stopReason: 'error',
  1090. })
  1091. await Promise.all([completed.dispose(), failed.dispose()])
  1092. })
  1093. it('fails closed when iteration rejects after a result', async () => {
  1094. const child = fakeChild()
  1095. const outcome = { exitCode: 31, signal: null } as const
  1096. async function* stream(): AsyncGenerator<SDKMessage, void> {
  1097. yield success('partial final')
  1098. child.settle(outcome)
  1099. await Promise.resolve()
  1100. throw new Error('iterator boom')
  1101. }
  1102. queryMock.mockImplementation(({ options }) => {
  1103. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1104. return Object.assign(stream(), { close: vi.fn() }) as unknown as Query
  1105. })
  1106. const run = await startClaudeCodeRun(request(), {
  1107. cwd: '/workspace',
  1108. permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
  1109. env: {},
  1110. disposeGraceMs: 5,
  1111. spawn: () => child.handle,
  1112. })
  1113. await expect(run.result).resolves.toEqual({
  1114. output: [],
  1115. diagnostic: expectedFailureDiagnostic('query-run', 'unknown', outcome),
  1116. stopReason: 'error',
  1117. })
  1118. await run.dispose()
  1119. })
  1120. it('maps invalid success and missing result to fixed query-run facts', async () => {
  1121. for (const [messages, category] of [
  1122. [[success('answer', true)], 'invalid-result'],
  1123. [[success('')], 'invalid-result'],
  1124. [[{ type: 'system', subtype: 'init' } as SDKMessage], 'invalid-result'],
  1125. ] as const) {
  1126. const fixture = fakeRun(messages)
  1127. const run = await startClaudeCodeRun(request(), fixture.spec)
  1128. await expect(run.result).resolves.toEqual({
  1129. output: [],
  1130. diagnostic: expectedFailureDiagnostic('query-run', category),
  1131. stopReason: 'error',
  1132. })
  1133. await run.dispose()
  1134. }
  1135. })
  1136. it('reports an early process exit with independent code and signal facts', async () => {
  1137. const outcomes: SubprocessOutcome[] = [
  1138. { exitCode: 23, signal: null },
  1139. { exitCode: null, signal: 'SIGABRT' },
  1140. { exitCode: null, signal: null },
  1141. ]
  1142. for (const outcome of outcomes) {
  1143. const child = fakeChild()
  1144. async function* stream(): AsyncGenerator<SDKMessage, void> {
  1145. child.settle(outcome)
  1146. await Promise.resolve()
  1147. throw new Error('SECRET_TOKEN from process transport')
  1148. }
  1149. queryMock.mockImplementation(({ options }) => {
  1150. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1151. return Object.assign(stream(), { close: vi.fn() }) as unknown as Query
  1152. })
  1153. const run = await startClaudeCodeRun(request(), {
  1154. cwd: '/workspace',
  1155. permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
  1156. env: {},
  1157. disposeGraceMs: 5,
  1158. spawn: () => child.handle,
  1159. })
  1160. const result = await run.result
  1161. expect(result).toEqual({
  1162. output: [],
  1163. diagnostic: expectedFailureDiagnostic(
  1164. 'process',
  1165. 'process',
  1166. outcome,
  1167. ),
  1168. stopReason: 'error',
  1169. })
  1170. expect(result.diagnostic).not.toContain('SECRET_TOKEN')
  1171. await run.dispose()
  1172. }
  1173. })
  1174. it('gives local cancellation precedence and isolates overlapping controllers', async () => {
  1175. const firstChild = fakeChild()
  1176. const secondChild = fakeChild()
  1177. const children = [firstChild, secondChild]
  1178. const controllers: AbortController[] = []
  1179. let index = 0
  1180. const spec: ClaudeCodeRunSpec = {
  1181. cwd: '/workspace',
  1182. permissionMode: 'dontAsk',
  1183. env: {},
  1184. disposeGraceMs: 5,
  1185. spawn: () => children[index++]!.handle,
  1186. }
  1187. queryMock.mockImplementation(({ prompt, options }) => {
  1188. controllers.push(options.abortController!)
  1189. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1190. return prompt === 'wait'
  1191. ? waitingQuery(options.abortController!.signal)
  1192. : queryFrom([success('second answer')])
  1193. })
  1194. const firstAbort = new AbortController()
  1195. const first = await startClaudeCodeRun(
  1196. request([{ type: 'text', text: 'wait' }], firstAbort.signal),
  1197. spec,
  1198. )
  1199. const second = await startClaudeCodeRun(
  1200. request([{ type: 'text', text: 'finish' }]),
  1201. spec,
  1202. )
  1203. expect(controllers).toHaveLength(2)
  1204. expect(controllers[0]).not.toBe(controllers[1])
  1205. firstAbort.abort(new Error('parent cancelled'))
  1206. await expect(first.result).resolves.toEqual({
  1207. output: [],
  1208. stopReason: 'aborted',
  1209. })
  1210. await expect(second.result).resolves.toEqual({
  1211. output: [{ type: 'text', text: 'second answer' }],
  1212. stopReason: 'completed',
  1213. })
  1214. expect(controllers[1]!.signal.aborted).toBe(false)
  1215. await Promise.all([first.dispose(), second.dispose()])
  1216. })
  1217. it('keeps local cancellation authoritative when the SDK iterator ends normally', async () => {
  1218. const parentAbort = new AbortController()
  1219. const child = fakeChild()
  1220. async function* stream(): AsyncGenerator<SDKMessage, void> {
  1221. yield success('candidate answer')
  1222. parentAbort.abort(new Error('parent cancelled at iterator completion'))
  1223. }
  1224. queryMock.mockImplementation(({ options }) => {
  1225. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1226. return Object.assign(stream(), { close: vi.fn() }) as unknown as Query
  1227. })
  1228. const run = await startClaudeCodeRun(
  1229. request(undefined, parentAbort.signal),
  1230. {
  1231. cwd: '/workspace',
  1232. permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
  1233. env: {},
  1234. disposeGraceMs: 5,
  1235. spawn: () => child.handle,
  1236. },
  1237. )
  1238. await expect(run.result).resolves.toEqual({
  1239. output: [],
  1240. stopReason: 'aborted',
  1241. })
  1242. await run.dispose()
  1243. })
  1244. it('rejects pre-abort and every incomplete startup transaction', async () => {
  1245. const preAborted = new AbortController()
  1246. preAborted.abort()
  1247. const unused = fakeRun()
  1248. await expect(startClaudeCodeRun(
  1249. request(undefined, preAborted.signal),
  1250. unused.spec,
  1251. )).rejects.toThrow('aborted before SDK startup')
  1252. expect(unused.options).toEqual([])
  1253. const thrownAbort = new AbortController()
  1254. queryMock.mockImplementationOnce(() => {
  1255. thrownAbort.abort(new Error('startup cancelled before resource publication'))
  1256. throw new Error('query failed before resource publication')
  1257. })
  1258. await expect(startClaudeCodeRun(
  1259. request(undefined, thrownAbort.signal),
  1260. unused.spec,
  1261. )).rejects.toThrow('aborted before SDK startup')
  1262. const noChildClose = vi.fn()
  1263. queryMock.mockImplementationOnce(
  1264. () => queryFrom([], undefined, noChildClose),
  1265. )
  1266. await expect(startClaudeCodeRun(request(), {
  1267. ...unused.spec,
  1268. })).rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
  1269. expect(noChildClose).toHaveBeenCalledOnce()
  1270. const closeFailure = vi.fn(() => { throw new Error('close boom') })
  1271. queryMock.mockImplementationOnce(
  1272. () => queryFrom([], undefined, closeFailure),
  1273. )
  1274. const noChild = startClaudeCodeRun(request(), {
  1275. ...unused.spec,
  1276. })
  1277. await expect(noChild)
  1278. .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
  1279. await expect(noChild).rejects.toThrow(
  1280. `${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`,
  1281. )
  1282. await expect(noChild).rejects.toBeInstanceOf(AggregateError)
  1283. const startupAbort = new AbortController()
  1284. const abortedChild = fakeChild()
  1285. const abortedClose = vi.fn()
  1286. queryMock.mockImplementationOnce(({ options }) => {
  1287. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1288. startupAbort.abort(new Error('startup cancelled'))
  1289. return queryFrom([], undefined, abortedClose)
  1290. })
  1291. const abortedDuringStartup = startClaudeCodeRun(
  1292. request(undefined, startupAbort.signal),
  1293. {
  1294. ...unused.spec,
  1295. spawn: () => abortedChild.handle,
  1296. },
  1297. )
  1298. await expect(abortedDuringStartup)
  1299. .rejects.toThrow('aborted before SDK startup')
  1300. expect(abortedClose).toHaveBeenCalledOnce()
  1301. expect(abortedChild.terminate).toHaveBeenCalledOnce()
  1302. const cleanupAbort = new AbortController()
  1303. const cleanupFailedChild = fakeChild({
  1304. waitForExitError: new Error('SECRET_TOKEN cleanup wait failure'),
  1305. })
  1306. queryMock.mockImplementationOnce(({ options }) => {
  1307. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1308. cleanupAbort.abort(new Error('startup cancelled'))
  1309. return queryFrom([])
  1310. })
  1311. const cancelledCleanupFailure = startClaudeCodeRun(
  1312. request(undefined, cleanupAbort.signal),
  1313. {
  1314. ...unused.spec,
  1315. spawn: () => cleanupFailedChild.handle,
  1316. },
  1317. )
  1318. await expect(cancelledCleanupFailure)
  1319. .rejects.toBeInstanceOf(AggregateError)
  1320. await expect(cancelledCleanupFailure)
  1321. .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
  1322. await expect(cancelledCleanupFailure).rejects.toThrow(
  1323. `${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown', { exitCode: 0, signal: null })}`,
  1324. )
  1325. await expect(cancelledCleanupFailure)
  1326. .rejects.not.toThrow('SECRET_TOKEN')
  1327. queryMock.mockImplementationOnce(() => {
  1328. throw new Error('query failed before resource creation')
  1329. })
  1330. const queryFailureOnError = vi.fn<
  1331. NonNullable<ClaudeCodeRunSpec['onError']>
  1332. >()
  1333. const queryFailure = startClaudeCodeRun(request(), {
  1334. ...unused.spec,
  1335. onError: queryFailureOnError,
  1336. })
  1337. await expect(queryFailure)
  1338. .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
  1339. await expect(queryFailure).rejects.not.toThrow(
  1340. 'query failed before resource creation',
  1341. )
  1342. expect(queryFailureOnError).toHaveBeenCalledWith(
  1343. expect.any(Error),
  1344. 'error',
  1345. )
  1346. expect(errorCause(queryFailureOnError.mock.calls[0]?.[0])?.message)
  1347. .toBe('query failed before resource creation')
  1348. const spawned = fakeChild()
  1349. const spawnSpecs: SubprocessSpawnSpec[] = []
  1350. let factoryController: AbortController | undefined
  1351. queryMock.mockImplementationOnce(({ options }) => {
  1352. factoryController = options.abortController
  1353. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1354. spawned.settle({ exitCode: 17, signal: null })
  1355. throw new Error('query construction failed')
  1356. })
  1357. const factoryFailure = startClaudeCodeRun(request(), {
  1358. ...unused.spec,
  1359. spawn: (spawnSpec) => {
  1360. spawnSpecs.push(spawnSpec)
  1361. return spawned.handle
  1362. },
  1363. })
  1364. await expect(factoryFailure).rejects.toThrow(expectedFailureDiagnostic(
  1365. 'query-start',
  1366. 'unknown',
  1367. { exitCode: 17, signal: null },
  1368. ))
  1369. await expect(factoryFailure).rejects.not.toThrow('query construction failed')
  1370. expect(spawnSpecs).toHaveLength(1)
  1371. expect(factoryController?.signal.aborted).toBe(true)
  1372. expect(spawned.terminate).toHaveBeenCalledOnce()
  1373. const cleanupRaceAbort = new AbortController()
  1374. const cleanupRaceChild = fakeChild({ exitOnTerminate: false })
  1375. queryMock.mockImplementationOnce(({ options }) => {
  1376. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1377. throw new Error('query failed before cleanup wait')
  1378. })
  1379. const cleanupRace = startClaudeCodeRun(
  1380. request(undefined, cleanupRaceAbort.signal),
  1381. {
  1382. ...unused.spec,
  1383. spawn: () => cleanupRaceChild.handle,
  1384. },
  1385. )
  1386. await nextTask()
  1387. cleanupRaceAbort.abort(new Error('cancelled during cleanup'))
  1388. cleanupRaceChild.settle()
  1389. await expect(cleanupRace).rejects.toThrow('aborted before SDK startup')
  1390. const spawnError = Object.assign(
  1391. new Error('spawn /sdk/claude EACCES'),
  1392. { code: 'EACCES', path: '/sdk/claude' },
  1393. )
  1394. const failedSpawn = fakeChild({
  1395. pid: undefined,
  1396. doneError: spawnError,
  1397. })
  1398. const failed = fakeRun([], undefined, failedSpawn)
  1399. const failedStartup = startClaudeCodeRun(request(), failed.spec)
  1400. await expect(failedStartup)
  1401. .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
  1402. await expect(failedStartup).rejects.not.toThrow('spawn /sdk/claude EACCES')
  1403. await expect(failedStartup).rejects.toMatchObject({ cause: spawnError })
  1404. expect(failed.close).toHaveBeenCalledOnce()
  1405. expect(failedSpawn.terminate).toHaveBeenCalledOnce()
  1406. expect(failedSpawn.waitForExit).toHaveBeenCalledOnce()
  1407. const failedSpawnAbort = new AbortController()
  1408. const cancelledFailedSpawn = fakeChild({
  1409. pid: undefined,
  1410. doneError: spawnError,
  1411. })
  1412. const cancelledFailedClose = vi.fn()
  1413. queryMock.mockImplementationOnce(({ options }) => {
  1414. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1415. failedSpawnAbort.abort(new Error('startup cancelled'))
  1416. return queryFrom([], undefined, cancelledFailedClose)
  1417. })
  1418. await expect(startClaudeCodeRun(
  1419. request(undefined, failedSpawnAbort.signal),
  1420. { ...unused.spec, spawn: () => cancelledFailedSpawn.handle },
  1421. )).rejects.toThrow('aborted before SDK startup')
  1422. expect(cancelledFailedClose).toHaveBeenCalledOnce()
  1423. const cancelledFailedSpawnCloseError = new Error('cancelled query close failed')
  1424. const cancelledFailedSpawnClose = vi.fn(() => {
  1425. throw cancelledFailedSpawnCloseError
  1426. })
  1427. const cancelledFailedSpawnWithCloseFailure = fakeChild({
  1428. pid: undefined,
  1429. doneError: spawnError,
  1430. })
  1431. const failedSpawnAbortWithCloseFailure = new AbortController()
  1432. queryMock.mockImplementationOnce(({ options }) => {
  1433. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1434. failedSpawnAbortWithCloseFailure.abort(new Error('startup cancelled'))
  1435. return queryFrom([], undefined, cancelledFailedSpawnClose)
  1436. })
  1437. const cancelledWithCloseFailure = startClaudeCodeRun(
  1438. request(undefined, failedSpawnAbortWithCloseFailure.signal),
  1439. { ...unused.spec, spawn: () => cancelledFailedSpawnWithCloseFailure.handle },
  1440. )
  1441. await expect(cancelledWithCloseFailure).rejects.toMatchObject({
  1442. message: `subagent-claude-code: ${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`,
  1443. errors: [
  1444. expect.objectContaining({
  1445. message: `subagent-claude-code: ${expectedFailureDiagnostic('query-start', 'unknown')}`,
  1446. cause: spawnError,
  1447. }),
  1448. expect.objectContaining({
  1449. message: `subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`,
  1450. cause: cancelledFailedSpawnCloseError,
  1451. }),
  1452. ],
  1453. })
  1454. await expect(cancelledWithCloseFailure)
  1455. .rejects.not.toThrow('spawn /sdk/claude EACCES')
  1456. expect(cancelledFailedSpawnClose).toHaveBeenCalledOnce()
  1457. const failedSpawnCloseError = new Error('query close failed')
  1458. const failedSpawnClose = vi.fn(() => { throw failedSpawnCloseError })
  1459. const failedSpawnWithCloseFailure = fakeChild({
  1460. pid: undefined,
  1461. doneError: spawnError,
  1462. })
  1463. queryMock.mockImplementationOnce(({ options }) => {
  1464. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1465. return queryFrom([], undefined, failedSpawnClose)
  1466. })
  1467. const failedWithCloseFailure = startClaudeCodeRun(request(), {
  1468. ...unused.spec,
  1469. spawn: () => failedSpawnWithCloseFailure.handle,
  1470. })
  1471. await expect(failedWithCloseFailure)
  1472. .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
  1473. await expect(failedWithCloseFailure)
  1474. .rejects.not.toThrow('spawn /sdk/claude EACCES')
  1475. await expect(failedWithCloseFailure).rejects.toMatchObject({
  1476. message: `subagent-claude-code: ${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`,
  1477. errors: [
  1478. expect.objectContaining({ cause: spawnError }),
  1479. expect.objectContaining({ cause: failedSpawnCloseError }),
  1480. ],
  1481. })
  1482. const cleanupError = new Error('live child cleanup failed')
  1483. const constructionError = new Error(
  1484. 'query construction failed with a live child',
  1485. )
  1486. const liveChildCleanupFailure = fakeChild({ waitForExitError: cleanupError })
  1487. queryMock.mockImplementationOnce(({ options }) => {
  1488. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1489. throw constructionError
  1490. })
  1491. const liveCleanupFailure = startClaudeCodeRun(request(), {
  1492. ...unused.spec,
  1493. spawn: () => liveChildCleanupFailure.handle,
  1494. })
  1495. await expect(liveCleanupFailure).rejects.toMatchObject({
  1496. message: `subagent-claude-code: ${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown', { exitCode: 0, signal: null })}`,
  1497. errors: [
  1498. expect.objectContaining({ cause: constructionError }),
  1499. expect.objectContaining({ cause: cleanupError }),
  1500. ],
  1501. })
  1502. await expect(liveCleanupFailure)
  1503. .rejects.not.toThrow('query construction failed with a live child')
  1504. await expect(liveCleanupFailure)
  1505. .rejects.not.toThrow('live child cleanup failed')
  1506. })
  1507. it('waits one event-loop turn for a queued provider startup rejection', async () => {
  1508. const spawnError = Object.assign(
  1509. new Error('spawn /sdk/claude ENOENT'),
  1510. { code: 'ENOENT', path: '/sdk/claude' },
  1511. )
  1512. const child = fakeChild({ pid: undefined })
  1513. const close = vi.fn()
  1514. queryMock.mockImplementationOnce(({ options }) => {
  1515. options.spawnClaudeCodeProcess!(sdkSpawnOptions())
  1516. queueMicrotask(() => { child.fail(spawnError) })
  1517. return queryFrom([], undefined, close)
  1518. })
  1519. const startup = startClaudeCodeRun(request(), {
  1520. cwd: '/workspace',
  1521. permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
  1522. env: {},
  1523. disposeGraceMs: 5,
  1524. spawn: () => child.handle,
  1525. })
  1526. await expect(startup).rejects.toMatchObject({ cause: spawnError })
  1527. expect(close).toHaveBeenCalledOnce()
  1528. expect(child.terminate).toHaveBeenCalledOnce()
  1529. expect(child.waitForExit).toHaveBeenCalledOnce()
  1530. })
  1531. })
  1532. describe('query and process disposal', () => {
  1533. it('closes the query, terminates the tree, and waits for direct-child outcome', async () => {
  1534. const child = fakeChild()
  1535. const close = vi.fn()
  1536. await disposeClaudeCodeChild({ close }, child.handle)
  1537. expect(close).toHaveBeenCalledOnce()
  1538. expect(child.terminate).toHaveBeenCalledOnce()
  1539. expect(child.waitForExit).toHaveBeenCalledOnce()
  1540. expect(child.waitForExit).toHaveBeenCalledWith()
  1541. await expect(child.handle.done).resolves.toEqual({
  1542. exitCode: 0,
  1543. signal: null,
  1544. })
  1545. })
  1546. it('reports a published teardown failure to the Host diagnostic sink', async () => {
  1547. const fixture = fakeRun([success('exact answer')])
  1548. const onError = vi.fn<NonNullable<ClaudeCodeRunSpec['onError']>>()
  1549. const run = await startClaudeCodeRun(request(), {
  1550. ...fixture.spec,
  1551. onError,
  1552. })
  1553. await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
  1554. fixture.close.mockImplementationOnce(() => {
  1555. throw new Error('SECRET_TOKEN close failure')
  1556. })
  1557. await expect(run.dispose()).rejects.toThrow(
  1558. expectedFailureDiagnostic('teardown', 'unknown', {
  1559. exitCode: 0,
  1560. signal: null,
  1561. }),
  1562. )
  1563. expect(onError).toHaveBeenCalledWith(expect.any(Error), 'error')
  1564. expect(errorCause(onError.mock.calls[0]?.[0])?.message)
  1565. .toBe('SECRET_TOKEN close failure')
  1566. })
  1567. it('does not finish disposal before the managed tree exits', async () => {
  1568. const child = fakeChild({ exitOnTerminate: false })
  1569. let disposed = false
  1570. const disposal = disposeClaudeCodeChild(
  1571. { close: vi.fn() },
  1572. child.handle,
  1573. ).then(() => {
  1574. disposed = true
  1575. })
  1576. await nextTask()
  1577. expect(disposed).toBe(false)
  1578. child.settle()
  1579. await disposal
  1580. expect(disposed).toBe(true)
  1581. })
  1582. it('reports close and tree-wait failures without skipping cleanup', async () => {
  1583. const waitFailure = fakeChild({
  1584. waitForExitError: new Error('wait boom'),
  1585. })
  1586. const closeFailure = vi.fn(() => { throw new Error('close boom') })
  1587. const waitAndClose = disposeClaudeCodeChild(
  1588. { close: closeFailure },
  1589. waitFailure.handle,
  1590. )
  1591. await expect(waitAndClose).rejects.toThrow(expectedFailureDiagnostic(
  1592. 'teardown',
  1593. 'unknown',
  1594. { exitCode: 0, signal: null },
  1595. ))
  1596. const waitAndCloseError = await waitAndClose.then(
  1597. () => undefined,
  1598. (error: unknown) => error,
  1599. )
  1600. const waitAndCloseCause = errorCause(waitAndCloseError)
  1601. expect(waitAndCloseCause).toBeInstanceOf(AggregateError)
  1602. expect((waitAndCloseCause as AggregateError).errors).toEqual([
  1603. expect.objectContaining({ message: 'close boom' }),
  1604. expect.objectContaining({ message: 'wait boom' }),
  1605. ])
  1606. expect(waitFailure.terminate).toHaveBeenCalledOnce()
  1607. })
  1608. it('reports a tree-wait failure without waiting for a pending direct outcome', async () => {
  1609. const waitFailure = new Error('managed range observation failed')
  1610. const child = fakeChild({
  1611. exitOnTerminate: false,
  1612. waitForExitError: waitFailure,
  1613. })
  1614. const result = await Promise.race([
  1615. disposeClaudeCodeChild({ close: vi.fn() }, child.handle).then(
  1616. () => undefined,
  1617. (error: unknown) => error,
  1618. ),
  1619. new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 100)),
  1620. ])
  1621. expect(result).not.toBe('timeout')
  1622. expect(errorCause(result)).toBe(waitFailure)
  1623. expect(child.terminate).toHaveBeenCalledOnce()
  1624. })
  1625. })