2026-08-12 19:28:31 +08:00
import { readFileSync } from 'node:fs'
2026-08-13 18:45:16 +08:00
import { dirname , resolve } from 'node:path'
2026-08-04 20:57:55 +08:00
import { PassThrough } from 'node:stream'
2026-08-12 19:28:31 +08:00
import { fileURLToPath } from 'node:url'
2026-08-04 20:57:55 +08:00
import type {
2026-08-04 22:18:35 +08:00
Options ,
2026-08-04 20:57:55 +08:00
Query ,
SDKMessage ,
2026-08-15 16:36:32 +08:00
SDKPermissionDeniedMessage ,
2026-08-04 20:57:55 +08:00
SDKResultMessage ,
SpawnOptions ,
} from '@anthropic-ai/claude-agent-sdk'
build(vendor): rescope the vendored Cordis packages into @deepseek-ai
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it
prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`,
`verify-translation-pairing --write` for the touched bilingual pairs,
`gen-doc-graphs`, and one typert snapshot whose ids embed character offsets.
`pnpm run rescope-vendor --check` verifies the result.
Renames nine vendored packages (cordis, cosmokit, schemastery and the six
@cordisjs plugins) and every reference that resolves them: manifest names and
dependency keys, module specifiers including declare-module merges, cordis.yml
plugin names, tsconfig paths, every Markdown fence, and `docs/` prose.
Directory names, upstream versions, and dependency ranges are unchanged, so
vendor/README.md still reads as an upstream snapshot; its manifest table gains
an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed
at each fork's origin.
The tutorial tier follows the rename end to end: its yaml fences named plugins
the Loader can no longer resolve, its `ts ignore-check` fences disagreed with
the compiled fences beside them, and its prose quoted both. The contracts that
told readers to keep upstream names — the root convention and the vendoring
cookbook's tree comment and manifest invariant — now say to rescope instead.
Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle
purity gate now names the vendored libraries a browser bundle inlines, and the
files where a bare `cordis` is an agent-preset id keep that product data.
2026-08-10 22:04:06 +08:00
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
2026-08-12 19:28:31 +08:00
import * as yaml from 'js-yaml'
2026-08-04 22:18:35 +08:00
import {
afterEach ,
beforeEach ,
describe ,
expect ,
it ,
type Mock ,
vi ,
} from 'vitest'
2026-08-04 20:57:55 +08:00
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
2026-08-13 00:36:22 +08:00
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
2026-08-04 20:57:55 +08:00
import type {
SubprocessHandle ,
SubprocessOutcome ,
SubprocessSpawnSpec ,
} from '@deepseek-ai/dsh-subprocess'
2026-08-13 00:36:22 +08:00
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
2026-08-05 04:41:27 +08:00
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
2026-08-04 20:57:55 +08:00
import * as claudeCode from '../src/index.ts'
import * as invariant from '../src/invariant.ts'
import {
claudeSpawnSpec ,
ManagedClaudeCodeProcess ,
2026-08-05 01:07:58 +08:00
sdkEnvironmentOverlay ,
2026-08-04 20:57:55 +08:00
} from '../src/process.ts'
import {
2026-08-15 16:36:32 +08:00
CLAUDE_CODE_PERMISSION_MODES ,
DEFAULT_CLAUDE_CODE_PERMISSION_MODE ,
2026-08-04 20:57:55 +08:00
claudeQueryOptions ,
consumeClaudeQuery ,
disposeClaudeCodeChild ,
startClaudeCodeRun ,
successfulResult ,
textTask ,
type ClaudeCodeRunSpec ,
} from '../src/run.ts'
2026-08-04 22:18:35 +08:00
type QueryFactory = ( params : {
prompt : string
options : Options
} ) = > Query
const queryMock = vi . hoisted ( ( ) = > vi . fn < QueryFactory > ( ) )
2026-08-13 18:45:16 +08:00
const CLAUDE_AGENT_SDK_VERSION = '0.3.220'
const CLAUDE_CODE_VERSION = '2.1.220'
const CLAUDE_PLATFORM_PACKAGES = [
'@anthropic-ai/claude-agent-sdk-darwin-arm64' ,
'@anthropic-ai/claude-agent-sdk-darwin-x64' ,
'@anthropic-ai/claude-agent-sdk-linux-arm64' ,
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl' ,
'@anthropic-ai/claude-agent-sdk-linux-x64' ,
'@anthropic-ai/claude-agent-sdk-linux-x64-musl' ,
'@anthropic-ai/claude-agent-sdk-win32-arm64' ,
'@anthropic-ai/claude-agent-sdk-win32-x64' ,
] as const
2026-08-04 22:18:35 +08:00
vi . mock ( '@anthropic-ai/claude-agent-sdk' , async importOriginal = > ( {
. . . await importOriginal < typeof import ( '@anthropic-ai/claude-agent-sdk' ) > ( ) ,
query : queryMock ,
} ) )
2026-08-04 20:57:55 +08:00
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 ) } )
}
2026-08-18 05:37:14 +08:00
function errorCause ( value : unknown ) : Error | undefined {
return value instanceof Error && value . cause instanceof Error
? value . cause
: undefined
}
2026-08-04 20:57:55 +08:00
interface FakeChildOptions {
readonly pid? : number
readonly exitOnTerminate? : 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 ( 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 ,
2026-08-05 04:41:27 +08:00
stdin ,
stdout ,
2026-08-04 20:57:55 +08:00
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
}
2026-08-18 01:43:20 +08:00
function expectedFailureDiagnostic (
stage : 'query-start' | 'query-run' | 'process' | 'teardown' ,
category : string ,
outcome? : Partial < SubprocessOutcome > ,
) : string {
const fields = [
'product: Claude Code' ,
` stage: ${ stage } ` ,
` category: ${ category } ` ,
]
if ( outcome ? . exitCode !== null && outcome ? . exitCode !== undefined ) {
fields . push ( ` exit code: ${ outcome . exitCode } ` )
}
if ( outcome ? . signal !== null && outcome ? . signal !== undefined ) {
fields . push ( ` signal: ${ outcome . signal } ` )
}
return ` Product subagent failure ( ${ fields . join ( '; ' ) } ) `
}
2026-08-15 16:36:32 +08:00
function permissionDenied ( ) : SDKPermissionDeniedMessage {
return {
type : 'system' ,
subtype : 'permission_denied' ,
tool_name : 'Bash' ,
tool_use_id : 'tool-secret' ,
decision_reason_type : 'mode' ,
decision_reason : 'contains /private/secret.txt' ,
message : 'command with SECRET_TOKEN was denied' ,
uuid : '00000000-0000-4000-8000-000000000001' ,
session_id : 'session-secret' ,
}
}
2026-08-04 20:57:55 +08:00
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 close : ReturnType < typeof vi.fn >
readonly spawnSpecs : SubprocessSpawnSpec [ ]
2026-08-04 22:18:35 +08:00
readonly options : Options [ ]
2026-08-04 20:57:55 +08:00
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' ,
2026-08-15 16:36:32 +08:00
permissionMode : DEFAULT_CLAUDE_CODE_PERMISSION_MODE ,
2026-08-04 20:57:55 +08:00
env : { ANTHROPIC_API_KEY : 'fake-key' } ,
disposeGraceMs : 5 ,
spawn : ( spawnSpec ) = > {
spawnSpecs . push ( spawnSpec )
return child . handle
} ,
}
2026-08-04 22:18:35 +08:00
queryMock . mockImplementation ( ( params ) = > {
options . push ( params . options )
params . options . spawnClaudeCodeProcess ! ( sdkSpawnOptions ( ) )
return query
} )
2026-08-05 04:41:27 +08:00
return { child , close , spawnSpecs , options , spec }
2026-08-04 20:57:55 +08:00
}
2026-08-04 22:18:35 +08:00
beforeEach ( ( ) = > {
queryMock . mockImplementation ( ( { options } ) = > {
options . spawnClaudeCodeProcess ! ( sdkSpawnOptions ( {
cwd : options.cwd ! ,
env : options.env ! ,
signal : options.abortController ! . signal ,
} ) )
return queryFrom ( [ ] )
} )
} )
2026-08-04 20:57:55 +08:00
afterEach ( ( ) = > {
2026-08-04 22:18:35 +08:00
queryMock . mockReset ( )
2026-08-04 20:57:55 +08:00
vi . restoreAllMocks ( )
vi . unstubAllEnvs ( )
} )
describe ( 'task admission and package contracts' , ( ) = > {
2026-08-12 19:28:31 +08:00
it ( 'ships one independently installable provider-only Bundle patch' , ( ) = > {
const root = fileURLToPath ( new URL ( '..' , import . meta . url ) )
const manifest = JSON . parse ( readFileSync ( resolve ( root , 'package.json' ) , 'utf8' ) ) as {
dependencies? : Record < string , string >
files? : string [ ]
dsh ? : { bundle ? : { patch? : string } }
}
expect ( manifest . dsh ? . bundle ? . patch ) . toBe ( './cordis.patch.yml' )
expect ( manifest . files ) . toContain ( 'cordis.patch.yml' )
2026-08-13 18:45:16 +08:00
expect ( manifest . dependencies ) . toHaveProperty (
'@anthropic-ai/claude-agent-sdk' ,
CLAUDE_AGENT_SDK_VERSION ,
)
2026-08-13 20:30:36 +08:00
expect ( manifest . dependencies ) . toHaveProperty (
'@modelcontextprotocol/sdk' ,
'^1.29.0' ,
)
expect ( manifest . dependencies ) . toHaveProperty ( 'zod' , '^4.4.3' )
2026-08-12 19:28:31 +08:00
expect ( manifest . dependencies ) . not . toHaveProperty ( '@deepseek-ai/dsh-subagent-codex' )
2026-08-13 18:45:16 +08:00
const sdkRoot = dirname ( fileURLToPath (
import . meta . resolve ( '@anthropic-ai/claude-agent-sdk' ) ,
) )
const sdkManifest = JSON . parse ( readFileSync (
resolve ( sdkRoot , 'package.json' ) ,
'utf8' ,
) ) as {
version : string
claudeCodeVersion : string
optionalDependencies : Record < string , string >
}
expect ( sdkManifest . version ) . toBe ( CLAUDE_AGENT_SDK_VERSION )
expect ( sdkManifest . claudeCodeVersion ) . toBe ( CLAUDE_CODE_VERSION )
expect ( sdkManifest . optionalDependencies ) . toEqual ( Object . fromEntries (
CLAUDE_PLATFORM_PACKAGES . map ( packageName = > [
packageName ,
CLAUDE_AGENT_SDK_VERSION ,
] ) ,
) )
const lockfile = readFileSync ( resolve ( root , '../../../pnpm-lock.yaml' ) , 'utf8' )
for ( const packageName of CLAUDE_PLATFORM_PACKAGES ) {
expect ( lockfile ) . toContain (
` ' ${ packageName } @ ${ CLAUDE_AGENT_SDK_VERSION } ': ` ,
)
expect ( lockfile ) . toContain (
` ' ${ packageName } ': ${ CLAUDE_AGENT_SDK_VERSION } ` ,
)
}
2026-08-12 19:28:31 +08:00
const parsed = yaml . load ( readFileSync ( resolve ( root , manifest . dsh ! . bundle ! . patch ! ) , 'utf8' ) )
const rows = Array . isArray ( parsed )
? ( parsed as Array < { insert? : Array < { id? : string ; name? : string } > } > ) . flatMap ( entry = > entry . insert ? ? [ ] )
: [ ]
expect ( rows ) . toEqual ( [ {
id : 'subagent-claude-code' ,
name : '@deepseek-ai/dsh-subagent-claude-code' ,
} ] )
expect ( JSON . stringify ( rows ) ) . not . toContain ( 'tool-subagent' )
} )
2026-08-04 20:57:55 +08:00
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' )
} )
2026-08-18 01:16:29 +08:00
it ( 'registers the default descriptor, validates config, and unregisters on HMR' , async ( ) = > {
2026-08-04 20:57:55 +08:00
const ctx = new Context ( )
2026-08-13 00:36:22 +08:00
await ctx . plugin ( SubagentRuntime )
await ctx . plugin ( LocalSubprocessRuntime )
2026-08-04 20:57:55 +08:00
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' )
}
2026-08-05 04:41:27 +08:00
await expect ( ctx . plugin ( claudeCode , {
disposeGraceMs : MAX_TIMER_DELAY_MS + 1 ,
} ) ) . rejects . toThrow (
` disposeGraceMs must be no greater than ${ MAX_TIMER_DELAY_MS } ` ,
)
2026-08-04 20:57:55 +08:00
await ctx . fiber . dispose ( )
} )
2026-08-18 01:16:29 +08:00
it ( 'keeps named instances, runs, and HMR ownership isolated' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( SubagentRuntime )
await ctx . plugin ( LocalSubprocessRuntime )
const safeChild = fakeChild ( )
const bypassChild = fakeChild ( )
const spawnSpecs : SubprocessSpawnSpec [ ] = [ ]
vi . spyOn ( ctx . subprocess , 'spawn' ) . mockImplementation ( ( spec ) = > {
spawnSpecs . push ( spec )
return spec . env ? . DSH_CLAUDE_INSTANCE === 'safe'
? safeChild . handle
: bypassChild . handle
} )
const queryOptions : Options [ ] = [ ]
queryMock . mockImplementation ( ( { options } ) = > {
queryOptions . push ( options )
options . spawnClaudeCodeProcess ! ( sdkSpawnOptions ( {
cwd : options.cwd ! ,
env : options.env ! ,
signal : options.abortController ! . signal ,
} ) )
return options . permissionMode === 'dontAsk'
? waitingQuery ( options . abortController ! . signal )
: queryFrom ( [ success ( 'bypass answer' ) ] )
} )
const added : string [ ] = [ ]
const started : string [ ] = [ ]
const ended : string [ ] = [ ]
const removed : string [ ] = [ ]
ctx . on ( 'subagent/provider-added' , provider = > void added . push ( provider . name ) )
ctx . on ( 'subagent/start' , info = > void started . push ( info . provider ) )
ctx . on ( 'subagent/end' , info = > void ended . push ( info . provider ) )
ctx . on ( 'subagent/provider-removed' , providerName = > void removed . push ( providerName ) )
const safeFiber = await ctx . plugin ( claudeCode , {
providerName : 'claude-safe' ,
env : { DSH_CLAUDE_INSTANCE : 'safe' } ,
permissionMode : 'dontAsk' ,
disposeGraceMs : 11 ,
} )
const bypassFiber = await ctx . plugin ( claudeCode , {
providerName : 'claude-bypass' ,
env : { DSH_CLAUDE_INSTANCE : 'bypass' } ,
permissionMode : 'bypassPermissions' ,
disposeGraceMs : 29 ,
} )
expect ( ctx . subagents . list ( ) ) . toEqual ( [ 'claude-safe' , 'claude-bypass' ] )
expect ( added ) . toEqual ( [ 'claude-safe' , 'claude-bypass' ] )
const safeController = new AbortController ( )
const [ safeRun , bypassRun ] = await Promise . all ( [
ctx . subagents . start ( 'claude-safe' , request ( undefined , safeController . signal ) ) ,
ctx . subagents . start ( 'claude-bypass' , request ( ) ) ,
] )
await safeFiber . dispose ( )
expect ( ctx . subagents . list ( ) ) . toEqual ( [ 'claude-bypass' ] )
expect ( removed ) . toEqual ( [ 'claude-safe' ] )
await expect ( ctx . subagents . start ( 'claude-safe' , request ( ) ) )
. rejects . toMatchObject ( { code : 'NO_PROVIDER' } )
await expect ( bypassRun . result ) . resolves . toEqual ( {
output : [ { type : 'text' , text : 'bypass answer' } ] ,
stopReason : 'completed' ,
} )
safeController . abort ( new Error ( 'stop only the safe instance' ) )
await expect ( safeRun . result ) . resolves . toEqual ( {
output : [ ] ,
stopReason : 'aborted' ,
} )
expect ( queryOptions . map ( options = > ( {
instance : options.env?.DSH_CLAUDE_INSTANCE ,
permissionMode : options.permissionMode ,
} ) ) ) . toEqual ( [
{ instance : 'safe' , permissionMode : 'dontAsk' } ,
{ instance : 'bypass' , permissionMode : 'bypassPermissions' } ,
] )
expect ( spawnSpecs . map ( spec = > ( {
instance : spec.env?.DSH_CLAUDE_INSTANCE ,
graceMs : spec.graceMs ,
} ) ) ) . toEqual ( [
{ instance : 'safe' , graceMs : 11 } ,
{ instance : 'bypass' , graceMs : 29 } ,
] )
await Promise . all ( [ safeRun . dispose ( ) , bypassRun . dispose ( ) ] )
expect ( [ . . . started ] . sort ( ) ) . toEqual ( [ 'claude-bypass' , 'claude-safe' ] )
expect ( [ . . . ended ] . sort ( ) ) . toEqual ( [ 'claude-bypass' , 'claude-safe' ] )
expect ( safeChild . terminate ) . toHaveBeenCalledOnce ( )
expect ( bypassChild . terminate ) . toHaveBeenCalledOnce ( )
await bypassFiber . dispose ( )
expect ( removed ) . toEqual ( [ 'claude-safe' , 'claude-bypass' ] )
await ctx . fiber . dispose ( )
} )
it ( 'rejects duplicate provider names without replacing the first instance' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( SubagentRuntime )
await ctx . plugin ( LocalSubprocessRuntime )
const firstFiber = await ctx . plugin ( claudeCode , {
providerName : 'claude-duplicate' ,
} )
const first = ctx . subagents . getProvider ( 'claude-duplicate' )
await expect ( ctx . plugin ( claudeCode , {
providerName : 'claude-duplicate' ,
permissionMode : 'bypassPermissions' ,
} ) ) . rejects . toMatchObject ( { code : 'DUPLICATE_PROVIDER' } )
expect ( ctx . subagents . getProvider ( 'claude-duplicate' ) ) . toBe ( first )
expect ( ctx . subagents . list ( ) ) . toEqual ( [ 'claude-duplicate' ] )
await firstFiber . dispose ( )
await ctx . fiber . dispose ( )
} )
2026-08-15 16:36:32 +08:00
it ( 'accepts only the five fixed non-interactive permission modes' , ( ) = > {
2026-08-18 01:16:29 +08:00
expect ( claudeCode . Config ( { } ) . providerName ) . toBe ( 'claude-code' )
expect ( claudeCode . Config ( { providerName : 'claude-safe' } ) . providerName )
. toBe ( 'claude-safe' )
expect ( ( ) = > claudeCode . Config ( { providerName : '' } ) ) . toThrow ( )
2026-08-15 16:36:32 +08:00
expect ( claudeCode . Config ( { } ) . permissionMode )
. toBe ( DEFAULT_CLAUDE_CODE_PERMISSION_MODE )
for ( const permissionMode of CLAUDE_CODE_PERMISSION_MODES ) {
expect ( claudeCode . Config ( { permissionMode } ) . permissionMode )
. toBe ( permissionMode )
}
for ( const permissionMode of [ 'default' , 'interactive' , 'future-mode' ] ) {
expect ( ( ) = > claudeCode . Config ( { permissionMode } as never ) ) . toThrow ( )
}
} )
it ( 'resolves the safe permission default when apply is called directly' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( SubagentRuntime )
await ctx . plugin ( LocalSubprocessRuntime )
claudeCode . apply ( ctx , { env : { } , disposeGraceMs : 3_000 } )
expect ( ctx . subagents . getProvider ( 'claude-code' ) ) . toBeDefined ( )
await ctx . fiber . dispose ( )
} )
2026-08-04 20:57:55 +08:00
it ( 'starts through the registered provider with its resolved config and diagnostics' , async ( ) = > {
const ctx = new Context ( )
2026-08-13 00:36:22 +08:00
await ctx . plugin ( SubagentRuntime )
await ctx . plugin ( LocalSubprocessRuntime )
2026-08-04 20:57:55 +08:00
const child = fakeChild ( )
const spawn = vi . spyOn ( ctx . subprocess , 'spawn' )
. mockImplementation ( ( ) = > child . handle )
2026-08-10 12:45:05 +08:00
const resolveExecutable = vi . spyOn ( ctx . subprocess , 'resolveExecutable' )
2026-08-13 18:45:16 +08:00
. mockResolvedValue ( '/host/bin/claude' )
2026-08-04 20:57:55 +08:00
const warn = vi . spyOn ( ctx . logger , 'warn' ) . mockImplementation ( ( ) = > { } )
await ctx . plugin ( claudeCode , {
2026-08-18 01:16:29 +08:00
providerName : 'claude-diagnostic' ,
2026-08-04 20:57:55 +08:00
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' ,
} ,
2026-08-15 16:36:32 +08:00
permissionMode : 'auto' ,
2026-08-04 20:57:55 +08:00
disposeGraceMs : 29 ,
} )
2026-08-18 01:16:29 +08:00
await expect ( ctx . subagents . start ( 'claude-diagnostic' , {
2026-08-05 07:32:27 +08:00
. . . request ( ) ,
parent : {
id : 'parent-without-cwd' ,
session : { header : { } } ,
} as unknown as Agent ,
} ) ) . rejects . toThrow (
'subagent-claude-code: no working directory for the child — delegate from a parent session that has one' ,
)
expect ( queryMock ) . not . toHaveBeenCalled ( )
2026-08-18 20:43:04 +08:00
const invalidCwdParent = {
id : 'parent-with-invalid-cwd' ,
session : { header : { cwd : 'relative/SECRET_TOKEN' } } ,
} as unknown as Agent
const invalidCwd = ctx . subagents . start ( 'claude-diagnostic' , {
. . . request ( ) ,
parent : invalidCwdParent ,
} )
await expect ( invalidCwd )
2026-08-18 01:43:20 +08:00
. rejects . toThrow ( expectedFailureDiagnostic ( 'query-start' , 'unknown' ) )
2026-08-18 20:43:04 +08:00
await expect ( invalidCwd ) . rejects . not . toThrow ( 'relative/SECRET_TOKEN' )
2026-08-18 05:17:47 +08:00
expect ( warn ) . toHaveBeenCalledWith (
2026-08-18 19:36:16 +08:00
'subagent-claude-code "claude-diagnostic": child start failed: %o' ,
2026-08-18 05:17:47 +08:00
expect . any ( Error ) ,
)
2026-08-18 05:37:14 +08:00
expect ( errorCause ( warn . mock . calls [ 0 ] ? . [ 1 ] as unknown ) ? . message )
2026-08-18 20:43:04 +08:00
. toContain ( 'relative/SECRET_TOKEN' )
const invalidCwdAbort = new AbortController ( )
invalidCwdAbort . abort ( new Error ( 'cancel invalid cwd startup' ) )
await expect ( ctx . subagents . start ( 'claude-diagnostic' , {
. . . request ( undefined , invalidCwdAbort . signal ) ,
parent : invalidCwdParent ,
} ) ) . rejects . toThrow ( 'aborted before SDK startup' )
2026-08-18 01:43:20 +08:00
expect ( queryMock ) . not . toHaveBeenCalled ( )
2026-08-18 20:43:04 +08:00
warn . mockClear ( )
2026-08-18 01:43:20 +08:00
2026-08-13 18:45:16 +08:00
vi . stubEnv ( 'PATH' , '/host/bin' )
queryMock . mockImplementationOnce ( ( ) = > {
throw new Error (
'Native CLI binary for fixture-platform not found. Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set options.pathToClaudeCodeExecutable.' ,
)
2026-08-18 01:43:20 +08:00
} )
2026-08-18 20:43:04 +08:00
const missingPayload = ctx . subagents . start ( 'claude-diagnostic' , request ( ) )
await expect ( missingPayload )
. rejects . toThrow ( expectedFailureDiagnostic ( 'query-start' , 'unknown' ) )
await expect ( missingPayload ) . rejects . not . toThrow ( 'Native CLI binary' )
expect ( warn ) . toHaveBeenCalledWith (
expect . stringContaining (
'subagent-claude-code "claude-diagnostic": child run failed (error):' ,
) ,
expect . any ( Error ) ,
)
expect ( errorCause ( warn . mock . calls [ 0 ] ? . [ 1 ] as unknown ) ? . message )
. toContain ( 'Native CLI binary for fixture-platform not found' )
2026-08-13 18:45:16 +08:00
expect ( resolveExecutable ) . not . toHaveBeenCalled ( )
2026-08-10 12:45:05 +08:00
2026-08-18 01:16:29 +08:00
const run = await ctx . subagents . start ( 'claude-diagnostic' , request ( ) )
2026-08-04 20:57:55 +08:00
child . settle ( { exitCode : 9 , signal : null } )
child . stdout . end ( )
await expect ( run . result ) . resolves . toEqual ( {
output : [ ] ,
2026-08-18 01:43:20 +08:00
diagnostic : expectedFailureDiagnostic ( 'query-run' , 'missing-result' ) ,
2026-08-04 20:57:55 +08:00
stopReason : 'error' ,
} )
2026-08-18 01:43:20 +08:00
expect ( warn ) . toHaveBeenCalledWith (
2026-08-18 19:36:16 +08:00
expect . stringContaining (
'subagent-claude-code "claude-diagnostic": child run failed (error):' ,
) ,
2026-08-18 01:43:20 +08:00
expect . any ( Error ) ,
)
2026-08-13 18:45:16 +08:00
expect ( resolveExecutable ) . not . toHaveBeenCalled ( )
expect ( queryMock . mock . calls [ 1 ] ? . [ 0 ] . options )
. not . toHaveProperty ( 'pathToClaudeCodeExecutable' )
2026-08-18 18:11:42 +08:00
expect ( queryMock . mock . calls [ 1 ] ? . [ 0 ] . options . permissionMode ) . toBe ( 'auto' )
2026-08-04 20:57:55 +08:00
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' , ( ) = > {
2026-08-05 01:07:58 +08:00
vi . stubEnv ( 'SDK_REMOVED_AMBIENT' , 'ambient-value' )
2026-08-04 20:57:55 +08:00
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 ,
} )
2026-08-05 01:07:58 +08:00
expect ( sdkEnvironmentOverlay ( options . env ) ) . toEqual ( expect . objectContaining ( {
A : 'one' ,
B : undefined ,
C : 'three' ,
SDK_REMOVED_AMBIENT : undefined ,
} ) )
const spawnSpec = claudeSpawnSpec ( options , 321 )
expect ( spawnSpec ) . toMatchObject ( {
2026-08-04 20:57:55 +08:00
argv : [ '/official/claude' , '--one' , 'two' ] ,
cwd : '/parent/workspace' ,
stdio : { stdin : 'pipe' , stdout : 'pipe' , stderr : 'inherit' } ,
graceMs : 321 ,
signal ,
} )
2026-08-05 01:07:58 +08:00
expect ( spawnSpec . env ) . toEqual ( expect . objectContaining ( {
A : 'one' ,
B : undefined ,
C : 'three' ,
SDK_REMOVED_AMBIENT : undefined ,
} ) )
2026-08-04 20:57:55 +08:00
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' )
} )
2026-08-13 18:45:16 +08:00
it ( 'forwards the SDK-selected Windows native executable without a batch shim' , ( ) = > {
const command = String . raw ` C: \ Program Files \ Claude \ claude.exe `
2026-08-10 12:45:05 +08:00
const spec = claudeSpawnSpec ( sdkSpawnOptions ( {
command ,
args : [ '--output-format' , 'stream-json' ] ,
2026-08-13 18:45:16 +08:00
} ) , 7 )
2026-08-10 12:45:05 +08:00
expect ( spec . argv ) . toEqual ( [
2026-08-13 18:45:16 +08:00
command , '--output-format' , 'stream-json' ,
2026-08-10 12:45:05 +08:00
] )
} )
2026-08-04 20:57:55 +08:00
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 ( )
2026-08-18 02:45:42 +08:00
expect ( process . outcome ) . toBeUndefined ( )
2026-08-04 20:57:55 +08:00
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' )
2026-08-18 02:45:42 +08:00
expect ( process . outcome ) . toEqual ( { exitCode : null , signal : 'SIGTERM' } )
2026-08-04 20:57:55 +08:00
expect ( process . kill ( 'SIGTERM' ) ) . toBe ( false )
} )
2026-08-05 00:26:39 +08:00
it ( 'emits spawn errors' , async ( ) = > {
2026-08-18 21:41:06 +08:00
const child = fakeChild ( { pid : - 1 } )
2026-08-04 20:57:55 +08:00
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 ( )
} )
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 ( )
2026-08-18 02:45:42 +08:00
expect ( process . outcome ) . toEqual ( { exitCode : 7 , signal : null } )
2026-08-04 20:57:55 +08:00
expect ( process . kill ( 'SIGTERM' ) ) . toBe ( false )
} )
} )
describe ( 'query options and result mapping' , ( ) = > {
2026-08-15 16:36:32 +08:00
it ( 'builds the fixed unattended options over the scrubbed environment' , async ( ) = > {
2026-08-04 20:57:55 +08:00
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 [ ] = [ ]
2026-08-15 16:36:32 +08:00
const diagnostics : string [ ] = [ ]
2026-08-04 20:57:55 +08:00
const spec : ClaudeCodeRunSpec = {
cwd : '/workspace' ,
2026-08-15 16:36:32 +08:00
permissionMode : 'acceptEdits' ,
2026-08-04 20:57:55 +08:00
env : {
HOST_VISIBLE : 'overridden' ,
ANTHROPIC_API_KEY : 'explicit-fake-key' ,
} ,
disposeGraceMs : 17 ,
spawn ,
}
const controller = new AbortController ( )
2026-08-15 16:36:32 +08:00
const options = claudeQueryOptions (
spec ,
controller ,
( value ) = > {
captured . push ( value )
} ,
value = > diagnostics . push ( value ) ,
)
2026-08-04 20:57:55 +08:00
expect ( options ) . toMatchObject ( {
abortController : controller ,
cwd : '/workspace' ,
persistSession : false ,
disallowedTools : [ 'AskUserQuestion' ] ,
2026-08-15 16:36:32 +08:00
permissionMode : 'acceptEdits' ,
supportedDialogKinds : [ 'refusal_fallback_prompt' ] ,
2026-08-04 20:57:55 +08:00
} )
2026-08-13 18:45:16 +08:00
expect ( options ) . not . toHaveProperty ( 'pathToClaudeCodeExecutable' )
2026-08-15 16:36:32 +08:00
expect ( options ) . not . toHaveProperty ( 'allowDangerouslySkipPermissions' )
2026-08-04 20:57:55 +08:00
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' )
2026-08-15 16:36:32 +08:00
expect ( options ) . not . toHaveProperty ( 'settingSources' )
const callbackSignal = new AbortController ( ) . signal
await expect ( options . canUseTool ! (
'Bash' ,
{ command : 'cat /private/secret.txt' , token : 'SECRET_TOKEN' } ,
{
signal : callbackSignal ,
toolUseID : 'tool-1' ,
requestId : 'request-1' ,
blockedPath : '/private/secret.txt' ,
decisionReason : 'SECRET_TOKEN in /private/secret.txt' ,
} ,
) ) . resolves . toEqual ( {
behavior : 'deny' ,
message : 'This unattended Claude Code subagent cannot request human approval.' ,
} )
await expect ( options . onElicitation ! (
{
serverName : 'private-server' ,
message : 'enter SECRET_TOKEN' ,
requestedSchema : { secret : true } ,
} ,
{ signal : callbackSignal } ,
) ) . resolves . toEqual ( { action : 'decline' } )
await expect ( options . onUserDialog ! (
{
dialogKind : 'refusal_fallback_prompt' ,
payload : { path : '/private/secret.txt' , token : 'SECRET_TOKEN' } ,
} ,
{ signal : callbackSignal } ,
) ) . resolves . toEqual ( { behavior : 'cancelled' } )
expect ( diagnostics ) . toEqual ( [
'Claude Code unattended decision (mode: acceptEdits; request: tool permission; decision: denied): the provider does not request human approval' ,
'Claude Code unattended decision (mode: acceptEdits; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input' ,
'Claude Code unattended decision (mode: acceptEdits; request: user dialog; decision: cancelled): the provider does not render blocking dialogs' ,
] )
expect ( diagnostics . join ( '\n' ) ) . not . toContain ( 'SECRET_TOKEN' )
expect ( diagnostics . join ( '\n' ) ) . not . toContain ( '/private/secret.txt' )
2026-08-04 20:57:55 +08:00
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 ,
} ) )
} )
2026-08-15 16:36:32 +08:00
it . each ( CLAUDE_CODE_PERMISSION_MODES ) (
'maps the %s mode and only confirms the dangerous bypass' ,
( permissionMode ) = > {
const child = fakeChild ( )
const options = claudeQueryOptions ( {
cwd : '/workspace' ,
permissionMode ,
env : { } ,
disposeGraceMs : 17 ,
spawn : ( ) = > child . handle ,
} , new AbortController ( ) , ( ) = > { } , ( ) = > { } )
expect ( options . permissionMode ) . toBe ( permissionMode )
2026-08-15 18:15:53 +08:00
expect ( options . disallowedTools ) . toEqual ( permissionMode === 'plan'
? [ 'AskUserQuestion' , 'ExitPlanMode' ]
: [ 'AskUserQuestion' ] )
2026-08-15 16:36:32 +08:00
if ( permissionMode === 'bypassPermissions' ) {
expect ( options . allowDangerouslySkipPermissions ) . toBe ( true )
expect ( options ) . not . toHaveProperty ( 'canUseTool' )
} else {
expect ( options ) . not . toHaveProperty ( 'allowDangerouslySkipPermissions' )
expect ( options . canUseTool ) . toBeTypeOf ( 'function' )
}
} ,
)
2026-08-15 18:15:53 +08:00
it ( 'disallows ExitPlanMode before native plan-mode allow rules' , ( ) = > {
2026-08-15 17:46:13 +08:00
const child = fakeChild ( )
const options = claudeQueryOptions ( {
cwd : '/workspace' ,
permissionMode : 'plan' ,
env : { } ,
disposeGraceMs : 17 ,
spawn : ( ) = > child . handle ,
2026-08-15 18:15:53 +08:00
} , new AbortController ( ) , ( ) = > { } , ( ) = > { } )
expect ( options . disallowedTools ) . toEqual ( [
'AskUserQuestion' ,
2026-08-15 17:46:13 +08:00
'ExitPlanMode' ,
] )
} )
2026-08-04 20:57:55 +08:00
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 ) ) )
2026-08-18 01:43:20 +08:00
. toThrow ( expectedFailureDiagnostic ( 'query-run' , 'invalid-success' ) )
2026-08-04 20:57:55 +08:00
expect ( ( ) = > successfulResult ( success ( ' \n ' ) ) )
2026-08-18 01:43:20 +08:00
. toThrow ( expectedFailureDiagnostic ( 'query-run' , 'invalid-success' ) )
const sdkFailure = ( ) = > successfulResult ( failure (
2026-08-04 20:57:55 +08:00
'error_during_execution' ,
2026-08-18 01:43:20 +08:00
[ 'SECRET_TOKEN' , '/private/secret.txt' ] ,
) )
expect ( sdkFailure ) . toThrow ( expectedFailureDiagnostic (
'query-run' ,
'error_during_execution' ,
) )
expect ( sdkFailure ) . not . toThrow ( 'SECRET_TOKEN' )
expect ( sdkFailure ) . not . toThrow ( '/private/secret.txt' )
2026-08-04 20:57:55 +08:00
expect ( ( ) = > successfulResult ( failure (
'error_max_turns' ,
[ ] ,
2026-08-18 01:43:20 +08:00
) ) ) . toThrow ( expectedFailureDiagnostic ( 'query-run' , 'error_max_turns' ) )
const unknown = {
type : 'result' ,
subtype : 'future_failure' ,
is_error : true ,
errors : [ 'SECRET_TOKEN' ] ,
} as unknown as SDKResultMessage
expect ( ( ) = > successfulResult ( unknown ) )
. toThrow ( expectedFailureDiagnostic ( 'query-run' , 'unknown' ) )
expect ( ( ) = > successfulResult ( unknown ) ) . not . toThrow ( 'future_failure' )
expect ( ( ) = > successfulResult ( unknown ) ) . not . toThrow ( 'SECRET_TOKEN' )
2026-08-04 20:57:55 +08:00
} )
it ( 'consumes the complete stream and keeps the latest strict success' , async ( ) = > {
const query = queryFrom ( [
{ type : 'system' , subtype : 'init' } as SDKMessage ,
success ( 'first' ) ,
success ( 'last' ) ,
] )
2026-08-04 22:18:35 +08:00
await expect ( consumeClaudeQuery ( query ) ) . resolves . toEqual ( {
2026-08-04 20:57:55 +08:00
output : [ { type : 'text' , text : 'last' } ] ,
stopReason : 'completed' ,
} )
await expect ( consumeClaudeQuery (
queryFrom ( [ { type : 'system' , subtype : 'init' } as SDKMessage ] ) ,
2026-08-18 01:43:20 +08:00
) ) . rejects . toThrow ( expectedFailureDiagnostic ( 'query-run' , 'missing-result' ) )
2026-08-15 16:36:32 +08:00
const onPermissionDenied = vi . fn ( )
await expect ( consumeClaudeQuery ( queryFrom ( [
permissionDenied ( ) ,
success ( 'after denial' ) ,
] ) , onPermissionDenied ) ) . resolves . toEqual ( {
output : [ { type : 'text' , text : 'after denial' } ] ,
stopReason : 'completed' ,
} )
expect ( onPermissionDenied ) . toHaveBeenCalledOnce ( )
2026-08-04 20:57:55 +08:00
} )
} )
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 : [ ] ,
2026-08-18 01:43:20 +08:00
diagnostic : expectedFailureDiagnostic ( 'query-run' , subtype ) ,
2026-08-04 20:57:55 +08:00
stopReason : 'error' ,
} )
expect ( onError ) . toHaveBeenCalledWith (
expect . any ( Error ) ,
'error' ,
)
await run . dispose ( )
}
} )
2026-08-15 16:36:32 +08:00
it ( 'attaches a safe diagnostic when a permission denial precedes failure' , async ( ) = > {
const fixture = fakeRun ( [
permissionDenied ( ) ,
failure ( 'error_during_execution' ) ,
] )
const run = await startClaudeCodeRun ( request ( ) , fixture . spec )
const result = await run . result
expect ( result ) . toEqual ( {
output : [ ] ,
2026-08-18 01:43:20 +08:00
diagnostic : ` ${ expectedFailureDiagnostic ( 'query-run' , 'error_during_execution' ) } \ nClaude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt ` ,
2026-08-15 16:36:32 +08:00
stopReason : 'error' ,
} )
expect ( result . diagnostic ) . not . toContain ( 'SECRET_TOKEN' )
expect ( result . diagnostic ) . not . toContain ( '/private/secret.txt' )
await run . dispose ( )
} )
it ( 'omits captured diagnostics on success and isolates concurrent runs' , async ( ) = > {
const children = [ fakeChild ( ) , fakeChild ( ) ]
let childIndex = 0
const spec : ClaudeCodeRunSpec = {
cwd : '/workspace' ,
permissionMode : 'dontAsk' ,
env : { } ,
disposeGraceMs : 5 ,
spawn : ( ) = > children [ childIndex ++ ] ! . handle ,
}
queryMock . mockImplementation ( ( { prompt , options } ) = > {
options . spawnClaudeCodeProcess ! ( sdkSpawnOptions ( ) )
return prompt === 'denied then completed'
? queryFrom ( [ permissionDenied ( ) , success ( 'completed answer' ) ] )
: queryFrom ( [ failure ( 'error_during_execution' ) ] )
} )
const [ completed , failed ] = await Promise . all ( [
startClaudeCodeRun (
request ( [ { type : 'text' , text : 'denied then completed' } ] ) ,
spec ,
) ,
startClaudeCodeRun (
request ( [ { type : 'text' , text : 'unrelated failure' } ] ) ,
spec ,
) ,
] )
await expect ( completed . result ) . resolves . toEqual ( {
output : [ { type : 'text' , text : 'completed answer' } ] ,
stopReason : 'completed' ,
} )
await expect ( failed . result ) . resolves . toEqual ( {
output : [ ] ,
2026-08-18 01:43:20 +08:00
diagnostic : expectedFailureDiagnostic (
'query-run' ,
'error_during_execution' ,
) ,
2026-08-15 16:36:32 +08:00
stopReason : 'error' ,
} )
await Promise . all ( [ completed . dispose ( ) , failed . dispose ( ) ] )
} )
2026-08-04 22:18:35 +08:00
it ( 'fails closed when iteration rejects after a result' , async ( ) = > {
2026-08-18 05:17:47 +08:00
const child = fakeChild ( )
const outcome = { exitCode : 31 , signal : null } as const
async function * stream ( ) : AsyncGenerator < SDKMessage , void > {
yield success ( 'partial final' )
child . settle ( outcome )
await Promise . resolve ( )
throw new Error ( 'iterator boom' )
}
queryMock . mockImplementation ( ( { options } ) = > {
options . spawnClaudeCodeProcess ! ( sdkSpawnOptions ( ) )
return Object . assign ( stream ( ) , { close : vi.fn ( ) } ) as unknown as Query
} )
const run = await startClaudeCodeRun ( request ( ) , {
cwd : '/workspace' ,
permissionMode : DEFAULT_CLAUDE_CODE_PERMISSION_MODE ,
env : { } ,
disposeGraceMs : 5 ,
spawn : ( ) = > child . handle ,
} )
2026-08-04 20:57:55 +08:00
await expect ( run . result ) . resolves . toEqual ( {
2026-08-04 22:18:35 +08:00
output : [ ] ,
2026-08-18 05:17:47 +08:00
diagnostic : expectedFailureDiagnostic ( 'query-run' , 'unknown' , outcome ) ,
2026-08-04 20:57:55 +08:00
stopReason : 'error' ,
} )
await run . dispose ( )
} )
2026-08-18 01:43:20 +08:00
it ( 'maps invalid success and missing result to fixed query-run facts' , async ( ) = > {
for ( const [ messages , category ] of [
[ [ success ( 'answer' , true ) ] , 'invalid-success' ] ,
[ [ success ( '' ) ] , 'invalid-success' ] ,
[ [ { type : 'system' , subtype : 'init' } as SDKMessage ] , 'missing-result' ] ,
] as const ) {
2026-08-04 20:57:55 +08:00
const fixture = fakeRun ( messages )
const run = await startClaudeCodeRun ( request ( ) , fixture . spec )
2026-08-18 01:43:20 +08:00
await expect ( run . result ) . resolves . toEqual ( {
output : [ ] ,
diagnostic : expectedFailureDiagnostic ( 'query-run' , category ) ,
2026-08-04 20:57:55 +08:00
stopReason : 'error' ,
} )
await run . dispose ( )
}
} )
2026-08-18 01:43:20 +08:00
it ( 'reports an early process exit with independent code and signal facts' , async ( ) = > {
const outcomes : SubprocessOutcome [ ] = [
{ exitCode : 23 , signal : null } ,
{ exitCode : null , signal : 'SIGABRT' } ,
{ exitCode : null , signal : null } ,
]
for ( const outcome of outcomes ) {
const child = fakeChild ( )
async function * stream ( ) : AsyncGenerator < SDKMessage , void > {
child . settle ( outcome )
await Promise . resolve ( )
throw new Error ( 'SECRET_TOKEN from process transport' )
}
queryMock . mockImplementation ( ( { options } ) = > {
options . spawnClaudeCodeProcess ! ( sdkSpawnOptions ( ) )
return Object . assign ( stream ( ) , { close : vi.fn ( ) } ) as unknown as Query
} )
const run = await startClaudeCodeRun ( request ( ) , {
cwd : '/workspace' ,
permissionMode : DEFAULT_CLAUDE_CODE_PERMISSION_MODE ,
env : { } ,
disposeGraceMs : 5 ,
spawn : ( ) = > child . handle ,
} )
const result = await run . result
expect ( result ) . toEqual ( {
output : [ ] ,
diagnostic : expectedFailureDiagnostic (
'process' ,
'process-exit' ,
outcome ,
) ,
stopReason : 'error' ,
} )
expect ( result . diagnostic ) . not . toContain ( 'SECRET_TOKEN' )
await run . dispose ( )
}
} )
2026-08-04 20:57:55 +08:00
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' ,
2026-08-15 16:36:32 +08:00
permissionMode : 'dontAsk' ,
2026-08-04 20:57:55 +08:00
env : { } ,
disposeGraceMs : 5 ,
spawn : ( ) = > children [ index ++ ] ! . handle ,
}
2026-08-04 22:18:35 +08:00
queryMock . mockImplementation ( ( { prompt , options } ) = > {
controllers . push ( options . abortController ! )
options . spawnClaudeCodeProcess ! ( sdkSpawnOptions ( ) )
return prompt === 'wait'
? waitingQuery ( options . abortController ! . signal )
: queryFrom ( [ success ( 'second answer' ) ] )
} )
2026-08-04 20:57:55 +08:00
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 ( ) ] )
} )
2026-08-04 22:18:35 +08:00
it ( 'keeps local cancellation authoritative when the SDK iterator ends normally' , async ( ) = > {
const parentAbort = new AbortController ( )
const child = fakeChild ( )
async function * stream ( ) : AsyncGenerator < SDKMessage , void > {
yield success ( 'candidate answer' )
parentAbort . abort ( new Error ( 'parent cancelled at iterator completion' ) )
}
queryMock . mockImplementation ( ( { options } ) = > {
options . spawnClaudeCodeProcess ! ( sdkSpawnOptions ( ) )
return Object . assign ( stream ( ) , { close : vi.fn ( ) } ) as unknown as Query
} )
const run = await startClaudeCodeRun (
request ( undefined , parentAbort . signal ) ,
{
cwd : '/workspace' ,
2026-08-15 16:36:32 +08:00
permissionMode : DEFAULT_CLAUDE_CODE_PERMISSION_MODE ,
2026-08-04 22:18:35 +08:00
env : { } ,
disposeGraceMs : 5 ,
spawn : ( ) = > child . handle ,
} ,
)
await expect ( run . result ) . resolves . toEqual ( {
output : [ ] ,
stopReason : 'aborted' ,
} )
await run . dispose ( )
} )
2026-08-04 20:57:55 +08:00
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 ( )
2026-08-04 22:18:35 +08:00
queryMock . mockImplementationOnce (
( ) = > queryFrom ( [ ] , undefined , noChildClose ) ,
)
2026-08-04 20:57:55 +08:00
await expect ( startClaudeCodeRun ( request ( ) , {
. . . unused . spec ,
2026-08-18 01:43:20 +08:00
} ) ) . rejects . toThrow ( expectedFailureDiagnostic ( 'query-start' , 'unknown' ) )
2026-08-04 20:57:55 +08:00
expect ( noChildClose ) . toHaveBeenCalledOnce ( )
const closeFailure = vi . fn ( ( ) = > { throw new Error ( 'close boom' ) } )
2026-08-04 22:18:35 +08:00
queryMock . mockImplementationOnce (
( ) = > queryFrom ( [ ] , undefined , closeFailure ) ,
)
2026-08-04 20:57:55 +08:00
const noChild = startClaudeCodeRun ( request ( ) , {
. . . unused . spec ,
} )
2026-08-18 01:43:20 +08:00
await expect ( noChild )
. rejects . toThrow ( expectedFailureDiagnostic ( 'query-start' , 'unknown' ) )
2026-08-18 03:06:34 +08:00
await expect ( noChild ) . rejects . toThrow (
` ${ expectedFailureDiagnostic ( 'query-start' , 'unknown' ) } ; subagent-claude-code: ${ expectedFailureDiagnostic ( 'teardown' , 'unknown' ) } ` ,
)
2026-08-04 20:57:55 +08:00
await expect ( noChild ) . rejects . toBeInstanceOf ( AggregateError )
const startupAbort = new AbortController ( )
const abortedChild = fakeChild ( )
const abortedClose = vi . fn ( )
2026-08-04 22:18:35 +08:00
queryMock . mockImplementationOnce ( ( { options } ) = > {
options . spawnClaudeCodeProcess ! ( sdkSpawnOptions ( ) )
startupAbort . abort ( new Error ( 'startup cancelled' ) )
return queryFrom ( [ ] , undefined , abortedClose )
} )
2026-08-04 20:57:55 +08:00
const abortedDuringStartup = startClaudeCodeRun (
request ( undefined , startupAbort . signal ) ,
{
. . . unused . spec ,
spawn : ( ) = > abortedChild . handle ,
} ,
)
await expect ( abortedDuringStartup )
. rejects . toThrow ( 'aborted before SDK startup' )
expect ( abortedClose ) . toHaveBeenCalledOnce ( )
expect ( abortedChild . terminate ) . toHaveBeenCalledOnce ( )
2026-08-18 01:43:20 +08:00
const cleanupAbort = new AbortController ( )
const cleanupFailedChild = fakeChild ( {
waitForExitError : new Error ( 'SECRET_TOKEN cleanup wait failure' ) ,
} )
queryMock . mockImplementationOnce ( ( { options } ) = > {
options . spawnClaudeCodeProcess ! ( sdkSpawnOptions ( ) )
cleanupAbort . abort ( new Error ( 'startup cancelled' ) )
return queryFrom ( [ ] )
} )
const cancelledCleanupFailure = startClaudeCodeRun (
request ( undefined , cleanupAbort . signal ) ,
{
. . . unused . spec ,
spawn : ( ) = > cleanupFailedChild . handle ,
} ,
)
await expect ( cancelledCleanupFailure )
. rejects . toBeInstanceOf ( AggregateError )
await expect ( cancelledCleanupFailure )
. rejects . toThrow ( expectedFailureDiagnostic ( 'query-start' , 'unknown' ) )
2026-08-18 03:06:34 +08:00
await expect ( cancelledCleanupFailure ) . rejects . toThrow (
` ${ expectedFailureDiagnostic ( 'query-start' , 'unknown' ) } ; subagent-claude-code: ${ expectedFailureDiagnostic ( 'teardown' , 'unknown' , { exitCode : 0 , signal : null } )} ` ,
)
2026-08-18 01:43:20 +08:00
await expect ( cancelledCleanupFailure )
. rejects . not . toThrow ( 'SECRET_TOKEN' )
2026-08-04 22:18:35 +08:00
queryMock . mockImplementationOnce ( ( ) = > {
throw new Error ( 'query failed before resource creation' )
} )
2026-08-18 05:37:14 +08:00
const queryFailureOnError = vi . fn <
NonNullable < ClaudeCodeRunSpec [ 'onError' ] >
> ( )
2026-08-18 01:43:20 +08:00
const queryFailure = startClaudeCodeRun ( request ( ) , {
2026-08-04 20:57:55 +08:00
. . . unused . spec ,
2026-08-18 05:17:47 +08:00
onError : queryFailureOnError ,
2026-08-18 01:43:20 +08:00
} )
await expect ( queryFailure )
. rejects . toThrow ( expectedFailureDiagnostic ( 'query-start' , 'unknown' ) )
await expect ( queryFailure ) . rejects . not . toThrow (
'query failed before resource creation' ,
)
2026-08-18 05:17:47 +08:00
expect ( queryFailureOnError ) . toHaveBeenCalledWith (
expect . any ( Error ) ,
'error' ,
)
2026-08-18 05:37:14 +08:00
expect ( errorCause ( queryFailureOnError . mock . calls [ 0 ] ? . [ 0 ] ) ? . message )
. toBe ( 'query failed before resource creation' )
2026-08-04 20:57:55 +08:00
const spawned = fakeChild ( )
const spawnSpecs : SubprocessSpawnSpec [ ] = [ ]
let factoryController : AbortController | undefined
2026-08-04 22:18:35 +08:00
queryMock . mockImplementationOnce ( ( { options } ) = > {
factoryController = options . abortController
options . spawnClaudeCodeProcess ! ( sdkSpawnOptions ( ) )
2026-08-18 22:48:24 +08:00
spawned . settle ( { exitCode : 17 , signal : null } )
2026-08-04 22:18:35 +08:00
throw new Error ( 'query construction failed' )
} )
2026-08-04 20:57:55 +08:00
const factoryFailure = startClaudeCodeRun ( request ( ) , {
. . . unused . spec ,
spawn : ( spawnSpec ) = > {
spawnSpecs . push ( spawnSpec )
return spawned . handle
} ,
} )
2026-08-18 01:43:20 +08:00
await expect ( factoryFailure ) . rejects . toThrow ( expectedFailureDiagnostic (
'query-start' ,
'unknown' ,
2026-08-18 22:48:24 +08:00
{ exitCode : 17 , signal : null } ,
2026-08-18 01:43:20 +08:00
) )
await expect ( factoryFailure ) . rejects . not . toThrow ( 'query construction failed' )
2026-08-04 20:57:55 +08:00
expect ( spawnSpecs ) . toHaveLength ( 1 )
expect ( factoryController ? . signal . aborted ) . toBe ( true )
expect ( spawned . terminate ) . toHaveBeenCalledOnce ( )
2026-08-18 02:45:42 +08:00
const cleanupRaceAbort = new AbortController ( )
const cleanupRaceChild = fakeChild ( { exitOnTerminate : false } )
queryMock . mockImplementationOnce ( ( { options } ) = > {
options . spawnClaudeCodeProcess ! ( sdkSpawnOptions ( ) )
throw new Error ( 'query failed before cleanup wait' )
} )
const cleanupRace = startClaudeCodeRun (
request ( undefined , cleanupRaceAbort . signal ) ,
{
. . . unused . spec ,
spawn : ( ) = > cleanupRaceChild . handle ,
} ,
)
await nextTask ( )
cleanupRaceAbort . abort ( new Error ( 'cancelled during cleanup' ) )
cleanupRaceChild . settle ( )
await expect ( cleanupRace ) . rejects . toThrow ( 'aborted before SDK startup' )
2026-08-13 19:34:56 +08:00
const spawnError = Object . assign (
new Error ( 'spawn /sdk/claude EACCES' ) ,
{ code : 'EACCES' , path : '/sdk/claude' } ,
)
2026-08-04 20:57:55 +08:00
const failedSpawn = fakeChild ( {
pid : - 1 ,
2026-08-13 19:34:56 +08:00
doneError : spawnError ,
2026-08-04 20:57:55 +08:00
} )
const failed = fakeRun ( [ ] , undefined , failedSpawn )
2026-08-18 01:43:20 +08:00
const failedStartup = startClaudeCodeRun ( request ( ) , failed . spec )
await expect ( failedStartup )
. rejects . toThrow ( expectedFailureDiagnostic ( 'query-start' , 'unknown' ) )
2026-08-18 20:43:04 +08:00
await expect ( failedStartup ) . rejects . not . toThrow ( 'spawn /sdk/claude EACCES' )
await expect ( failedStartup ) . rejects . toMatchObject ( { cause : spawnError } )
2026-08-04 20:57:55 +08:00
expect ( failed . close ) . toHaveBeenCalledOnce ( )
2026-08-13 19:34:56 +08:00
expect ( failedSpawn . terminate ) . not . toHaveBeenCalled ( )
expect ( failedSpawn . waitForExit ) . not . toHaveBeenCalled ( )
const failedSpawnAbort = new AbortController ( )
const cancelledFailedSpawn = fakeChild ( {
pid : - 1 ,
doneError : spawnError ,
} )
const cancelledFailedClose = vi . fn ( )
queryMock . mockImplementationOnce ( ( { options } ) = > {
options . spawnClaudeCodeProcess ! ( sdkSpawnOptions ( ) )
failedSpawnAbort . abort ( new Error ( 'startup cancelled' ) )
return queryFrom ( [ ] , undefined , cancelledFailedClose )
} )
await expect ( startClaudeCodeRun (
request ( undefined , failedSpawnAbort . signal ) ,
{ . . . unused . spec , spawn : ( ) = > cancelledFailedSpawn . handle } ,
) ) . rejects . toThrow ( 'aborted before SDK startup' )
expect ( cancelledFailedClose ) . toHaveBeenCalledOnce ( )
2026-08-14 16:05:48 +08:00
const cancelledFailedSpawnCloseError = new Error ( 'cancelled query close failed' )
const cancelledFailedSpawnClose = vi . fn ( ( ) = > {
throw cancelledFailedSpawnCloseError
} )
const cancelledFailedSpawnWithCloseFailure = fakeChild ( {
pid : - 1 ,
doneError : spawnError ,
} )
const failedSpawnAbortWithCloseFailure = new AbortController ( )
queryMock . mockImplementationOnce ( ( { options } ) = > {
options . spawnClaudeCodeProcess ! ( sdkSpawnOptions ( ) )
failedSpawnAbortWithCloseFailure . abort ( new Error ( 'startup cancelled' ) )
return queryFrom ( [ ] , undefined , cancelledFailedSpawnClose )
} )
const cancelledWithCloseFailure = startClaudeCodeRun (
request ( undefined , failedSpawnAbortWithCloseFailure . signal ) ,
{ . . . unused . spec , spawn : ( ) = > cancelledFailedSpawnWithCloseFailure . handle } ,
)
await expect ( cancelledWithCloseFailure ) . rejects . toMatchObject ( {
2026-08-18 20:43:04 +08:00
message : ` subagent-claude-code: ${ expectedFailureDiagnostic ( 'query-start' , 'unknown' ) } ; subagent-claude-code: ${ expectedFailureDiagnostic ( 'teardown' , 'unknown' ) } ` ,
2026-08-14 16:05:48 +08:00
errors : [
2026-08-18 20:43:04 +08:00
expect . objectContaining ( {
message : ` subagent-claude-code: ${ expectedFailureDiagnostic ( 'query-start' , 'unknown' ) } ` ,
cause : spawnError ,
} ) ,
expect . objectContaining ( {
message : ` subagent-claude-code: ${ expectedFailureDiagnostic ( 'teardown' , 'unknown' ) } ` ,
cause : cancelledFailedSpawnCloseError ,
} ) ,
2026-08-14 16:05:48 +08:00
] ,
} )
2026-08-18 20:43:04 +08:00
await expect ( cancelledWithCloseFailure )
. rejects . not . toThrow ( 'spawn /sdk/claude EACCES' )
2026-08-14 16:05:48 +08:00
expect ( cancelledFailedSpawnClose ) . toHaveBeenCalledOnce ( )
2026-08-13 19:34:56 +08:00
const failedSpawnCloseError = new Error ( 'query close failed' )
const failedSpawnClose = vi . fn ( ( ) = > { throw failedSpawnCloseError } )
const failedSpawnWithCloseFailure = fakeChild ( {
pid : - 1 ,
doneError : spawnError ,
} )
queryMock . mockImplementationOnce ( ( { options } ) = > {
options . spawnClaudeCodeProcess ! ( sdkSpawnOptions ( ) )
return queryFrom ( [ ] , undefined , failedSpawnClose )
} )
const failedWithCloseFailure = startClaudeCodeRun ( request ( ) , {
. . . unused . spec ,
spawn : ( ) = > failedSpawnWithCloseFailure . handle ,
} )
await expect ( failedWithCloseFailure )
2026-08-18 20:43:04 +08:00
. rejects . toThrow ( expectedFailureDiagnostic ( 'query-start' , 'unknown' ) )
await expect ( failedWithCloseFailure )
. rejects . not . toThrow ( 'spawn /sdk/claude EACCES' )
2026-08-13 19:34:56 +08:00
await expect ( failedWithCloseFailure ) . rejects . toMatchObject ( {
2026-08-18 20:43:04 +08:00
message : ` subagent-claude-code: ${ expectedFailureDiagnostic ( 'query-start' , 'unknown' ) } ; subagent-claude-code: ${ expectedFailureDiagnostic ( 'teardown' , 'unknown' ) } ` ,
errors : [
expect . objectContaining ( { cause : spawnError } ) ,
expect . objectContaining ( { cause : failedSpawnCloseError } ) ,
] ,
2026-08-13 19:34:56 +08:00
} )
const cleanupError = new Error ( 'live child cleanup failed' )
const constructionError = new Error (
'query construction failed with a live child' ,
)
2026-08-18 21:29:32 +08:00
const liveChildCleanupFailure = fakeChild ( { waitForExitError : cleanupError } )
2026-08-13 19:34:56 +08:00
queryMock . mockImplementationOnce ( ( { options } ) = > {
options . spawnClaudeCodeProcess ! ( sdkSpawnOptions ( ) )
throw constructionError
} )
2026-08-18 20:43:04 +08:00
const liveCleanupFailure = startClaudeCodeRun ( request ( ) , {
2026-08-13 19:34:56 +08:00
. . . unused . spec ,
spawn : ( ) = > liveChildCleanupFailure . handle ,
} )
2026-08-18 20:43:04 +08:00
await expect ( liveCleanupFailure ) . rejects . toMatchObject ( {
2026-08-18 21:29:32 +08:00
message : ` subagent-claude-code: ${ expectedFailureDiagnostic ( 'query-start' , 'unknown' ) } ; subagent-claude-code: ${ expectedFailureDiagnostic ( 'teardown' , 'unknown' , { exitCode : 0 , signal : null } )} ` ,
2026-08-18 20:43:04 +08:00
errors : [
expect . objectContaining ( { cause : constructionError } ) ,
expect . objectContaining ( { cause : cleanupError } ) ,
] ,
} )
await expect ( liveCleanupFailure )
. rejects . not . toThrow ( 'query construction failed with a live child' )
await expect ( liveCleanupFailure )
. rejects . not . toThrow ( 'live child cleanup failed' )
2026-08-04 20:57:55 +08:00
} )
} )
2026-08-04 22:18:35 +08:00
describe ( 'query and process disposal' , ( ) = > {
2026-08-04 20:57:55 +08:00
it ( 'closes the query, terminates the tree, and waits for direct-child outcome' , async ( ) = > {
const child = fakeChild ( )
const close = vi . fn ( )
2026-08-04 22:18:35 +08:00
await disposeClaudeCodeChild ( { close } , child . handle )
2026-08-04 20:57:55 +08:00
expect ( close ) . toHaveBeenCalledOnce ( )
expect ( child . terminate ) . toHaveBeenCalledOnce ( )
expect ( child . waitForExit ) . toHaveBeenCalledOnce ( )
2026-08-04 22:18:35 +08:00
expect ( child . waitForExit ) . toHaveBeenCalledWith ( )
2026-08-04 20:57:55 +08:00
await expect ( child . handle . done ) . resolves . toEqual ( {
exitCode : 0 ,
signal : null ,
} )
} )
2026-08-18 05:17:47 +08:00
it ( 'reports a published teardown failure to the Host diagnostic sink' , async ( ) = > {
const fixture = fakeRun ( [ success ( 'exact answer' ) ] )
2026-08-18 05:37:14 +08:00
const onError = vi . fn < NonNullable < ClaudeCodeRunSpec [ 'onError' ] > > ( )
2026-08-18 05:17:47 +08:00
const run = await startClaudeCodeRun ( request ( ) , {
. . . fixture . spec ,
onError ,
} )
await expect ( run . result ) . resolves . toMatchObject ( { stopReason : 'completed' } )
fixture . close . mockImplementationOnce ( ( ) = > {
throw new Error ( 'SECRET_TOKEN close failure' )
} )
await expect ( run . dispose ( ) ) . rejects . toThrow (
expectedFailureDiagnostic ( 'teardown' , 'unknown' , {
exitCode : 0 ,
signal : null ,
} ) ,
)
expect ( onError ) . toHaveBeenCalledWith ( expect . any ( Error ) , 'error' )
2026-08-18 05:37:14 +08:00
expect ( errorCause ( onError . mock . calls [ 0 ] ? . [ 0 ] ) ? . message )
. toBe ( 'SECRET_TOKEN close failure' )
2026-08-18 05:17:47 +08:00
} )
2026-08-04 22:18:35 +08:00
it ( 'does not finish disposal before the managed tree exits' , async ( ) = > {
const child = fakeChild ( { exitOnTerminate : false } )
let disposed = false
const disposal = disposeClaudeCodeChild (
2026-08-04 20:57:55 +08:00
{ close : vi.fn ( ) } ,
child . handle ,
2026-08-04 22:18:35 +08:00
) . then ( ( ) = > {
disposed = true
} )
2026-08-04 20:57:55 +08:00
await nextTask ( )
2026-08-04 22:18:35 +08:00
expect ( disposed ) . toBe ( false )
child . settle ( )
await disposal
expect ( disposed ) . toBe ( true )
2026-08-04 20:57:55 +08:00
} )
2026-08-18 21:29:32 +08:00
it ( 'reports close and tree-wait failures without skipping cleanup' , async ( ) = > {
2026-08-04 20:57:55 +08:00
const waitFailure = fakeChild ( {
waitForExitError : new Error ( 'wait boom' ) ,
} )
const closeFailure = vi . fn ( ( ) = > { throw new Error ( 'close boom' ) } )
2026-08-18 01:43:20 +08:00
const waitAndClose = disposeClaudeCodeChild (
2026-08-04 20:57:55 +08:00
{ close : closeFailure } ,
waitFailure . handle ,
2026-08-18 01:43:20 +08:00
)
await expect ( waitAndClose ) . rejects . toThrow ( expectedFailureDiagnostic (
'teardown' ,
'unknown' ,
{ exitCode : 0 , signal : null } ,
) )
2026-08-18 21:17:41 +08:00
const waitAndCloseError = await waitAndClose . then (
( ) = > undefined ,
( error : unknown ) = > error ,
)
const waitAndCloseCause = errorCause ( waitAndCloseError )
expect ( waitAndCloseCause ) . toBeInstanceOf ( AggregateError )
expect ( ( waitAndCloseCause as AggregateError ) . errors ) . toEqual ( [
expect . objectContaining ( { message : 'close boom' } ) ,
expect . objectContaining ( { message : 'wait boom' } ) ,
] )
2026-08-04 20:57:55 +08:00
expect ( waitFailure . terminate ) . toHaveBeenCalledOnce ( )
} )
} )