fix(subagent): complete product provider lifecycle

This commit is contained in:
pku-xht
2026-08-04 22:18:35 +08:00
parent d780902679
commit 32f829c4e6
21 changed files with 216 additions and 301 deletions

View File

@@ -1,5 +1,6 @@
import { PassThrough } from 'node:stream'
import type {
Options,
Query,
SDKMessage,
SDKResultMessage,
@@ -7,7 +8,15 @@ import type {
} 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 {
afterEach,
beforeEach,
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'
@@ -35,6 +44,18 @@ import {
type ClaudeCodeRunSpec,
} from '../src/run.ts'
type QueryFactory = (params: {
prompt: string
options: Options
}) => Query
const queryMock = vi.hoisted(() => vi.fn<QueryFactory>())
vi.mock('@anthropic-ai/claude-agent-sdk', async importOriginal => ({
...await importOriginal<typeof import('@anthropic-ai/claude-agent-sdk')>(),
query: queryMock,
}))
const fakeParent = {
id: 'parent',
session: { header: { cwd: process.cwd() } },
@@ -56,7 +77,6 @@ interface FakeChildOptions {
readonly stdin?: PassThrough | undefined
readonly stdout?: PassThrough | undefined
readonly exitOnTerminate?: boolean
readonly waitForExitResult?: boolean
readonly waitForExitError?: Error
readonly doneError?: Error
}
@@ -103,9 +123,6 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild {
if (options.waitForExitError !== undefined) {
throw options.waitForExitError
}
if (options.waitForExitResult !== undefined) {
return options.waitForExitResult
}
if (exited) return true
if (signal === undefined) {
await done.catch(() => {})
@@ -218,7 +235,7 @@ interface FakeRun {
readonly query: Query
readonly close: ReturnType<typeof vi.fn>
readonly spawnSpecs: SubprocessSpawnSpec[]
readonly options: Array<Parameters<NonNullable<ClaudeCodeRunSpec['query']>>[0]['options']>
readonly options: Options[]
readonly spec: ClaudeCodeRunSpec
}
@@ -239,16 +256,28 @@ function fakeRun(
spawnSpecs.push(spawnSpec)
return child.handle
},
query: (params) => {
options.push(params.options)
params.options.spawnClaudeCodeProcess!(sdkSpawnOptions())
return query
},
}
queryMock.mockImplementation((params) => {
options.push(params.options)
params.options.spawnClaudeCodeProcess!(sdkSpawnOptions())
return query
})
return { child, query, close, spawnSpecs, options, spec }
}
beforeEach(() => {
queryMock.mockImplementation(({ options }) => {
options.spawnClaudeCodeProcess!(sdkSpawnOptions({
cwd: options.cwd!,
env: options.env!,
signal: options.abortController!.signal,
}))
return queryFrom([])
})
})
afterEach(() => {
queryMock.mockReset()
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
@@ -523,25 +552,17 @@ describe('query options and result mapping', () => {
})
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({
await expect(consumeClaudeQuery(query)).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')
})
})
@@ -596,14 +617,14 @@ describe('run publication, cancellation, and settlement', () => {
}
})
it('preserves candidate output when iteration fails after a result', async () => {
it('fails closed when iteration rejects 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' }],
output: [],
stopReason: 'error',
})
await run.dispose()
@@ -635,14 +656,14 @@ describe('run publication, cancellation, and settlement', () => {
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')])
},
}
queryMock.mockImplementation(({ 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),
@@ -667,6 +688,33 @@ describe('run publication, cancellation, and settlement', () => {
await Promise.all([first.dispose(), second.dispose()])
})
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',
env: {},
disposeGraceMs: 5,
spawn: () => child.handle,
},
)
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'aborted',
})
await run.dispose()
})
it('rejects pre-abort and every incomplete startup transaction', async () => {
const preAborted = new AbortController()
preAborted.abort()
@@ -678,32 +726,36 @@ describe('run publication, cancellation, and settlement', () => {
expect(unused.options).toEqual([])
const noChildClose = vi.fn()
queryMock.mockImplementationOnce(
() => queryFrom([], undefined, noChildClose),
)
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') })
queryMock.mockImplementationOnce(
() => queryFrom([], undefined, closeFailure),
)
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()
queryMock.mockImplementationOnce(({ options }) => {
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
startupAbort.abort(new Error('startup cancelled'))
return queryFrom([], undefined, abortedClose)
})
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)
@@ -711,27 +763,27 @@ describe('run publication, cancellation, and settlement', () => {
expect(abortedClose).toHaveBeenCalledOnce()
expect(abortedChild.terminate).toHaveBeenCalledOnce()
queryMock.mockImplementationOnce(() => {
throw new Error('query failed before resource creation')
})
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
queryMock.mockImplementationOnce(({ options }) => {
factoryController = options.abortController
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
throw new Error('query construction failed')
})
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)
@@ -749,76 +801,45 @@ describe('run publication, cancellation, and settlement', () => {
})
})
describe('bounded query and process disposal', () => {
describe('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)
await disposeClaudeCodeChild({ close }, child.handle)
expect(close).toHaveBeenCalledOnce()
expect(child.terminate).toHaveBeenCalledOnce()
expect(child.waitForExit).toHaveBeenCalledOnce()
expect(child.waitForExit).toHaveBeenCalledWith()
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(
it('does not finish disposal before the managed tree exits', async () => {
const child = fakeChild({ exitOnTerminate: false })
let disposed = false
const disposal = disposeClaudeCodeChild(
{ close: vi.fn() },
child.handle,
5,
)).rejects.toThrow('did not exit within its dispose window')
child.fail(new Error('late direct-child failure'))
).then(() => {
disposed = true
})
await nextTask()
expect(disposed).toBe(false)
child.settle()
await disposal
expect(disposed).toBe(true)
})
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()
@@ -829,7 +850,6 @@ describe('bounded query and process disposal', () => {
await expect(disposeClaudeCodeChild(
{ close: vi.fn() },
doneFailure.handle,
5,
)).rejects.toThrow('spawn boom')
const both = fakeChild({
@@ -839,7 +859,6 @@ describe('bounded query and process disposal', () => {
await expect(disposeClaudeCodeChild(
{ close: () => { throw new Error('close boom') } },
both.handle,
5,
)).rejects.toBeInstanceOf(AggregateError)
})
})