feat(subagent): add Claude Code provider
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
LOADER_SMOKE_TEST_TIMEOUT_MS,
|
||||
runLoaderSmoke,
|
||||
} from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const fixtureDir = fileURLToPath(new URL(
|
||||
'../../../../examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/',
|
||||
import.meta.url,
|
||||
))
|
||||
const driver = join(fixtureDir, 'driver.ts')
|
||||
const configPath = join(fixtureDir, 'cordis.yml')
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
describe('product-provider public Loader composition', () => {
|
||||
it('loads both opt-in packages and foreground tools without starting either product', async () => {
|
||||
const { stdout, stderr } = await runLoaderSmoke({
|
||||
label: 'product-provider Loader composition',
|
||||
tempDirPrefix: 'dsh-product-provider-loader-',
|
||||
binScript: driver,
|
||||
libBinScript: driver,
|
||||
configPath,
|
||||
tsconfigPath: repoTsconfig,
|
||||
env: {
|
||||
// Loading either optional package must not probe or start its binary.
|
||||
PATH: '',
|
||||
},
|
||||
})
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
registeredProviders: ['codex', 'claude-code'],
|
||||
providers: [
|
||||
{
|
||||
name: 'codex',
|
||||
capabilities: {
|
||||
outputSchema: false,
|
||||
depthLimit: false,
|
||||
toolFilter: false,
|
||||
persona: false,
|
||||
},
|
||||
inheritsParentContext: false,
|
||||
},
|
||||
{
|
||||
name: 'claude-code',
|
||||
capabilities: {
|
||||
outputSchema: false,
|
||||
depthLimit: false,
|
||||
toolFilter: false,
|
||||
persona: false,
|
||||
},
|
||||
inheritsParentContext: false,
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: 'subagent_codex',
|
||||
parameterNames: ['description', 'prompt'],
|
||||
required: ['description', 'prompt'],
|
||||
},
|
||||
{
|
||||
name: 'subagent_claude_code',
|
||||
parameterNames: ['description', 'prompt'],
|
||||
required: ['description', 'prompt'],
|
||||
},
|
||||
],
|
||||
starts: 0,
|
||||
})
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
163
packages/subagent/subagent-claude-code/tests/messages-fixture.ts
Normal file
163
packages/subagent/subagent-claude-code/tests/messages-fixture.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { createServer, type IncomingHttpHeaders, type ServerResponse } from 'node:http'
|
||||
|
||||
/** One deterministic response emitted by the package-private Messages server. */
|
||||
export type MessagesBehavior =
|
||||
| { readonly kind: 'complete'; readonly text: string }
|
||||
| { readonly kind: 'hold' }
|
||||
|
||||
/** One recorded Anthropic Messages request. */
|
||||
interface RecordedMessagesRequest {
|
||||
readonly method: string
|
||||
readonly path: string
|
||||
readonly headers: IncomingHttpHeaders
|
||||
readonly body: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Running package-private Anthropic Messages fixture. */
|
||||
export interface MessagesFixture {
|
||||
readonly baseUrl: string
|
||||
readonly requests: RecordedMessagesRequest[]
|
||||
readonly requestStarted: Promise<void>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
function event(
|
||||
response: ServerResponse,
|
||||
type: string,
|
||||
payload: Record<string, unknown>,
|
||||
): void {
|
||||
response.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`)
|
||||
}
|
||||
|
||||
function complete(
|
||||
response: ServerResponse,
|
||||
body: Record<string, unknown>,
|
||||
text: string,
|
||||
): void {
|
||||
const model = typeof body.model === 'string' ? body.model : 'fixture-model'
|
||||
response.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive',
|
||||
})
|
||||
event(response, 'message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: 'msg_dsh_fixture',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model,
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 7,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
event(response, 'content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'text', text: '' },
|
||||
})
|
||||
event(response, 'content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text },
|
||||
})
|
||||
event(response, 'content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: 0,
|
||||
})
|
||||
event(response, 'message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
||||
usage: { output_tokens: 1 },
|
||||
})
|
||||
event(response, 'message_stop', { type: 'message_stop' })
|
||||
response.end()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a loopback-only Anthropic Messages SSE fixture.
|
||||
* @param script - one behavior per Messages request.
|
||||
* @returns the bound server and its recorded requests.
|
||||
*/
|
||||
export async function startMessagesFixture(
|
||||
script: readonly MessagesBehavior[],
|
||||
): Promise<MessagesFixture> {
|
||||
const requests: RecordedMessagesRequest[] = []
|
||||
let requestStartedResolve!: () => void
|
||||
const requestStarted = new Promise<void>((resolve) => {
|
||||
requestStartedResolve = resolve
|
||||
})
|
||||
let behaviorIndex = 0
|
||||
const server = createServer((request, response) => {
|
||||
const chunks: Buffer[] = []
|
||||
request.on('data', (chunk: Buffer) => { chunks.push(chunk) })
|
||||
request.on('end', () => {
|
||||
const path = request.url ?? ''
|
||||
if (!path.startsWith('/v1/messages')) {
|
||||
response.writeHead(404, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
type: 'error',
|
||||
error: { type: 'not_found_error', message: `unexpected path ${path}` },
|
||||
}))
|
||||
return
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
const body = JSON.parse(text) as Record<string, unknown>
|
||||
requests.push({
|
||||
method: request.method ?? '',
|
||||
path,
|
||||
headers: request.headers,
|
||||
body,
|
||||
})
|
||||
requestStartedResolve()
|
||||
const behavior = script[behaviorIndex++]
|
||||
if (behavior === undefined) {
|
||||
response.writeHead(500, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: 'Messages fixture script was exhausted',
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (behavior.kind === 'complete') {
|
||||
complete(response, body, behavior.text)
|
||||
}
|
||||
// A hold deliberately leaves the response pending until client abort.
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') {
|
||||
throw new Error('Messages fixture did not bind a TCP port')
|
||||
}
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
requestStarted,
|
||||
async close(): Promise<void> {
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error !== undefined) reject(error)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as claudeCode from '../src/index.ts'
|
||||
import {
|
||||
startMessagesFixture,
|
||||
type MessagesBehavior,
|
||||
type MessagesFixture,
|
||||
} from './messages-fixture.ts'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const sdkRoot = dirname(fileURLToPath(
|
||||
import.meta.resolve('@anthropic-ai/claude-agent-sdk'),
|
||||
))
|
||||
const sdkPackage = JSON.parse(readFileSync(
|
||||
join(sdkRoot, 'package.json'),
|
||||
'utf8',
|
||||
)) as {
|
||||
version: string
|
||||
claudeCodeVersion: string
|
||||
optionalDependencies: Record<string, string>
|
||||
}
|
||||
const platformPackage = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`
|
||||
const platformRoot = resolve(sdkRoot, '..', platformPackage.split('/')[1]!)
|
||||
const claudeBin = join(
|
||||
platformRoot,
|
||||
process.platform === 'win32' ? 'claude.exe' : 'claude',
|
||||
)
|
||||
const settingsModel = 'dsh-settings-inheritance-marker'
|
||||
const fakeKey = 'dsh-fake-anthropic-key'
|
||||
|
||||
const roots: string[] = []
|
||||
const fixtures: MessagesFixture[] = []
|
||||
const contexts: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
await Promise.all(fixtures.splice(0).map(fixture => fixture.close()))
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
interface RealHarness {
|
||||
readonly ctx: Context
|
||||
readonly handles: SubprocessHandle[]
|
||||
readonly parent: Agent
|
||||
readonly workspace: string
|
||||
readonly env: Record<string, string>
|
||||
}
|
||||
|
||||
async function realHarness(script: readonly MessagesBehavior[]): Promise<{
|
||||
readonly harness: RealHarness
|
||||
readonly fixture: MessagesFixture
|
||||
}> {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-real-'))
|
||||
roots.push(root)
|
||||
const workspace = join(root, 'workspace')
|
||||
const claudeConfig = join(root, 'claude-config')
|
||||
const xdgConfig = join(root, 'xdg')
|
||||
mkdirSync(workspace)
|
||||
mkdirSync(claudeConfig)
|
||||
mkdirSync(xdgConfig)
|
||||
writeFileSync(
|
||||
join(claudeConfig, 'settings.json'),
|
||||
`${JSON.stringify({ model: settingsModel }, null, 2)}\n`,
|
||||
)
|
||||
const fixture = await startMessagesFixture(script)
|
||||
fixtures.push(fixture)
|
||||
const env = {
|
||||
ANTHROPIC_API_KEY: fakeKey,
|
||||
ANTHROPIC_BASE_URL: fixture.baseUrl,
|
||||
CLAUDE_CONFIG_DIR: claudeConfig,
|
||||
HOME: root,
|
||||
XDG_CONFIG_HOME: xdgConfig,
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: '1',
|
||||
DISABLE_TELEMETRY: '1',
|
||||
DISABLE_ERROR_REPORTING: '1',
|
||||
HTTP_PROXY: '',
|
||||
HTTPS_PROXY: '',
|
||||
ALL_PROXY: '',
|
||||
NO_PROXY: '127.0.0.1,localhost',
|
||||
}
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
const handles: SubprocessHandle[] = []
|
||||
const spawn = ctx.subprocess.spawn.bind(ctx.subprocess)
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
|
||||
const handle = spawn(spec)
|
||||
handles.push(handle)
|
||||
return handle
|
||||
})
|
||||
await ctx.plugin(claudeCode, { env, disposeGraceMs: 3_000 })
|
||||
const parent = {
|
||||
id: 'real-parent',
|
||||
session: { header: { cwd: workspace } },
|
||||
} as unknown as Agent
|
||||
return {
|
||||
harness: { ctx, handles, parent, workspace, env },
|
||||
fixture,
|
||||
}
|
||||
}
|
||||
|
||||
async function expectQuiescent(
|
||||
handles: readonly SubprocessHandle[],
|
||||
): Promise<void> {
|
||||
expect(handles.length).toBeGreaterThan(0)
|
||||
for (const handle of handles) {
|
||||
await expect(handle.waitForExit()).resolves.toBe(true)
|
||||
const outcome = await handle.done
|
||||
expect(outcome).toHaveProperty('exitCode')
|
||||
expect(outcome).toHaveProperty('signal')
|
||||
}
|
||||
}
|
||||
|
||||
function startRequest(
|
||||
harness: RealHarness,
|
||||
prompt: string,
|
||||
signal = new AbortController().signal,
|
||||
) {
|
||||
return harness.ctx.subagents.start('claude-code', {
|
||||
prompt: [{ type: 'text', text: prompt }],
|
||||
parent: harness.parent,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', {
|
||||
timeout: 60_000,
|
||||
}, () => {
|
||||
it('inherits host settings and sends the exact task and fake key to local Messages', async () => {
|
||||
const sentinel = 'REAL_CLAUDE_CODE_SENTINEL_2_1_220'
|
||||
const task = 'Return the fixture sentinel exactly.'
|
||||
const { harness, fixture } = await realHarness([
|
||||
{ kind: 'complete', text: sentinel },
|
||||
])
|
||||
expect(sdkPackage.version).toBe('0.3.220')
|
||||
expect(sdkPackage.claudeCodeVersion).toBe('2.1.220')
|
||||
expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220')
|
||||
const version = await execFileAsync(claudeBin, ['--version'], {
|
||||
env: { ...process.env, ...harness.env },
|
||||
})
|
||||
expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)')
|
||||
|
||||
const run = await startRequest(harness, task)
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [{ type: 'text', text: sentinel }],
|
||||
stopReason: 'completed',
|
||||
})
|
||||
await run.dispose()
|
||||
|
||||
expect(fixture.requests).toHaveLength(1)
|
||||
const recorded = fixture.requests[0]!
|
||||
expect(recorded.method).toBe('POST')
|
||||
expect(recorded.path).toMatch(/^\/v1\/messages(?:\\?|$)/)
|
||||
expect(recorded.headers['x-api-key']).toBe(fakeKey)
|
||||
expect(recorded.body.model).toBe(settingsModel)
|
||||
expect(Array.isArray(recorded.body.messages)).toBe(true)
|
||||
const messageTexts = (
|
||||
recorded.body.messages as Array<{ content?: unknown }>
|
||||
).flatMap((message): unknown[] =>
|
||||
Array.isArray(message.content) ? message.content as unknown[] : [])
|
||||
.filter((block): block is { type: string; text: string } =>
|
||||
typeof block === 'object'
|
||||
&& block !== null
|
||||
&& 'type' in block
|
||||
&& block.type === 'text'
|
||||
&& 'text' in block
|
||||
&& typeof block.text === 'string')
|
||||
.map(block => block.text)
|
||||
expect(messageTexts.filter(text => text.includes(task))).toEqual([task])
|
||||
await expectQuiescent(harness.handles)
|
||||
})
|
||||
|
||||
it('maps a real CLI process failure to error', async () => {
|
||||
const { harness, fixture } = await realHarness([{ kind: 'hold' }])
|
||||
const run = await startRequest(harness, 'Exercise the failure path.')
|
||||
await fixture.requestStarted
|
||||
expect(harness.handles).toHaveLength(1)
|
||||
harness.handles[0]!.terminate()
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [],
|
||||
stopReason: 'error',
|
||||
})
|
||||
await run.dispose()
|
||||
expect(fixture.requests).toHaveLength(1)
|
||||
expect(fixture.requests[0]!.headers['x-api-key']).toBe(fakeKey)
|
||||
await expectQuiescent(harness.handles)
|
||||
})
|
||||
|
||||
it('settles cancellation and leaves the real SDK-spawned CLI tree quiescent', async () => {
|
||||
const { harness, fixture } = await realHarness([{ kind: 'hold' }])
|
||||
const controller = new AbortController()
|
||||
const run = await startRequest(
|
||||
harness,
|
||||
'Wait for cancellation.',
|
||||
controller.signal,
|
||||
)
|
||||
await fixture.requestStarted
|
||||
controller.abort(new Error('real product cancellation'))
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [],
|
||||
stopReason: 'aborted',
|
||||
})
|
||||
await run.dispose()
|
||||
await expectQuiescent(harness.handles)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user