subagent-claude-code.spec.ts 62 KB

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