refactor(subagent): unify async readiness and cancellation
This commit is contained in:
@@ -65,7 +65,7 @@ async function setup(script: Script, options: SetupOptions = {}) {
|
||||
name: 'spawn',
|
||||
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, {}),
|
||||
start: (request: SubagentStartRequest) => startInProcessRun(request, {}),
|
||||
})
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||
@@ -73,7 +73,13 @@ async function setup(script: Script, options: SetupOptions = {}) {
|
||||
}
|
||||
|
||||
function structuredRequest(parent: SubagentStartRequest['parent'], extra?: Partial<SubagentStartRequest>): SubagentStartRequest {
|
||||
return { prompt: [{ type: 'text', text: 'produce the answer' }], parent, outputSchema: SCHEMA, ...extra }
|
||||
return {
|
||||
prompt: [{ type: 'text', text: 'produce the answer' }],
|
||||
parent,
|
||||
signal: new AbortController().signal,
|
||||
outputSchema: SCHEMA,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
/** The tool names of one recorded model request. */
|
||||
@@ -86,7 +92,7 @@ describe('in-process structured output', () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.structured).toEqual({ answer: 42, note: 'done' })
|
||||
@@ -98,7 +104,7 @@ describe('in-process structured output', () => {
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
textResponse('MUST NOT BE CONSUMED'),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
await run.result
|
||||
// Default continuation would run a second step after the tool call; the
|
||||
// structured runtime's turn-continuation veto stops the turn instead.
|
||||
@@ -129,7 +135,7 @@ describe('in-process structured output', () => {
|
||||
return Promise.resolve([{ type: 'text', text: 'ran' }])
|
||||
},
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.structured).toEqual({ answer: 5 })
|
||||
@@ -157,7 +163,7 @@ describe('in-process structured output', () => {
|
||||
return Promise.resolve([{ type: 'text', text: 'ran' }])
|
||||
},
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
// Registered after the child and prepended: this listener returns allow
|
||||
// after every downstream pre-execute decision. The service-owned guard
|
||||
// runs after the waterfall and can only deny, so the body still cannot run.
|
||||
@@ -194,7 +200,7 @@ describe('in-process structured output', () => {
|
||||
return Promise.resolve([{ type: 'text', text: 'ran' }])
|
||||
},
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
// The call ran BEFORE captured was set: the deny gate only guards the
|
||||
// window after the terminal answer landed.
|
||||
@@ -203,46 +209,20 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('snapshots the schema at start(): caller mutation after start cannot drift enforcement', async () => {
|
||||
const mutable: StructuredOutputSchema = {
|
||||
type: 'object',
|
||||
properties: { answer: { type: 'number' } },
|
||||
required: ['answer'],
|
||||
additionalProperties: false,
|
||||
}
|
||||
const pristine = structuredClone(mutable)
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 3 }),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: mutable }))
|
||||
// Mutate the caller's object AFTER start() returned but before the child's
|
||||
// first request assembles: with a live reference this would reach both the
|
||||
// model-visible parameters and validateStructuredValue.
|
||||
;(mutable.properties as Record<string, unknown>).answer = { type: 'string' }
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 3 })
|
||||
// The child's request carried the PRISTINE schema, not the mutated one.
|
||||
const childRequest = adapter.requests.at(-1)
|
||||
const captureTool = (childRequest?.tools ?? []).find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
|
||||
expect(captureTool?.parameters).toEqual(pristine)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a later-prepended continuation wrapper cannot resurrect a captured turn', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||
textResponse('MUST NOT BE CONSUMED'),
|
||||
])
|
||||
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
let wrapperInstalled = false
|
||||
// Register this observer only after start() returns. The child session-start
|
||||
// boundary is after its unpublished setup attached structured output but
|
||||
// before the loop can run; install a prepended wrapper there. It awaits the
|
||||
// Register before the ready-only start. The child session-start boundary is
|
||||
// after unpublished setup attached structured output but before the loop
|
||||
// can run. The wrapper awaits the
|
||||
// explicit downstream stop above, then overwrites that result with continue.
|
||||
// The later terminal checkpoint still wins.
|
||||
ctx.on('agent/session-start', (child) => {
|
||||
if (child.id !== run.id) return
|
||||
if (child === parent) return
|
||||
wrapperInstalled = true
|
||||
child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, next): Promise<ContinuationDecision> => {
|
||||
const downstream = await next()
|
||||
@@ -250,6 +230,7 @@ describe('in-process structured output', () => {
|
||||
return { action: 'continue' }
|
||||
}, { prepend: true })
|
||||
})
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(wrapperInstalled).toBe(true)
|
||||
expect(result.structured).toEqual({ answer: 7 })
|
||||
@@ -268,7 +249,7 @@ describe('in-process structured output', () => {
|
||||
// would turn the stop back into continue. The terminal checkpoint runs
|
||||
// afterwards and discards that steering.
|
||||
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
ctx.on('agent/session-start', (child) => {
|
||||
if (child.id !== run.id) return
|
||||
child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, next): Promise<ContinuationDecision> => {
|
||||
@@ -294,7 +275,7 @@ describe('in-process structured output', () => {
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),
|
||||
toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 7 })
|
||||
expect(result.stopReason).toBe('completed')
|
||||
@@ -311,7 +292,7 @@ describe('in-process structured output', () => {
|
||||
textResponse('here is my answer in prose'),
|
||||
textResponse('MUST NOT BE CONSUMED'),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.structured).toBeUndefined()
|
||||
@@ -325,7 +306,7 @@ describe('in-process structured output', () => {
|
||||
it('an errored child keeps its honest error result (no capture expected)', async () => {
|
||||
// Script exhaustion on the first call → the child turn errors.
|
||||
const { ctx, parent, adapter } = await setup([])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(adapter.requests.length).toBe(1)
|
||||
@@ -334,12 +315,13 @@ describe('in-process structured output', () => {
|
||||
|
||||
it('a cancel landing after a clean capture-less turn settles aborted, not error', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('prose, no capture')])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const controller = new AbortController()
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent, { signal: controller.signal }))
|
||||
// Cancel synchronously inside the turn's end recording: the cancel
|
||||
// contract outranks the schema shortfall, so the result maps to aborted.
|
||||
ctx.on('session/event', (session, event) => {
|
||||
const child = ctx.agents.get(run.id)
|
||||
if (session === child?.session && event.type === 'turn/end') run.cancel('cancelled at turn end')
|
||||
if (session === child?.session && event.type === 'turn/end') controller.abort('cancelled at turn end')
|
||||
})
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
@@ -348,20 +330,18 @@ describe('in-process structured output', () => {
|
||||
|
||||
it('rejects a schema outside the subset loud, before any child exists', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
expect(() => ctx.subagents.start('spawn', structuredRequest(parent, {
|
||||
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
|
||||
outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema,
|
||||
}))).toThrow(/unsupported output schema/)
|
||||
}))).rejects.toThrow(/unsupported output schema/)
|
||||
expect(ctx.agents.get(AgentId('parent'))).toBeDefined()
|
||||
})
|
||||
|
||||
it('a schema carrying non-JSON values fails as OutputSchemaError, never as a raw clone error', async () => {
|
||||
it('a schema carrying non-JSON values fails as OutputSchemaError at the validation boundary', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// Assertion runs BEFORE the defensive structuredClone: a function-valued
|
||||
// annotation must surface as the subset violation it is, not escape as
|
||||
// structuredClone's DataCloneError.
|
||||
expect(() => ctx.subagents.start('spawn', structuredRequest(parent, {
|
||||
// Semantic assertion runs before provider startup.
|
||||
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
|
||||
outputSchema: { type: 'object', default: () => {} } as unknown as StructuredOutputSchema,
|
||||
}))).toThrow(/unsupported output schema.*annotation must be JSON data/)
|
||||
}))).rejects.toThrow(/unsupported output schema.*annotation must be JSON data/)
|
||||
})
|
||||
|
||||
it('a post-execute BLOCK on the capture call denies the capture: log and result agree on failure', async () => {
|
||||
@@ -377,7 +357,7 @@ describe('in-process structured output', () => {
|
||||
}
|
||||
return next()
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
// No capture was committed: the run reports the schema shortfall...
|
||||
expect(result.structured).toBeUndefined()
|
||||
@@ -403,7 +383,7 @@ describe('in-process structured output', () => {
|
||||
}
|
||||
return next()
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.structured).toEqual({ answer: 8 })
|
||||
@@ -415,7 +395,7 @@ describe('in-process structured output', () => {
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
|
||||
textResponse('capture was rejected'),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
// Registered after attachment and prepended, so it wraps every listener
|
||||
// the child installed. It delegates first, then converts the apparent
|
||||
// capture success into the pipeline's authoritative failure.
|
||||
@@ -442,7 +422,7 @@ describe('in-process structured output', () => {
|
||||
// replace it (AgentOptions has no prompt field — the instruction is
|
||||
// per-request wire state added by the final-request listener).
|
||||
ctx.systemPrompt.section({ name: 'test:persona', order: 10, text: 'You are a counter.' })
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
await run.result
|
||||
const childRequest = adapter.requests.at(-1)!
|
||||
expect(childRequest.system).toContain('You are a counter.')
|
||||
@@ -463,7 +443,7 @@ describe('in-process structured output', () => {
|
||||
return { logs: [], value: 'captured' }
|
||||
},
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
|
||||
// This listener is registered after the child's protection and prepended.
|
||||
// Service finalization still restores the stripped transport and prompt
|
||||
@@ -507,7 +487,7 @@ describe('in-process structured output', () => {
|
||||
} as never
|
||||
},
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toBeUndefined()
|
||||
@@ -536,7 +516,7 @@ describe('in-process structured output', () => {
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => exec.name === RUN_CODE_NAME
|
||||
? Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer blocked' }] })
|
||||
: next())
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toBeUndefined()
|
||||
@@ -553,7 +533,7 @@ describe('in-process structured output', () => {
|
||||
parent.send([{ type: 'text', text: 'hello' }])
|
||||
await parent.whenIdle()
|
||||
expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION)
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
await run.result
|
||||
// The loop always assembles a base prompt (the harness identity section),
|
||||
// so the instruction APPENDS — never replaces.
|
||||
@@ -584,7 +564,7 @@ describe('in-process structured output', () => {
|
||||
await parent.whenIdle()
|
||||
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
await run.result
|
||||
const childRequest = adapter.requests[1]!
|
||||
expect(toolNames(childRequest)).toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
@@ -617,8 +597,8 @@ describe('in-process structured output', () => {
|
||||
return toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, args)
|
||||
},
|
||||
])
|
||||
const runA = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const runB = ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema }))
|
||||
const runA = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const runB = await ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema }))
|
||||
const [a, b] = await Promise.all([runA.result, runB.result])
|
||||
expect(a.structured).toEqual({ answer: 1 })
|
||||
expect(b.structured).toEqual({ verdict: 'real' })
|
||||
@@ -647,7 +627,7 @@ describe('in-process structured output', () => {
|
||||
variables: { ...replaced.variables },
|
||||
}
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 5 })
|
||||
const entries = adapter.requests[0]!.tools!.filter(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
|
||||
@@ -672,7 +652,7 @@ describe('in-process structured output', () => {
|
||||
variables: { ...replaced.variables },
|
||||
}
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 5 })
|
||||
const entry = adapter.requests[0]!.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
|
||||
@@ -697,7 +677,7 @@ describe('in-process structured output', () => {
|
||||
execute: () => Promise.resolve([{ type: 'text', text: 'x' }]),
|
||||
})
|
||||
ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' })
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
await run.result
|
||||
const request = adapter.requests[0]!
|
||||
const names = toolNames(request)
|
||||
@@ -731,7 +711,7 @@ describe('in-process structured output', () => {
|
||||
variables: { ...replaced.variables },
|
||||
}
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 3 })
|
||||
const request = adapter.requests[0]!
|
||||
@@ -759,7 +739,7 @@ describe('in-process structured output', () => {
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }),
|
||||
])
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
// A backend hot-reload mid-run must not unregister the capture tool out
|
||||
// from under the live child: the registration rides the CHILD's fiber.
|
||||
await disposeProvider()
|
||||
@@ -800,7 +780,7 @@ describe('in-process structured output', () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
// A prepended post-execute listener blocks the first capture without
|
||||
// delegating. The final-result notification discards that execution's
|
||||
// stage when it observes the error.
|
||||
@@ -841,7 +821,7 @@ describe('in-process structured output', () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
// Block the first capture after its body stages a value. Its final error
|
||||
// discards that execution's stage.
|
||||
let blocks = 1
|
||||
@@ -879,7 +859,7 @@ describe('in-process structured output', () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
// Discard the first capture's stage via a final post-execute block.
|
||||
let blocks = 1
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { depthOf, type InProcessRunOptions, SubagentDepthError, startInProcessRun } from '../src/index.ts'
|
||||
import { depthOf, SubagentDepthError, startInProcessRun } from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
/**
|
||||
* Drives the shared in-process run driver DIRECTLY (no provider package), so the
|
||||
* driver's own contract — depth read/cap, the one-shot drive, the result read —
|
||||
* is covered independently of which backend (spawn/fork) calls it. The only
|
||||
* mocked boundary is the model; the real agent loop, SubagentService, and
|
||||
* dsh-invariants are mounted, so a malformed child session log fails the test.
|
||||
*/
|
||||
async function setup(script: Script) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -35,393 +28,127 @@ async function setup(script: Script) {
|
||||
return { ctx, parent }
|
||||
}
|
||||
|
||||
function text(blocks: { type: string; text?: string }[]): string {
|
||||
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
function request(parent: Agent, signal = new AbortController().signal) {
|
||||
return { prompt: [{ type: 'text' as const, text: 'child task' }], parent, signal }
|
||||
}
|
||||
|
||||
function text(blocks: readonly { type: string; text?: string }[]): string {
|
||||
return blocks.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
}
|
||||
|
||||
describe('depthOf', () => {
|
||||
it('reads 0 for an agent with no subagentDepth, the set value otherwise', async () => {
|
||||
it('reads zero for a top-level agent and an explicit child depth', async () => {
|
||||
const { parent } = await setup([])
|
||||
expect(depthOf(parent)).toBe(0)
|
||||
const withDepth = { options: { subagentDepth: 3 } } as unknown as Agent
|
||||
expect(depthOf(withDepth)).toBe(3)
|
||||
expect(depthOf({ options: { subagentDepth: 3 } } as unknown as Agent)).toBe(3)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'null', value: null as unknown as number },
|
||||
{ label: 'a string', value: '1' as unknown as number },
|
||||
{ label: 'NaN', value: Number.NaN },
|
||||
{ label: 'positive infinity', value: Number.POSITIVE_INFINITY },
|
||||
{ label: 'negative infinity', value: Number.NEGATIVE_INFINITY },
|
||||
{ label: 'a fraction', value: 1.5 },
|
||||
{ label: 'a negative integer', value: -1 },
|
||||
{ label: 'negative zero', value: -0 },
|
||||
{ label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 },
|
||||
])('rejects subagentDepth=$label', ({ value }) => {
|
||||
const agent = { options: { subagentDepth: value } } as unknown as Agent
|
||||
expect(() => depthOf(agent)).toThrow('agent subagentDepth must be a non-negative safe integer')
|
||||
it.each([Number.NaN, 1.5, -1, -0, Number.MAX_SAFE_INTEGER + 1])('rejects malformed depth %s', (value) => {
|
||||
expect(() => depthOf({ options: { subagentDepth: value } } as unknown as Agent))
|
||||
.toThrow('non-negative safe integer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('startInProcessRun', () => {
|
||||
it.each([
|
||||
{ label: 'null', value: null as unknown as number },
|
||||
{ label: 'a string', value: '1' as unknown as number },
|
||||
{ label: 'NaN', value: Number.NaN },
|
||||
{ label: 'positive infinity', value: Number.POSITIVE_INFINITY },
|
||||
{ label: 'negative infinity', value: Number.NEGATIVE_INFINITY },
|
||||
{ label: 'a fraction', value: 1.5 },
|
||||
{ label: 'a negative integer', value: -1 },
|
||||
{ label: 'negative zero', value: -0 },
|
||||
{ label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 },
|
||||
])('rejects maxDepth=$label before acquiring run ownership', async ({ value }) => {
|
||||
const { ctx, parent } = await setup([])
|
||||
expect(() => startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent,
|
||||
maxDepth: value,
|
||||
}, {})).toThrow('subagent maxDepth must be a non-negative safe integer')
|
||||
})
|
||||
|
||||
it('rejects a non-string persona before acquiring run ownership', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
expect(() => startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent,
|
||||
persona: 42 as unknown as string,
|
||||
}, {})).toThrow('subagent persona must be a string')
|
||||
})
|
||||
|
||||
it('rejects a child depth with no safe-integer representation before acquiring run ownership', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const parent = {
|
||||
options: { subagentDepth: Number.MAX_SAFE_INTEGER },
|
||||
} as unknown as Agent
|
||||
|
||||
expect(() => startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent,
|
||||
}, {})).toThrow(RangeError)
|
||||
})
|
||||
|
||||
it('rejects a non-JSON prompt before acquiring any run ownership', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
expect(() => startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: Number.NaN as unknown as string }],
|
||||
parent,
|
||||
}, {})).toThrow('subagent prompt must be losslessly JSON-serializable')
|
||||
})
|
||||
|
||||
it('reads each prompt value once before asynchronous child creation', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
let reads = 0
|
||||
const prompt = [{
|
||||
type: 'text' as const,
|
||||
get text(): string {
|
||||
reads += 1
|
||||
return reads === 1 ? 'valid during pre-check' : Number.NaN as unknown as string
|
||||
},
|
||||
}]
|
||||
|
||||
const run = startInProcessRun(ctx, { prompt, parent }, {})
|
||||
expect(reads).toBe(1)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('reads each public request and seed option field once', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const reads = { prompt: 0, toolFilter: 0, maxDepth: 0, outputSchema: 0, agentOptions: 0, persona: 0, seed: 0 }
|
||||
const request = Object.defineProperties({ parent }, {
|
||||
prompt: { enumerable: true, get: () => { reads.prompt += 1; return [{ type: 'text', text: 'accepted' }] } },
|
||||
toolFilter: { enumerable: true, get: () => { reads.toolFilter += 1; return reads.toolFilter === 1 ? undefined : { deny: ['ghost'] } } },
|
||||
maxDepth: { enumerable: true, get: () => { reads.maxDepth += 1; return reads.maxDepth === 1 ? undefined : 0 } },
|
||||
outputSchema: { enumerable: true, get: () => { reads.outputSchema += 1; return undefined } },
|
||||
agentOptions: { enumerable: true, get: () => { reads.agentOptions += 1; return {} } },
|
||||
persona: { enumerable: true, get: () => { reads.persona += 1; return undefined } },
|
||||
}) as unknown as SubagentStartRequest
|
||||
const options = Object.defineProperty({}, 'seed', {
|
||||
enumerable: true,
|
||||
get: () => { reads.seed += 1; return reads.seed === 1 ? undefined : [] },
|
||||
}) as InProcessRunOptions
|
||||
|
||||
const run = startInProcessRun(ctx, request, options)
|
||||
|
||||
expect(reads).toEqual({ prompt: 1, toolFilter: 1, maxDepth: 1, outputSchema: 1, agentOptions: 1, persona: 1, seed: 1 })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('rejects an exotic seed before asynchronous owner setup can sanitize it', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
class ExoticSeedEvent {
|
||||
readonly type = 'turn/start'
|
||||
readonly seq = 0
|
||||
readonly time = 1
|
||||
readonly data = { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }
|
||||
}
|
||||
|
||||
expect(() => startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'accepted' }],
|
||||
parent,
|
||||
}, { seed: [new ExoticSeedEvent()] as unknown as SessionEvent[] }))
|
||||
.toThrow(/subagent seed must be losslessly JSON-serializable/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'tool filter',
|
||||
overrides: { toolFilter: { deny: [Number.NaN as unknown as string] } },
|
||||
message: 'subagent tool filter must be losslessly JSON-serializable',
|
||||
},
|
||||
{
|
||||
label: 'agent options',
|
||||
overrides: { agentOptions: { model: Number.NaN as unknown as string } },
|
||||
message: 'subagent agent options must be losslessly JSON-serializable',
|
||||
},
|
||||
{
|
||||
label: 'output schema',
|
||||
overrides: {
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: { answer: { type: Number.NaN } },
|
||||
} as unknown as NonNullable<SubagentStartRequest['outputSchema']>,
|
||||
},
|
||||
message: 'schema annotation must be JSON data',
|
||||
},
|
||||
])('rejects non-JSON $label before asynchronous child creation', async ({ overrides, message }) => {
|
||||
const { ctx, parent } = await setup([])
|
||||
|
||||
expect(() => startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'accepted' }],
|
||||
parent,
|
||||
...overrides,
|
||||
}, {})).toThrow(message)
|
||||
})
|
||||
|
||||
it('rejects a non-JSON model inherited from the parent before child creation', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const invalidParent = {
|
||||
options: { ...parent.options, model: Number.NaN as unknown as string },
|
||||
session: parent.session,
|
||||
} as unknown as Agent
|
||||
|
||||
expect(() => startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'accepted' }],
|
||||
parent: invalidParent,
|
||||
}, {})).toThrow('subagent agent options must be losslessly JSON-serializable')
|
||||
})
|
||||
|
||||
it('rejects when the run-owner fiber settles without installing its context', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
function inertOwner(): void {}
|
||||
const inertFiber = ctx.plugin(inertOwner)
|
||||
await inertFiber
|
||||
const parentWithoutOwnerContext = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: { plugin: () => inertFiber },
|
||||
} as unknown as Agent
|
||||
|
||||
const run = startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent: parentWithoutOwnerContext,
|
||||
}, {})
|
||||
await expect(run.result).rejects.toThrow('subagent run owner became inactive before child creation')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('normalizes a non-Error thrown while installing the run-owner fiber', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const setupFailure = 'non-Error owner setup failure'
|
||||
const parentWithFailingOwnerSetup = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: { plugin: () => { throw setupFailure } },
|
||||
} as unknown as Agent
|
||||
|
||||
const run = startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent: parentWithFailingOwnerSetup,
|
||||
}, {})
|
||||
await expect(run.result).rejects.toMatchObject({
|
||||
message: 'subagent run owner setup failed with a non-Error value',
|
||||
cause: setupFailure,
|
||||
})
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('normalizes a non-Error rejected by asynchronous child creation', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const creationFailure = 'non-Error child creation failure'
|
||||
function inertOwner(): void {}
|
||||
const ownerFiber = ctx.plugin(inertOwner)
|
||||
await ownerFiber
|
||||
const rejectWithNonError = (): Promise<never> => {
|
||||
// Deliberately violate the promise contract to exercise boundary normalization.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
return Promise.reject(creationFailure)
|
||||
}
|
||||
const rejectingOwnerCtx = {
|
||||
agents: { create: rejectWithNonError },
|
||||
} as unknown as Context
|
||||
const parentWithRejectingFactory = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: {
|
||||
plugin(plugin: (inner: Context) => void) {
|
||||
plugin(rejectingOwnerCtx)
|
||||
return ownerFiber
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
|
||||
const run = startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent: parentWithRejectingFactory,
|
||||
}, {})
|
||||
await expect(run.result).rejects.toMatchObject({
|
||||
message: 'subagent child creation failed with a non-Error value',
|
||||
cause: creationFailure,
|
||||
})
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('follows owner-fiber inertia when raw teardown was already in flight', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
let inertia: Promise<undefined> | undefined = gate.promise
|
||||
const fakeFiber = {
|
||||
dispose: vi.fn(() => undefined),
|
||||
get inertia() { return inertia },
|
||||
} as unknown as Fiber & PromiseLike<Fiber>
|
||||
const rejectingOwnerCtx = {
|
||||
agents: { create: () => Promise.reject(new Error('creation stopped by teardown')) },
|
||||
} as unknown as Context
|
||||
const parentWithDisposingOwner = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: {
|
||||
plugin(plugin: (inner: Context) => void) {
|
||||
plugin(rejectingOwnerCtx)
|
||||
return fakeFiber
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
const run = startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent: parentWithDisposingOwner,
|
||||
}, {})
|
||||
|
||||
let settled = false
|
||||
const disposing = run.dispose().then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(fakeFiber.dispose).toHaveBeenCalledOnce()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
inertia = undefined
|
||||
gate.resolve(undefined)
|
||||
await disposing
|
||||
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
|
||||
})
|
||||
|
||||
it('observes detached pre-readiness teardown failure and reports it to explicit dispose', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
function inertOwner(): void {}
|
||||
const ownerFiber = ctx.plugin(inertOwner)
|
||||
await ownerFiber
|
||||
const disposeFailure = new Error('owner dispose exploded')
|
||||
const disposeSpy = vi.spyOn(ownerFiber, 'dispose').mockImplementation(() => { throw disposeFailure })
|
||||
const rejectingOwnerCtx = {
|
||||
agents: { create: () => Promise.reject(new Error('creation stopped by cancellation')) },
|
||||
} as unknown as Context
|
||||
const parentWithFailingTeardown = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: {
|
||||
plugin(plugin: (inner: Context) => void) {
|
||||
plugin(rejectingOwnerCtx)
|
||||
return ownerFiber
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
const run = startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent: parentWithFailingTeardown,
|
||||
}, {})
|
||||
|
||||
run.cancel('cancel before readiness')
|
||||
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
|
||||
await expect(run.dispose()).rejects.toBe(disposeFailure)
|
||||
disposeSpy.mockRestore()
|
||||
await ownerFiber.dispose()
|
||||
})
|
||||
|
||||
it('does not attach an abort listener when provider ownership is already inactive', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
let providerCtx: Context | undefined
|
||||
function provider(inner: Context): void { providerCtx = inner }
|
||||
const providerFiber = await ctx.plugin(provider)
|
||||
await providerFiber.dispose()
|
||||
if (providerCtx === undefined) throw new Error('provider context was not captured')
|
||||
const inactiveProviderCtx = providerCtx
|
||||
|
||||
const controller = new AbortController()
|
||||
const addListener = vi.spyOn(controller.signal, 'addEventListener')
|
||||
expect(() => startInProcessRun(inactiveProviderCtx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent,
|
||||
signal: controller.signal,
|
||||
}, {})).toThrow(/inactive context/)
|
||||
expect(addListener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drives a fresh child (no seed) to completion and returns its output', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('driver child answer')])
|
||||
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, {})
|
||||
it('returns only after publication, drives a fresh child, and disposes it', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('driver answer')])
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
expect(ctx.agents.get(run.id)).toBeDefined()
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('driver child answer')
|
||||
expect(text(result.output)).toBe('driver answer')
|
||||
expect(depthOf(ctx.agents.get(run.id)!)).toBe(1)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('snapshots the prompt before asynchronous child creation', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const prompt = [{ type: 'text' as const, text: 'original prompt' }]
|
||||
const run = startInProcessRun(ctx, { prompt, parent }, {})
|
||||
|
||||
prompt[0]!.text = 'mutated after start'
|
||||
prompt.push({ type: 'text', text: 'also injected' })
|
||||
await run.result
|
||||
|
||||
const child = ctx.agents.get(run.id)!
|
||||
const userMessage = child.session.events.find(event => event.type === 'user/message')
|
||||
expect(userMessage?.type === 'user/message' && userMessage.data.content)
|
||||
.toEqual([{ type: 'text', text: 'original prompt' }])
|
||||
await run.dispose()
|
||||
expect(ctx.agents.get(run.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('throws SubagentDepthError when the child would exceed maxDepth', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, {}))
|
||||
.toThrow(SubagentDepthError)
|
||||
})
|
||||
|
||||
it('seeds the child session when a seed is supplied', async () => {
|
||||
// Drive the parent through one real turn, then seed the child with that
|
||||
// completed-turn prefix — the child must SEE the parent's history but its
|
||||
// result is scoped to its OWN events (not the seeded parent message).
|
||||
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('seeded child reply')])
|
||||
parent.send([{ type: 'text', text: 'parent q' }])
|
||||
it('seeds a forked child but reads only the child-owned output', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
|
||||
parent.send([{ type: 'text', text: 'parent question' }])
|
||||
await parent.whenIdle()
|
||||
const seed = parent.session.events.slice()
|
||||
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { seed })
|
||||
const run = await startInProcessRun(request(parent), { seed })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('seeded child reply')
|
||||
expect(text(result.output)).toBe('child answer')
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// The child inherited the parent's prefix.
|
||||
expect(child.session.events.slice(0, seed.length).some(e => e.type === 'user/message')).toBe(true)
|
||||
expect(child.session.header.seedLength).toBe(seed.length)
|
||||
expect(child.session.events.slice(0, seed.length)).toEqual(seed)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('rejects invalid and exceeded depth before publication', async () => {
|
||||
const { parent } = await setup([])
|
||||
await expect(startInProcessRun({ ...request(parent), maxDepth: -1 }, {}))
|
||||
.rejects.toThrow('non-negative safe integer')
|
||||
await expect(startInProcessRun({ ...request(parent), maxDepth: 0 }, {}))
|
||||
.rejects.toBeInstanceOf(SubagentDepthError)
|
||||
const maxParent = { options: { subagentDepth: Number.MAX_SAFE_INTEGER } } as unknown as Agent
|
||||
await expect(startInProcessRun(request(maxParent), {})).rejects.toBeInstanceOf(RangeError)
|
||||
})
|
||||
|
||||
it('rejects an already-aborted request without publishing a child', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const beforeAgents = ctx.agents.list().length
|
||||
const beforeSessions = ctx.sessions.list().length
|
||||
const controller = new AbortController()
|
||||
controller.abort('too late')
|
||||
await expect(startInProcessRun(request(parent, controller.signal), {}))
|
||||
.rejects.toThrow('aborted before child publication')
|
||||
expect(ctx.agents.list()).toHaveLength(beforeAgents)
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
|
||||
it('uses the request signal after publication and dispose as cancellation paths', async () => {
|
||||
const { parent } = await setup(['hang', 'hang'])
|
||||
const controller = new AbortController()
|
||||
const signalled = await startInProcessRun(request(parent, controller.signal), {})
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
controller.abort('stop child')
|
||||
await expect(signalled.result).resolves.toMatchObject({ stopReason: 'aborted' })
|
||||
await signalled.dispose()
|
||||
|
||||
const disposed = await startInProcessRun(request(parent), {})
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
await disposed.dispose()
|
||||
await expect(disposed.result).resolves.toMatchObject({ stopReason: 'aborted' })
|
||||
})
|
||||
|
||||
it('cleans a failed unpublished setup before rejecting', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const beforeAgents = ctx.agents.list().length
|
||||
const beforeSessions = ctx.sessions.list().length
|
||||
await expect(startInProcessRun({
|
||||
...request(parent),
|
||||
toolFilter: { deny: ['unknown-tool'] },
|
||||
}, {})).rejects.toThrow('unknown global tool')
|
||||
expect(ctx.agents.list()).toHaveLength(beforeAgents)
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
|
||||
it('closes the abort handoff after the factory detaches its creation listener', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const controller = new AbortController()
|
||||
const beforeAgents = ctx.agents.list().length
|
||||
const beforeSessions = ctx.sessions.list().length
|
||||
const parentWithAbortAtHandoff = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: {
|
||||
agents: {
|
||||
create: async (options: Parameters<typeof ctx.agents.create>[0]) => {
|
||||
const handle = await ctx.agents.create(options)
|
||||
// `create()` has detached its creation-only listener, but the
|
||||
// provider continuation has not installed its live-run listener.
|
||||
controller.abort('handoff race')
|
||||
return handle
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
await expect(startInProcessRun(request(parentWithAbortAtHandoff, controller.signal), {}))
|
||||
.rejects.toThrow('aborted before child publication')
|
||||
expect(ctx.agents.list()).toHaveLength(beforeAgents)
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user