feat(subagent): add Claude Code provider

This commit is contained in:
pku-xht
2026-08-04 20:57:55 +08:00
parent c09b20f96b
commit be56f64b7a
54 changed files with 3014 additions and 220 deletions

View File

@@ -0,0 +1,845 @@
import { PassThrough } from 'node:stream'
import type {
Query,
SDKMessage,
SDKResultMessage,
SpawnOptions,
} from '@anthropic-ai/claude-agent-sdk'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { afterEach, describe, expect, it, type Mock, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type {
SubprocessHandle,
SubprocessOutcome,
SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as claudeCode from '../src/index.ts'
import * as invariant from '../src/invariant.ts'
import {
claudeSpawnSpec,
definedEnvironment,
ManagedClaudeCodeProcess,
} from '../src/process.ts'
import {
claudeQueryOptions,
consumeClaudeQuery,
disposeClaudeCodeChild,
startClaudeCodeRun,
successfulResult,
textTask,
type ClaudeCodeRunSpec,
} from '../src/run.ts'
const fakeParent = {
id: 'parent',
session: { header: { cwd: process.cwd() } },
} as unknown as Agent
function request(
prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
signal = new AbortController().signal,
) {
return { prompt, parent: fakeParent, signal }
}
async function nextTask(): Promise<void> {
await new Promise<void>((resolve) => { setImmediate(resolve) })
}
interface FakeChildOptions {
readonly pid?: number
readonly stdin?: PassThrough | undefined
readonly stdout?: PassThrough | undefined
readonly exitOnTerminate?: boolean
readonly waitForExitResult?: boolean
readonly waitForExitError?: Error
readonly doneError?: Error
}
interface FakeChild {
readonly handle: SubprocessHandle
readonly stdin: PassThrough
readonly stdout: PassThrough
readonly settle: (outcome?: SubprocessOutcome) => void
readonly fail: (error: Error) => void
readonly terminate: Mock<SubprocessHandle['terminate']>
readonly waitForExit: Mock<SubprocessHandle['waitForExit']>
}
function fakeChild(options: FakeChildOptions = {}): FakeChild {
const stdin = new PassThrough()
const stdout = new PassThrough()
let exited = false
let resolveDone!: (outcome: SubprocessOutcome) => void
let rejectDone!: (error: Error) => void
const done = new Promise<SubprocessOutcome>((resolve, reject) => {
resolveDone = resolve
rejectDone = reject
})
// Individual tests deliberately exercise rejected and still-pending handles.
void done.catch(() => {})
const settle = (
outcome: SubprocessOutcome = { exitCode: 0, signal: null },
): void => {
if (exited) return
exited = true
resolveDone(outcome)
}
const fail = (error: Error): void => {
if (exited) return
exited = true
rejectDone(error)
}
if (options.doneError !== undefined) fail(options.doneError)
const terminate = vi.fn<SubprocessHandle['terminate']>(() => {
if (options.exitOnTerminate !== false) settle()
})
const waitForExit = vi.fn<SubprocessHandle['waitForExit']>(async (signal?: AbortSignal): Promise<boolean> => {
if (options.waitForExitError !== undefined) {
throw options.waitForExitError
}
if (options.waitForExitResult !== undefined) {
return options.waitForExitResult
}
if (exited) return true
if (signal === undefined) {
await done.catch(() => {})
return true
}
return await new Promise<boolean>((resolve) => {
const onAbort = (): void => { resolve(false) }
signal.addEventListener('abort', onAbort, { once: true })
void done.then(
() => {
signal.removeEventListener('abort', onAbort)
resolve(true)
},
() => {
signal.removeEventListener('abort', onAbort)
resolve(true)
},
)
})
})
const handle: SubprocessHandle = {
pid: options.pid ?? 1234,
stdin: options.stdin === undefined ? stdin : options.stdin,
stdout: options.stdout === undefined ? stdout : options.stdout,
stderr: undefined,
collected: {},
done,
terminate,
waitForExit,
}
return {
handle,
stdin,
stdout,
settle,
fail,
terminate,
waitForExit,
}
}
function success(
result = 'answer',
isError = false,
): SDKResultMessage {
return {
type: 'result',
subtype: 'success',
is_error: isError,
result,
} as SDKResultMessage
}
type ErrorSubtype = Exclude<SDKResultMessage['subtype'], 'success'>
function failure(
subtype: ErrorSubtype,
errors: string[] = ['fixture failure'],
): SDKResultMessage {
return {
type: 'result',
subtype,
is_error: true,
errors,
} as SDKResultMessage
}
function queryFrom(
messages: readonly SDKMessage[],
after?: Error,
close = vi.fn(),
): Query {
async function* stream(): AsyncGenerator<SDKMessage, void> {
for (const message of messages) yield message
if (after !== undefined) throw after
}
return Object.assign(stream(), { close }) as unknown as Query
}
function waitingQuery(signal: AbortSignal, close = vi.fn()): Query {
async function* stream(): AsyncGenerator<SDKMessage, void> {
await new Promise<never>((_resolve, reject) => {
const fail = (): void => {
reject(signal.reason instanceof Error
? signal.reason
: new Error(String(signal.reason)))
}
if (signal.aborted) fail()
else signal.addEventListener('abort', fail, { once: true })
})
}
return Object.assign(stream(), { close }) as unknown as Query
}
function sdkSpawnOptions(
overrides: Partial<SpawnOptions> = {},
): SpawnOptions {
return {
command: '/sdk/claude',
args: ['--output-format', 'stream-json'],
cwd: '/workspace',
env: { PATH: '/bin', OMITTED: undefined },
signal: new AbortController().signal,
...overrides,
}
}
interface FakeRun {
readonly child: FakeChild
readonly query: Query
readonly close: ReturnType<typeof vi.fn>
readonly spawnSpecs: SubprocessSpawnSpec[]
readonly options: Array<Parameters<NonNullable<ClaudeCodeRunSpec['query']>>[0]['options']>
readonly spec: ClaudeCodeRunSpec
}
function fakeRun(
messages: readonly SDKMessage[] = [success()],
after?: Error,
child = fakeChild(),
): FakeRun {
const close = vi.fn()
const query = queryFrom(messages, after, close)
const spawnSpecs: SubprocessSpawnSpec[] = []
const options: FakeRun['options'] = []
const spec: ClaudeCodeRunSpec = {
cwd: '/workspace',
env: { ANTHROPIC_API_KEY: 'fake-key' },
disposeGraceMs: 5,
spawn: (spawnSpec) => {
spawnSpecs.push(spawnSpec)
return child.handle
},
query: (params) => {
options.push(params.options)
params.options.spawnClaudeCodeProcess!(sdkSpawnOptions())
return query
},
}
return { child, query, close, spawnSpecs, options, spec }
}
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
describe('task admission and package contracts', () => {
it('preserves text sequences and rejects empty, blank, and non-text tasks', () => {
expect(textTask([
{ type: 'text', text: 'one' },
{ type: 'text', text: 'two' },
])).toBe('onetwo')
expect(() => textTask([])).toThrow('only text blocks')
expect(() => textTask([{ type: 'reasoning', text: 'hidden' }]))
.toThrow('only text blocks')
expect(() => textTask([{ type: 'text', text: ' \n ' }]))
.toThrow('must not be empty')
})
it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
const fiber = await ctx.plugin(claudeCode, {})
expect(ctx.subagents.getProvider('claude-code')).toMatchObject({
name: 'claude-code',
capabilities: {
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
},
inheritsParentContext: false,
})
expect(ctx.subagents.list()).toEqual(['claude-code'])
await fiber.dispose()
expect(ctx.subagents.list()).toEqual([])
for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
await expect(ctx.plugin(claudeCode, { disposeGraceMs }))
.rejects.toThrow('disposeGraceMs must be a positive finite number')
}
await ctx.fiber.dispose()
})
it('starts through the registered provider with its resolved config and diagnostics', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
const child = fakeChild()
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
.mockImplementation(() => child.handle)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
await ctx.plugin(claudeCode, {
env: {
ANTHROPIC_API_KEY: 'provider-fake-key',
CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config',
HOME: '/private/tmp/dsh-claude-code-unit-home',
},
disposeGraceMs: 29,
})
const run = await ctx.subagents.start('claude-code', request())
child.settle({ exitCode: 9, signal: null })
child.stdout.end()
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'error',
})
expect(warn).toHaveBeenCalledWith(expect.stringContaining(
'subagent-claude-code: child run failed (error):',
))
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
cwd: process.cwd(),
graceMs: 29,
}))
expect(spawn.mock.calls[0]?.[0].env).toMatchObject({
ANTHROPIC_API_KEY: 'provider-fake-key',
})
await run.dispose()
await ctx.fiber.dispose()
})
it('keeps the Loader namespace shape and package-owned empty invariant', async () => {
expect('default' in claudeCode).toBe(false)
expect(claudeCode.name).toBe('subagent-claude-code')
expect(claudeCode.inject).toEqual(['subagents', 'subprocess'])
const loader = Object.create(Loader.prototype) as Loader
expect(loader.unwrapExports(claudeCode)).toBe(claudeCode)
const dispose = vi.fn()
const register = vi.fn((
_packageName: string,
_installer: InvariantInstaller,
) => dispose)
const ctx = { invariants: { register } } as unknown as Context
await expect(invariant.apply(ctx)).resolves.toBe(dispose)
expect(register).toHaveBeenCalledWith(
'@deepseek-ai/dsh-subagent-claude-code',
expect.any(Function),
)
const install = register.mock.calls[0]![1]
await install(new Context(), (message) => { throw new Error(message) })
expect(invariant.name).toBe('subagent-claude-code-invariant')
expect(invariant.inject).toEqual(['invariants'])
})
})
describe('official spawn projection', () => {
it('forwards command, arguments, cwd, environment, and signal exactly', () => {
const signal = new AbortController().signal
const options = sdkSpawnOptions({
command: '/official/claude',
args: ['--one', 'two'],
cwd: '/parent/workspace',
env: { A: 'one', B: undefined, C: 'three' },
signal,
})
expect(definedEnvironment(options.env)).toEqual({ A: 'one', C: 'three' })
expect(claudeSpawnSpec(options, 321)).toEqual({
argv: ['/official/claude', '--one', 'two'],
cwd: '/parent/workspace',
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
graceMs: 321,
signal,
env: { A: 'one', C: 'three' },
})
const missingCwd = sdkSpawnOptions()
delete missingCwd.cwd
expect(() => claudeSpawnSpec(
missingCwd,
321,
)).toThrow('SDK spawn request omitted its workspace')
expect(() => claudeSpawnSpec(
sdkSpawnOptions({ cwd: '' }),
321,
)).toThrow('SDK spawn request omitted its workspace')
})
it('projects streams, exit facts, listeners, and idempotent tree termination', async () => {
const child = fakeChild({ exitOnTerminate: false })
const process = new ManagedClaudeCodeProcess(child.handle)
expect(process.stdin).toBe(child.stdin)
expect(process.stdout).toBe(child.stdout)
expect(process.killed).toBe(false)
expect(process.exitCode).toBeNull()
expect(process.signalCode).toBeNull()
const exit = vi.fn()
const once = vi.fn()
const removed = vi.fn()
process.on('exit', exit)
process.once('exit', once)
process.on('exit', removed)
process.off('exit', removed)
expect(process.kill('SIGTERM')).toBe(true)
expect(process.killed).toBe(true)
expect(process.kill('SIGKILL')).toBe(false)
expect(child.terminate).toHaveBeenCalledOnce()
child.settle({ exitCode: null, signal: 'SIGTERM' })
await nextTask()
expect(exit).toHaveBeenCalledWith(null, 'SIGTERM')
expect(once).toHaveBeenCalledOnce()
expect(removed).not.toHaveBeenCalled()
expect(process.signalCode).toBe('SIGTERM')
expect(process.kill('SIGTERM')).toBe(false)
})
it('emits spawn errors and rejects handles without the required pipes', async () => {
const child = fakeChild()
const process = new ManagedClaudeCodeProcess(child.handle)
const errorListener = vi.fn()
const removed = vi.fn()
process.once('error', errorListener)
process.on('error', removed)
process.off('error', removed)
child.fail(new Error('spawn boom'))
await nextTask()
expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({
message: 'spawn boom',
}))
expect(removed).not.toHaveBeenCalled()
const missingStdin = fakeChild({ stdin: undefined })
Object.defineProperty(missingStdin.handle, 'stdin', { value: undefined })
expect(() => new ManagedClaudeCodeProcess(missingStdin.handle))
.toThrow('requires piped stdin and stdout')
const missingStdout = fakeChild({ stdout: undefined })
Object.defineProperty(missingStdout.handle, 'stdout', { value: undefined })
expect(() => new ManagedClaudeCodeProcess(missingStdout.handle))
.toThrow('requires piped stdin and stdout')
})
it('exposes a settled direct-child exit code', async () => {
const child = fakeChild()
const process = new ManagedClaudeCodeProcess(child.handle)
child.settle({ exitCode: 7, signal: null })
await nextTask()
expect(process.exitCode).toBe(7)
expect(process.signalCode).toBeNull()
expect(process.kill('SIGTERM')).toBe(false)
})
})
describe('query options and result mapping', () => {
it('builds the fixed unattended options over the scrubbed environment', () => {
vi.stubEnv('HOST_VISIBLE', 'visible')
vi.stubEnv('HOST_SECRET_TOKEN', 'must-not-leak')
vi.stubEnv('DSH_INTERNAL', 'must-not-leak')
const child = fakeChild()
const spawn = vi.fn(() => child.handle)
const captured: SubprocessHandle[] = []
const spec: ClaudeCodeRunSpec = {
cwd: '/workspace',
env: {
HOST_VISIBLE: 'overridden',
ANTHROPIC_API_KEY: 'explicit-fake-key',
},
disposeGraceMs: 17,
spawn,
}
const controller = new AbortController()
const options = claudeQueryOptions(spec, controller, (value) => {
captured.push(value)
})
expect(options).toMatchObject({
abortController: controller,
cwd: '/workspace',
persistSession: false,
disallowedTools: ['AskUserQuestion'],
})
expect(options.env).toMatchObject({
HOST_VISIBLE: 'overridden',
ANTHROPIC_API_KEY: 'explicit-fake-key',
})
expect(options.env).not.toHaveProperty('HOST_SECRET_TOKEN')
expect(options.env).not.toHaveProperty('DSH_INTERNAL')
for (const omitted of [
'settingSources',
'canUseTool',
'onElicitation',
'onUserDialog',
'supportedDialogKinds',
]) {
expect(options).not.toHaveProperty(omitted)
}
const spawned = options.spawnClaudeCodeProcess!(sdkSpawnOptions())
expect(spawned).toBeInstanceOf(ManagedClaudeCodeProcess)
expect(captured).toEqual([child.handle])
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
argv: ['/sdk/claude', '--output-format', 'stream-json'],
cwd: '/workspace',
graceMs: 17,
}))
})
it('accepts only a non-error success with a non-blank final result', () => {
expect(successfulResult(success('exact final'))).toBe('exact final')
expect(() => successfulResult(success('answer', true)))
.toThrow('marked as an error')
expect(() => successfulResult(success(' \n ')))
.toThrow('contained no answer')
expect(() => successfulResult(failure(
'error_during_execution',
['first', 'second'],
))).toThrow('first; second')
expect(() => successfulResult(failure(
'error_max_turns',
[],
))).toThrow('error_max_turns')
})
it('consumes the complete stream and keeps the latest strict success', async () => {
const outputs: ContentBlock[][] = []
const query = queryFrom([
{ type: 'system', subtype: 'init' } as SDKMessage,
success('first'),
success('last'),
])
await expect(consumeClaudeQuery(query, (output) => {
outputs.push(output)
})).resolves.toEqual({
output: [{ type: 'text', text: 'last' }],
stopReason: 'completed',
})
expect(outputs).toEqual([
[{ type: 'text', text: 'first' }],
[{ type: 'text', text: 'last' }],
])
await expect(consumeClaudeQuery(
queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]),
() => {},
)).rejects.toThrow('ended without a result')
})
})
describe('run publication, cancellation, and settlement', () => {
it('publishes only after Query and managed child exist, then disposes once', async () => {
const fixture = fakeRun([success('exact answer')])
const run = await startClaudeCodeRun(
request([
{ type: 'text', text: 'first' },
{ type: 'text', text: 'second' },
]),
fixture.spec,
)
expect(fixture.options).toHaveLength(1)
expect(fixture.spawnSpecs).toHaveLength(1)
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: 'exact answer' }],
stopReason: 'completed',
})
const first = run.dispose()
const second = run.dispose()
expect(second).toBe(first)
await first
expect(fixture.close).toHaveBeenCalledOnce()
expect(fixture.child.terminate).toHaveBeenCalledOnce()
})
it('flattens every SDK error result without inventing shared stop reasons', async () => {
const subtypes: ErrorSubtype[] = [
'error_during_execution',
'error_max_turns',
'error_max_budget_usd',
'error_max_structured_output_retries',
]
for (const subtype of subtypes) {
const fixture = fakeRun([failure(subtype)])
const onError = vi.fn()
const run = await startClaudeCodeRun(
request(),
{ ...fixture.spec, onError },
)
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'error',
})
expect(onError).toHaveBeenCalledWith(
expect.any(Error),
'error',
)
await run.dispose()
}
})
it('preserves candidate output when iteration fails after a result', async () => {
const fixture = fakeRun(
[success('partial final')],
new Error('iterator boom'),
)
const run = await startClaudeCodeRun(request(), fixture.spec)
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: 'partial final' }],
stopReason: 'error',
})
await run.dispose()
})
it('maps invalid success and missing result to error', async () => {
for (const messages of [
[success('answer', true)],
[success('')],
[{ type: 'system', subtype: 'init' } as SDKMessage],
]) {
const fixture = fakeRun(messages)
const run = await startClaudeCodeRun(request(), fixture.spec)
await expect(run.result).resolves.toMatchObject({
stopReason: 'error',
})
await run.dispose()
}
})
it('gives local cancellation precedence and isolates overlapping controllers', async () => {
const firstChild = fakeChild()
const secondChild = fakeChild()
const children = [firstChild, secondChild]
const controllers: AbortController[] = []
let index = 0
const spec: ClaudeCodeRunSpec = {
cwd: '/workspace',
env: {},
disposeGraceMs: 5,
spawn: () => children[index++]!.handle,
query: ({ prompt, options }) => {
controllers.push(options.abortController!)
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
return prompt === 'wait'
? waitingQuery(options.abortController!.signal)
: queryFrom([success('second answer')])
},
}
const firstAbort = new AbortController()
const first = await startClaudeCodeRun(
request([{ type: 'text', text: 'wait' }], firstAbort.signal),
spec,
)
const second = await startClaudeCodeRun(
request([{ type: 'text', text: 'finish' }]),
spec,
)
expect(controllers).toHaveLength(2)
expect(controllers[0]).not.toBe(controllers[1])
firstAbort.abort(new Error('parent cancelled'))
await expect(first.result).resolves.toEqual({
output: [],
stopReason: 'aborted',
})
await expect(second.result).resolves.toEqual({
output: [{ type: 'text', text: 'second answer' }],
stopReason: 'completed',
})
expect(controllers[1]!.signal.aborted).toBe(false)
await Promise.all([first.dispose(), second.dispose()])
})
it('rejects pre-abort and every incomplete startup transaction', async () => {
const preAborted = new AbortController()
preAborted.abort()
const unused = fakeRun()
await expect(startClaudeCodeRun(
request(undefined, preAborted.signal),
unused.spec,
)).rejects.toThrow('aborted before SDK startup')
expect(unused.options).toEqual([])
const noChildClose = vi.fn()
await expect(startClaudeCodeRun(request(), {
...unused.spec,
query: () => queryFrom([], undefined, noChildClose),
})).rejects.toThrow('did not publish a controllable')
expect(noChildClose).toHaveBeenCalledOnce()
const closeFailure = vi.fn(() => { throw new Error('close boom') })
const noChild = startClaudeCodeRun(request(), {
...unused.spec,
query: () => queryFrom([], undefined, closeFailure),
})
await expect(noChild).rejects.toBeInstanceOf(AggregateError)
const startupAbort = new AbortController()
const abortedChild = fakeChild()
const abortedClose = vi.fn()
const abortedDuringStartup = startClaudeCodeRun(
request(undefined, startupAbort.signal),
{
...unused.spec,
spawn: () => abortedChild.handle,
query: ({ options }) => {
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
startupAbort.abort(new Error('startup cancelled'))
return queryFrom([], undefined, abortedClose)
},
},
)
await expect(abortedDuringStartup)
.rejects.toThrow('aborted before SDK startup')
expect(abortedClose).toHaveBeenCalledOnce()
expect(abortedChild.terminate).toHaveBeenCalledOnce()
await expect(startClaudeCodeRun(request(), {
...unused.spec,
query: () => {
throw new Error('query failed before resource creation')
},
})).rejects.toThrow('query failed before resource creation')
const spawned = fakeChild()
const spawnSpecs: SubprocessSpawnSpec[] = []
let factoryController: AbortController | undefined
const factoryFailure = startClaudeCodeRun(request(), {
...unused.spec,
spawn: (spawnSpec) => {
spawnSpecs.push(spawnSpec)
return spawned.handle
},
query: ({ options }) => {
factoryController = options.abortController
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
throw new Error('query construction failed')
},
})
await expect(factoryFailure).rejects.toThrow('query construction failed')
expect(spawnSpecs).toHaveLength(1)
expect(factoryController?.signal.aborted).toBe(true)
expect(spawned.terminate).toHaveBeenCalledOnce()
const failedSpawn = fakeChild({
pid: -1,
doneError: new Error('spawn failed'),
})
const failed = fakeRun([], undefined, failedSpawn)
await expect(startClaudeCodeRun(request(), failed.spec))
.rejects.toBeInstanceOf(AggregateError)
expect(failed.close).toHaveBeenCalledOnce()
})
})
describe('bounded query and process disposal', () => {
it('closes the query, terminates the tree, and waits for direct-child outcome', async () => {
const child = fakeChild()
const close = vi.fn()
await disposeClaudeCodeChild({ close }, child.handle, 5)
expect(close).toHaveBeenCalledOnce()
expect(child.terminate).toHaveBeenCalledOnce()
expect(child.waitForExit).toHaveBeenCalledOnce()
await expect(child.handle.done).resolves.toEqual({
exitCode: 0,
signal: null,
})
})
it('accepts fractional and larger-than-Node grace windows', async () => {
for (const graceMs of [0.25, Number.MAX_VALUE]) {
const child = fakeChild()
await expect(disposeClaudeCodeChild(
{ close: vi.fn() },
child.handle,
graceMs,
)).resolves.toBeUndefined()
const signal = child.waitForExit.mock.calls[0]?.[0]
expect(signal?.aborted).toBe(false)
}
})
it('chains a doubled grace window beyond one Node timer segment', async () => {
vi.useFakeTimers()
try {
const child = fakeChild({ exitOnTerminate: false })
const disposal = disposeClaudeCodeChild(
{ close: vi.fn() },
child.handle,
1_073_741_823.75,
)
const rejected = expect(disposal)
.rejects.toThrow('did not exit within its dispose window')
await vi.advanceTimersByTimeAsync(2_147_483_647)
await vi.advanceTimersByTimeAsync(1)
await rejected
} finally {
vi.useRealTimers()
}
})
it('does not turn a missed tree-exit bound into an unbounded done wait', async () => {
const child = fakeChild({
exitOnTerminate: false,
waitForExitResult: false,
})
await expect(disposeClaudeCodeChild(
{ close: vi.fn() },
child.handle,
5,
)).rejects.toThrow('did not exit within its dispose window')
child.fail(new Error('late direct-child failure'))
await nextTask()
})
it('reports wait, close, and direct-child failures without skipping cleanup', async () => {
const waitFailure = fakeChild({
exitOnTerminate: false,
waitForExitError: new Error('wait boom'),
})
const closeFailure = vi.fn(() => { throw new Error('close boom') })
await expect(disposeClaudeCodeChild(
{ close: closeFailure },
waitFailure.handle,
5,
)).rejects.toBeInstanceOf(AggregateError)
expect(waitFailure.terminate).toHaveBeenCalledOnce()
const doneFailure = fakeChild({
pid: -1,
doneError: new Error('spawn boom'),
})
await expect(disposeClaudeCodeChild(
{ close: vi.fn() },
doneFailure.handle,
5,
)).rejects.toThrow('spawn boom')
const both = fakeChild({
pid: -1,
doneError: new Error('spawn boom'),
})
await expect(disposeClaudeCodeChild(
{ close: () => { throw new Error('close boom') } },
both.handle,
5,
)).rejects.toBeInstanceOf(AggregateError)
})
})