subagent-claude-code.spec.ts 47 KB

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