refactor(agent): complete inbox lifecycle migration
This commit is contained in:
@@ -1,19 +1,35 @@
|
||||
/**
|
||||
* Shared driver for in-process subagent providers. The agent factory's
|
||||
* Shared driver for in-process ONE-SHOT subagent providers. The agent factory's
|
||||
* creation transaction owns unpublished setup and rollback; after publication
|
||||
* the returned AgentHandle is the one quiescent lifecycle owner held by the
|
||||
* provider's caller.
|
||||
*
|
||||
* Continuable children never come through here: the continuation manager
|
||||
* composes and drives them directly, so this driver owns exactly one turn with
|
||||
* one result.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-inprocess
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
import {
|
||||
applyChildComposition,
|
||||
assertSubagentMaxDepth,
|
||||
childSessionMeta,
|
||||
resolveChildAgentOptions,
|
||||
resolveChildDepth,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import type {
|
||||
ResolvedSubagentStartRequest,
|
||||
SubagentDescriptorData,
|
||||
SubagentResult,
|
||||
SubagentRun,
|
||||
SubagentStopReason,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
|
||||
// to the policy services when composed — the driver consumes both
|
||||
// opportunistically (the documented `ctx.get` pattern), never as a hard dep.
|
||||
@@ -29,14 +45,6 @@ export {
|
||||
STRUCTURED_OUTPUT_INSTRUCTION,
|
||||
} from './structured.ts'
|
||||
|
||||
/** Thrown when starting a child would exceed the requested depth cap. */
|
||||
class SubagentDepthError extends Error {
|
||||
constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) {
|
||||
super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`)
|
||||
this.name = 'SubagentDepthError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a session turn outcome to the subagent seam's terminal vocabulary. */
|
||||
function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
|
||||
switch (reason?.kind) {
|
||||
@@ -64,42 +72,42 @@ function prePublicationAbort(): Error {
|
||||
return new Error('subagent request was aborted before child publication')
|
||||
}
|
||||
|
||||
/** Append one one-shot descriptor inside the child's initial turn before its first request. */
|
||||
function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void {
|
||||
let appended = false
|
||||
childCtx.on('agent/pre-step', async (agent, _messages, _context, next) => {
|
||||
const decision = await next()
|
||||
if (!appended && decision.kind === 'enter') {
|
||||
appended = true
|
||||
agent.session.append('subagent/descriptor', descriptor)
|
||||
}
|
||||
return decision
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish and drive one in-process child. Fulfillment means the agent is
|
||||
* already published in the registry; rejection means the agent factory's
|
||||
* creation transaction and any partially-created child have reached quiescence.
|
||||
* Establish and drive one in-process one-shot child. Fulfillment means the agent
|
||||
* is already published in the registry and transfers its turn, cancellation,
|
||||
* and disposal work through the returned run. Rejection means the agent
|
||||
* factory's unpublished creation transaction reached quiescence without
|
||||
* publishing a child. Every start appends its resolved descriptor inside the
|
||||
* child's initial turn.
|
||||
* @param request - the trusted typed start request, including its required signal.
|
||||
* @param options - the optional fork seed.
|
||||
* @returns a ready holder-owned run.
|
||||
* @returns a published holder-owned run.
|
||||
*/
|
||||
export async function startInProcessRun(
|
||||
request: SubagentStartRequest,
|
||||
request: ResolvedSubagentStartRequest,
|
||||
options: InProcessRunOptions,
|
||||
): Promise<SubagentRun> {
|
||||
assertSubagentMaxDepth(request.maxDepth)
|
||||
if (request.signal.aborted) throw prePublicationAbort()
|
||||
const parent = request.parent
|
||||
const childDepth = delegationDepthOf(parent) + 1
|
||||
if (!Number.isSafeInteger(childDepth)) {
|
||||
throw new RangeError('subagent child depth exceeds the safe-integer range')
|
||||
}
|
||||
if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
|
||||
throw new SubagentDepthError(childDepth, request.maxDepth)
|
||||
}
|
||||
const childDepth = resolveChildDepth(parent, request.maxDepth)
|
||||
|
||||
const childId = SessionId(randomUUID())
|
||||
const seedLength = options.seed?.length ?? 0
|
||||
const parentHeader = parent.session.header
|
||||
const parentProvider = parent.options.provider
|
||||
const parentModel = parent.options.model
|
||||
const parentMaxTokens = parent.options.maxTokens
|
||||
const agentOptions: AgentOptions = {
|
||||
...parentProvider !== undefined ? { provider: parentProvider } : {},
|
||||
...parentModel !== undefined ? { model: parentModel } : {},
|
||||
...parentMaxTokens !== undefined ? { maxTokens: parentMaxTokens } : {},
|
||||
...request.agentOptions,
|
||||
subagentDepth: childDepth,
|
||||
}
|
||||
const seed = options.seed
|
||||
const activationBoundary = seed?.length ?? 0
|
||||
|
||||
// Capture before the first await: a later parent switch belongs to the
|
||||
// parent's future.
|
||||
@@ -108,6 +116,8 @@ export async function startInProcessRun(
|
||||
|
||||
let structured: StructuredAttachment | undefined
|
||||
const setup = (childCtx: Context): void => {
|
||||
// Inherited overrides land on the child's own log, so its effective policy
|
||||
// is reconstructable from that log alone.
|
||||
const childSession = (childCtx.agent as Agent).session
|
||||
if (inheritedMode !== undefined) {
|
||||
childSession.append('sandbox/mode', { mode: inheritedMode, source: 'delegation' })
|
||||
@@ -115,61 +125,72 @@ export async function startInProcessRun(
|
||||
if (inheritedPolicy !== undefined) {
|
||||
childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' })
|
||||
}
|
||||
if (request.persona !== undefined) {
|
||||
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona })
|
||||
}
|
||||
if (request.toolFilter !== undefined) childCtx.tools.restrict(request.toolFilter)
|
||||
applyChildComposition(childCtx, {
|
||||
persona: request.persona,
|
||||
toolFilter: request.toolFilter,
|
||||
})
|
||||
if (request.outputSchema !== undefined) {
|
||||
structured = attachStructuredRuntime(childCtx, request.outputSchema)
|
||||
}
|
||||
attachDescriptorAppend(childCtx, request.descriptor)
|
||||
}
|
||||
|
||||
const flags = { cancelled: false }
|
||||
const handle = await parent.ctx.agents.create({
|
||||
sessionId: childId,
|
||||
meta: {
|
||||
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
|
||||
parentSession: parentHeader.id,
|
||||
// Durable: the recursion budget must survive persistence and resume.
|
||||
delegationDepth: childDepth,
|
||||
...seedLength > 0 ? { seedLength } : {},
|
||||
},
|
||||
...options.seed === undefined ? {} : { seed: options.seed },
|
||||
agentOptions,
|
||||
meta: childSessionMeta(parent, childDepth, activationBoundary),
|
||||
...seed !== undefined ? { seed } : {},
|
||||
agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth),
|
||||
signal: request.signal,
|
||||
setup,
|
||||
})
|
||||
const child = handle.agent
|
||||
// Agent creation detaches its creation-only abort listener before returning.
|
||||
// Close the narrow handoff race before installing the live-run listener.
|
||||
// Static analysis does not model the abort that may land between the
|
||||
// factory's listener detachment and this continuation.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
if (request.signal.aborted) {
|
||||
flags.cancelled = true
|
||||
await handle.dispose()
|
||||
throw prePublicationAbort()
|
||||
}
|
||||
return drivePublishedRun(
|
||||
handle,
|
||||
request.signal,
|
||||
request.prompt,
|
||||
childId,
|
||||
activationBoundary,
|
||||
structured,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a published child in the single run lifecycle that owns signal handoff,
|
||||
* one turn, result settlement, and quiescent disposal.
|
||||
*/
|
||||
function drivePublishedRun(
|
||||
handle: AgentHandle,
|
||||
signal: AbortSignal,
|
||||
prompt: ContentBlock[],
|
||||
childId: SessionId,
|
||||
boundary: number,
|
||||
structured: StructuredAttachment | undefined,
|
||||
): SubagentRun {
|
||||
const child = handle.agent
|
||||
const flags = { cancelled: false }
|
||||
const onAbort = (): void => {
|
||||
flags.cancelled = true
|
||||
child.cancel({ kind: 'parent' })
|
||||
}
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
// Agent creation detaches its creation-only listener before returning. The
|
||||
// post-registration check closes that handoff without treating an already
|
||||
// published child as a failed start.
|
||||
if (signal.aborted) onAbort()
|
||||
|
||||
const result: Promise<SubagentResult> = (async () => {
|
||||
try {
|
||||
const message = createUserMessage({ content: request.prompt, source: { kind: 'user' } })
|
||||
child.followup(message)
|
||||
await child.whenIdle()
|
||||
if (!flags.cancelled) {
|
||||
child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } }))
|
||||
await child.whenIdle()
|
||||
}
|
||||
return readResult(
|
||||
child,
|
||||
seedLength,
|
||||
boundary,
|
||||
flags.cancelled,
|
||||
structured ? { captured: structured.captured() } : undefined,
|
||||
)
|
||||
} finally {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -177,31 +198,33 @@ export async function startInProcessRun(
|
||||
id: childId,
|
||||
localAgent: child,
|
||||
result,
|
||||
dispose(): Promise<void> {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
async dispose(): Promise<void> {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
flags.cancelled = true
|
||||
return handle.dispose()
|
||||
const settlements = await Promise.allSettled([handle.dispose(), result])
|
||||
const disposal = settlements[0]
|
||||
// The result channel owns run faults; disposal reports only failure to
|
||||
// release the published handle after both operations settle.
|
||||
if (disposal.status === 'rejected') throw disposal.reason
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one settled child's result from events after its optional fork seed. */
|
||||
/** Read one settled child's result from events after its activation boundary. */
|
||||
function readResult(
|
||||
child: Agent,
|
||||
seedLength: number,
|
||||
boundary: number,
|
||||
cancelled: boolean,
|
||||
structured?: { captured?: { value: unknown } | undefined },
|
||||
): SubagentResult {
|
||||
const own = child.session.events.slice(seedLength)
|
||||
const own = child.session.events.slice(boundary)
|
||||
const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message')
|
||||
const lastEnd = own.findLast((event): event is SessionEvent<'turn/end'> => event.type === 'turn/end')
|
||||
const lastEnd = findLastMessageTurnEnd(own)
|
||||
const output: ContentBlock[] = lastMessage?.data.message.content ?? []
|
||||
const recorded = toStopReason(lastEnd?.data.reason)
|
||||
// A requested cancellation owns every non-completed in-flight outcome; a
|
||||
// turn already completed stays so.
|
||||
const stopReason: SubagentStopReason = cancelled && recorded !== 'completed'
|
||||
? 'aborted'
|
||||
: recorded
|
||||
// Disposal can tear the owner down before the loop records its ordinary
|
||||
// `aborted` end, yielding `disposed` instead.
|
||||
const stopReason: SubagentStopReason = cancelled && recorded !== 'completed' ? 'aborted' : recorded
|
||||
if (structured !== undefined) {
|
||||
if (structured.captured !== undefined) {
|
||||
return { output, structured: structured.captured.value, stopReason }
|
||||
|
||||
@@ -9,7 +9,7 @@ import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import SubagentService, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { startInProcessRun } from '../src/index.ts'
|
||||
|
||||
@@ -35,7 +35,17 @@ async function setup(script: Script, parentOptions: Partial<AgentOptions> = {})
|
||||
}
|
||||
|
||||
function request(parent: Agent, signal = new AbortController().signal) {
|
||||
return { prompt: [{ type: 'text' as const, text: 'child task' }], parent, signal }
|
||||
return {
|
||||
label: 'child task',
|
||||
prompt: [{ type: 'text' as const, text: 'child task' }],
|
||||
parent,
|
||||
signal,
|
||||
descriptor: snapshotSubagentDescriptor({
|
||||
mode: 'one-shot',
|
||||
provider: 'test',
|
||||
label: 'child task',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function text(blocks: readonly { type: string; text?: string }[]): string {
|
||||
@@ -56,27 +66,92 @@ describe('startInProcessRun', () => {
|
||||
expect(ctx.agents.get(run.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports the final whole-agent outcome after idle replacement work', async () => {
|
||||
const { ctx, parent } = await setup([maxTokensResponse('partial answer'), textResponse('replacement answer')])
|
||||
let replaced = false
|
||||
ctx.on('agent/status', (agent, status) => {
|
||||
if (replaced || status !== 'idle' || agent.session.header.parentSession === undefined) return
|
||||
replaced = true
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'replacement work' }],
|
||||
source: { kind: 'plugin', plugin: 'replacement' },
|
||||
}))
|
||||
it('uses explicit child model selectors when the parent has none and preserves its cwd', async () => {
|
||||
const { ctx } = await setup([textResponse('driver answer')])
|
||||
const parent = ctx.agentLoop.create(SessionId('bare-parent'), {}, { cwd: '/workspace' })
|
||||
const run = await startInProcessRun({
|
||||
...request(parent),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
}, {})
|
||||
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(child.options).toMatchObject({ provider: 'mock', model: 'mock' })
|
||||
expect(child.session.header.cwd).toBe('/workspace')
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('does not add a final durability checkpoint to a foreground run', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('driver answer')])
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session.header.parentSession === undefined) return
|
||||
flushes++
|
||||
throw new Error('disk full')
|
||||
})
|
||||
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
|
||||
expect(flushes).toBe(0)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('keeps published run and handle disposal failures on separate channels', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const runError = new Error('published run failed')
|
||||
const disposalError = new Error('published handle disposal failed')
|
||||
const beforeAgents = ctx.agents.list().length
|
||||
const beforeSessions = ctx.sessions.list().length
|
||||
const parentWithFailedDisposal = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: {
|
||||
get: () => undefined,
|
||||
agents: {
|
||||
create: async (options: Parameters<typeof ctx.agents.create>[0]) => {
|
||||
const handle = await ctx.agents.create(options)
|
||||
handle.agent.followup = () => { throw runError }
|
||||
return {
|
||||
...handle,
|
||||
dispose: async () => {
|
||||
await handle.dispose()
|
||||
throw disposalError
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
|
||||
const run = await startInProcessRun(request(parentWithFailedDisposal), {})
|
||||
expect(ctx.agents.get(run.id)).toBeDefined()
|
||||
await expect(run.result).rejects.toBe(runError)
|
||||
await expect(run.dispose()).rejects.toBe(disposalError)
|
||||
expect(ctx.agents.list()).toHaveLength(beforeAgents)
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
it('reports the turn outcome when later metadata is appended during flush', async () => {
|
||||
const { ctx, parent } = await setup([maxTokensResponse('partial answer')])
|
||||
let injected = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (injected || session.header.parentSession === undefined) return
|
||||
const lastEnd = session.events.findLast(event => event.type === 'turn/end')
|
||||
if (lastEnd?.type !== 'turn/end' || lastEnd.data.reason.kind !== 'max-tokens') return
|
||||
injected = true
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'late metadata' }],
|
||||
source: { kind: 'plugin', plugin: 'late-metadata' },
|
||||
}), { surfaceOp: 'append' })
|
||||
})
|
||||
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
const result = await run.result
|
||||
const child = ctx.agents.get(run.id)!
|
||||
|
||||
expect(replaced).toBe(true)
|
||||
expect(injected).toBe(false)
|
||||
expect(child.session.events.findLast(event => event.type === 'turn/end'))
|
||||
.toMatchObject({ data: { reason: { kind: 'completed' } } })
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('replacement answer')
|
||||
.toMatchObject({ data: { reason: { kind: 'max-tokens' } } })
|
||||
expect(result.stopReason).toBe('max-tokens')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
@@ -94,13 +169,16 @@ describe('startInProcessRun', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('persists the child depth in its session header', async () => {
|
||||
it('persists the child origin and depth in its session header', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('child answer')])
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
await run.result
|
||||
// The recursion budget is durable session data, not only runtime options —
|
||||
// a depth that lived only in AgentOptions would reset to 0 on resume.
|
||||
expect(ctx.agents.get(run.id)!.session.header.delegationDepth).toBe(1)
|
||||
expect(ctx.agents.get(run.id)!.session.header).toMatchObject({
|
||||
origin: 'subagent',
|
||||
delegationDepth: 1,
|
||||
})
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
@@ -179,6 +257,21 @@ describe('startInProcessRun', () => {
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
|
||||
it('stamps only the resolved depth when neither parent nor request declares a model route', async () => {
|
||||
// The one-shot analogue of the deleted resume coverage ("resumes without
|
||||
// inventing undeclared agent model options"): a bare parent with no request
|
||||
// agentOptions yields a child whose options carry ONLY the stamped depth —
|
||||
// no provider/model is fabricated, so the child's turn errors for want of a
|
||||
// route rather than silently adopting one.
|
||||
const { ctx } = await setup([])
|
||||
const parent = ctx.agentLoop.create(SessionId('routeless-parent'), {})
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(child.options).toEqual({ subagentDepth: 1 })
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'error' })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('uses the request signal after publication and dispose as cancellation paths', async () => {
|
||||
const { parent, adapter } = await setup(['hang', 'hang'])
|
||||
const controller = new AbortController()
|
||||
@@ -189,10 +282,7 @@ describe('startInProcessRun', () => {
|
||||
expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' })
|
||||
const child = parent.ctx.agents.get(signalled.id)
|
||||
const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({
|
||||
kind: 'aborted',
|
||||
reason: { kind: 'parent' },
|
||||
})
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'parent' } })
|
||||
await signalled.dispose()
|
||||
|
||||
const disposed = await startInProcessRun(request(parent), {})
|
||||
@@ -213,7 +303,7 @@ describe('startInProcessRun', () => {
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
|
||||
it('closes the abort handoff after the factory detaches its creation listener', async () => {
|
||||
it('treats abort after factory publication as a cancelled run with an id', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const controller = new AbortController()
|
||||
const beforeAgents = ctx.agents.list().length
|
||||
@@ -229,15 +319,17 @@ describe('startInProcessRun', () => {
|
||||
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.
|
||||
// published run has not installed its live listener yet.
|
||||
controller.abort('handoff race')
|
||||
return handle
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
await expect(startInProcessRun(request(parentWithAbortAtHandoff, controller.signal), {}))
|
||||
.rejects.toThrow('aborted before child publication')
|
||||
const run = await startInProcessRun(request(parentWithAbortAtHandoff, controller.signal), {})
|
||||
expect(ctx.agents.get(run.id)).toBeDefined()
|
||||
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
|
||||
await run.dispose()
|
||||
expect(ctx.agents.list()).toHaveLength(beforeAgents)
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user