refactor(agent): complete inbox lifecycle migration

This commit is contained in:
_Kerman
2026-08-03 12:25:33 +08:00
parent dc1d542092
commit 49e90695cc
214 changed files with 6019 additions and 4235 deletions

View File

@@ -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 }

View File

@@ -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)
})

View File

@@ -847,16 +847,13 @@ export class SubagentContinuationManager {
// quiet Agent from one whose accepted turn has not been admitted yet.
// Registered through the child's own scoped context, so scope filtering
// already restricts both listeners to this exact agent.
handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => {
/* v8 ignore next -- a dequeue of an id this manager never admitted needs
handle.agent.ctx.on('agent/inbox/claimed', (_agent, { message }) => {
/* v8 ignore next -- a claim of an id this manager never admitted needs
* another sender on the same child, which no current path allows. */
if (activation.accepted.delete(item.message.id)) this.wake(activation)
if (activation.accepted.delete(message.id)) this.wake(activation)
})
handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => {
// Deleting every id in the batch is unconditional; waking once afterwards
// costs nothing and avoids branching on which ids this manager admitted.
for (const item of items) activation.accepted.delete(item.message.id)
this.wake(activation)
handle.agent.ctx.on('agent/inbox/discarded', (_agent, { message }) => {
if (activation.accepted.delete(message.id)) this.wake(activation)
})
// Agent creation committed setup at its publication boundary;
// revocations from here on are immediate live revocation.

View File

@@ -207,7 +207,6 @@ function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopR
return 'max-tokens'
case 'aborted':
case 'interrupted':
case 'disposed':
return 'aborted'
case 'error':
return 'error'

View File

@@ -142,11 +142,24 @@ async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void>
}, { timeout: 5_000 })
}
/** Observe calls at the Agent cancellation boundary without a production event. */
function observeCancel(agent: Agent, callback: () => void): void {
const cancel = agent.cancel.bind(agent)
let observed = false
vi.spyOn(agent, 'cancel').mockImplementation((cause, options) => {
if (!observed) {
observed = true
callback()
}
cancel(cause, options)
})
}
describe('SubagentService.startContinuable', () => {
it('returns both identities at inbox acceptance, without waiting for the turn or the log', async () => {
const { ctx, parent, adapter } = await setup([textResponse('first answer')])
const enqueued: { id: MessageId; loggedYet: boolean }[] = []
ctx.on('agent/inbox/enqueue', (agent, accepted) => {
ctx.on('agent/inbox/inserted', (agent, accepted) => {
// Acceptance is the boundary `startContinuable` resolves at, so observe
// the log state exactly there rather than after later microtasks.
enqueued.push({ id: accepted.message.id, loggedYet: hasUserText(agent.session.events, 'child task') })
@@ -737,7 +750,9 @@ describe('continuable durability and teardown', () => {
const grandchild = await ctx.subagents.startContinuable(startSpec(targetChild))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(3) })
const cancellations: SessionId[] = []
ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) })
observeCancel(targetChild, () => { cancellations.push(targetChild.id) })
const grandchildAgent = ctx.agents.get(grandchild.childId)!
observeCancel(grandchildAgent, () => { cancellations.push(grandchildAgent.id) })
const drained = ctx.subagents.drainContinuableDescendants([parent])
const convergedDrain = ctx.subagents.drainContinuableDescendants([parent])
@@ -784,7 +799,8 @@ describe('continuable durability and teardown', () => {
const grandchild = await ctx.subagents.startContinuable(startSpec(child))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const cancellations: SessionId[] = []
ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) })
const grandchildAgent = ctx.agents.get(grandchild.childId)!
observeCancel(grandchildAgent, () => { cancellations.push(grandchildAgent.id) })
const drained = ctx.subagents.drainContinuableDescendants([child])
@@ -828,7 +844,8 @@ describe('continuable durability and teardown', () => {
expect(ctx.agents.get(intermediateId)).toBeUndefined()
expect(ctx.agents.get(descendant.childId)).toBeDefined()
const cancellations: SessionId[] = []
ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) })
const descendantAgent = ctx.agents.get(descendant.childId)!
observeCancel(descendantAgent, () => { cancellations.push(descendantAgent.id) })
const drained = ctx.subagents.drainContinuableDescendants([parent])
@@ -926,7 +943,7 @@ describe('continuable durability and teardown', () => {
const drains: Promise<void>[] = []
const accepted: MessageId[] = []
ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) })
ctx.on('agent/inbox/enqueue', (_agent, item) => { accepted.push(item.message.id) })
ctx.on('agent/inbox/inserted', (_agent, item) => { accepted.push(item.message.id) })
await expect(ctx.subagents.startContinuable(startSpec(parent)))
.rejects.toMatchObject({ code: 'DRAINING' })
@@ -967,12 +984,12 @@ describe('continuable durability and teardown', () => {
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const order: string[] = []
child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
child.ctx.on('agent/inbox/inserted', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'before drain')) {
order.push('enqueue')
}
})
child.ctx.on('agent/cancel-requested', () => { order.push('cancel') })
observeCancel(child, () => { order.push('cancel') })
const delivery = followup(ctx, parent, started.childId, message('before drain'))
// Let the child-lock operation reach the live admission cutoff. Admission
@@ -1150,9 +1167,9 @@ describe('continuable review regressions', () => {
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
// Block the resumed prompt so this epoch produces nothing of its own.
ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => {
ctx.on('agent/pre-step', async (subject, _messages, _context, next) => {
if (subject === parent) return next()
return { kind: 'block', reason: 'blocked by policy' }
return { kind: 'reject' }
})
await followup(ctx, parent, started.childId, message('again'))
await waitNoActivation(ctx, started.childId)
@@ -1258,7 +1275,7 @@ describe('continuable review regressions', () => {
expect(found).toBeDefined()
return found!
})
child.ctx.on('agent/cancel-requested', () => { order.push('cancel') })
observeCancel(child, () => { order.push('cancel') })
const drained = drainManager(ctx)
hold.resolve(undefined)
@@ -1298,7 +1315,7 @@ describe('continuable review regressions', () => {
// Cancel from the synchronous enqueue observer: the discard fires after the
// id is recorded but before `followup()` returns.
const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
child.cancel({ kind: 'user' })
}
@@ -1330,7 +1347,7 @@ describe('continuable review regressions', () => {
await followup(ctx, parent, started.childId, message('queued'))
expect(activation.accepted.size).toBe(1)
const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
child.cancel({ kind: 'user' })
}
@@ -1348,9 +1365,9 @@ describe('continuable review regressions', () => {
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
// Block admission so the child's only turn never opens.
ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => {
ctx.on('agent/pre-step', async (subject, _messages, _context, next) => {
if (subject === parent) return next()
return { kind: 'block', reason: 'blocked by policy' }
return { kind: 'reject' }
})
const started = await ctx.subagents.startContinuable(startSpec(parent))
@@ -1370,7 +1387,7 @@ describe('continuable review regressions', () => {
const registeredAtEnqueue: boolean[] = []
// A synchronous inbox observer runs before the admitting microtask, the
// exact window where `Agent.status` is still idle.
ctx.on('agent/inbox/enqueue', (agent) => {
ctx.on('agent/inbox/inserted', (agent) => {
if (agent.session.header.parentSession !== undefined) {
registeredAtEnqueue.push(ctx.agents.get(agent.id) === agent)
}

View File

@@ -116,7 +116,6 @@ describe('SubagentService.listChildren', () => {
const child = ctx.sessions.create(childId, { meta: { parentSession: parentId } })
child.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
child.append('subagent/descriptor', descriptorPayload('query-only child'))
@@ -234,7 +233,7 @@ describe('SubagentService.listChildren', () => {
// descriptor and the parent lineage, without starting an Activation.
const liveId = SessionId('live-child')
const live = ctx.sessions.create(liveId, { meta: { parentSession: parent.id } })
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
live.append('turn/start', { turn: 1 })
live.append('subagent/descriptor', descriptorPayload('live child'))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toContainEqual({

View File

@@ -96,14 +96,15 @@ function callReport(ctx: Context, child: Agent, output: string, signal = testSig
})
}
/** Reports durably visible in one Agent's Session. */
/** Reports already visible or still pending in one Agent. */
function reports(agent: Agent): { id: string; text: string; sender: string }[] {
return agent.session.events.flatMap((event) => {
if (event.type !== 'user/message' || event.data.source.kind !== 'subagent-report') return []
const visible = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
return [...visible, ...agent.inbox.nextStep].flatMap((message) => {
if (message.source.kind !== 'subagent-report') return []
return [{
id: event.data.id,
text: event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n'),
sender: event.data.source.senderSessionId,
id: message.id,
text: message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n'),
sender: message.source.senderSessionId,
}]
})
}
@@ -163,8 +164,10 @@ describe('dsh-tool-subagent-report', () => {
const { started, child } = await startChild(ctx, parent)
const parentRequests = adapter.requests.filter(request => request.sessionId === parent.id).length
const enqueues: string[] = []
ctx.on('agent/inbox/enqueue', (agent, item) => {
if (agent === parent) enqueues.push(item.placement)
ctx.on('agent/inbox/inserted', (agent, item) => {
if (agent === parent) {
enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering')
}
})
const result = await callReport(ctx, child, 'CHILD_FINDING')
@@ -178,7 +181,7 @@ describe('dsh-tool-subagent-report', () => {
text: `Background subagent ${started.childId} reported:\nCHILD_FINDING`,
sender: started.childId,
}])
expect(enqueues).toEqual([])
expect(enqueues).toEqual(['steering'])
expect(parent.status).toBe('idle')
expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(parentRequests)
})
@@ -187,8 +190,10 @@ describe('dsh-tool-subagent-report', () => {
const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } })
const { child } = await startChild(ctx, parent)
const enqueues: string[] = []
ctx.on('agent/inbox/enqueue', (agent, item) => {
if (agent === parent) enqueues.push(item.placement)
ctx.on('agent/inbox/inserted', (agent, item) => {
if (agent === parent) {
enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering')
}
})
const result = await callReport(ctx, child, 'WAKE_UP')
@@ -227,9 +232,9 @@ describe('dsh-tool-subagent-report', () => {
expect((await callReport(ctx, grandchild, 'FROM_GRANDCHILD')).isError).toBe(false)
expect(reports(parent)).toEqual([])
// The intermediate parent's turn is open, so quiet context is staged until
// that turn reaches its next safe log boundary.
expect(reports(child)).toEqual([])
// The intermediate parent's turn is open, so quiet context is pending in
// its inbox until that turn reaches its next safe log boundary.
expect(reports(child)).toHaveLength(1)
adapter.release()
await vi.waitFor(() => { expect(reports(child)).toHaveLength(1) })
expect(reports(child)[0]?.sender).toBe(grandchildStart.childId)