subagent-claude-code.spec.ts 59 KB

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