subagent-claude-code.spec.ts 62 KB

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