subagent-claude-code.spec.ts 63 KB

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