Files
deepseek-harness/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts
Hypatia May 85dd22fd4f feat(subagent): deliver continuable child settlement to parents
A continuable child that stopped without reporting — an error, a token
ceiling, cancellation, teardown — left its parent nothing to act on.
The continuation manager now delivers an unconditional settlement
notice to the durable direct parent before releasing ownership, folding
consumed work (foldConsumedWork supersedes findLastMessageTurnEnd) so a
claimed-but-unrun prompt reads as aborted rather than completed, waking
an idle parent, steering a busy one, and never waking a closing tree.
2026-08-11 12:31:49 +08:00

560 lines
24 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { assembleContextFor } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import * as control from '@deepseek-ai/dsh-tool-subagent-control'
import { textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as tool from '../src/index.ts'
const testSignal = new AbortController().signal
/** Adapter that keeps child Activations resident until released. */
class HeldAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
private readonly gate = Promise.withResolvers<undefined>()
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
await this.gate.promise
for (const chunk of textResponse('held answer')) {
if (options.signal?.aborted) throw new Error('aborted')
yield chunk
}
}
release(): void {
this.gate.resolve(undefined)
}
}
const cleanups: (() => Promise<void>)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
})
/** Boot the real continuation graph with optional report installation. */
async function setup(options: { load?: boolean; config?: tool.Config } = {}) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-report-'))
await ctx.plugin(JsonlSessionPersistence, { root })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
const fiber = options.load === false
? undefined
: await ctx.plugin(tool, options.config ?? { reportDelivery: 'quiet' })
const adapter = new HeldAdapter()
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
cleanups.push(async () => {
adapter.release()
await ctx.fiber.dispose()
rmSync(root, { recursive: true, force: true })
})
return { ctx, parent, adapter, fiber }
}
/** Start and resolve one resident continuable child. */
async function startChild(ctx: Context, parent: Agent, prompt = 'child task') {
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: prompt,
request: {
prompt: [{ type: 'text', text: prompt }],
parent,
},
signal: testSignal,
})
const child = await vi.waitFor(() => {
const live = ctx.agents.get(started.childId)
expect(live).toBeDefined()
return live as Agent
})
return { started, child }
}
let calls = 0
function callReport(ctx: Context, child: Agent, output: string, signal = testSignal) {
return ctx.tools.execute({
signal,
callId: CallId(`report-${++calls}`),
name: 'report',
arguments: { output },
agent: child,
})
}
/** Occupy the child-local report name to force installation rollback. */
function registerReportConflict(child: Agent): () => void {
return child.ctx.tools.register({
name: 'report',
description: 'conflicting report fixture',
parameters: { type: 'object', properties: {} },
output: { schema: { type: 'object', properties: {} }, render: () => [] },
execute: () => Promise.resolve({}),
})
}
/** Reports already visible or still pending in one Agent. */
function reports(agent: Agent): { id: string; text: string; sender: string }[] {
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: message.id,
text: message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n'),
sender: message.source.senderSessionId,
}]
})
}
function renderedText(result: { content: { type: string; text?: string }[] }): string {
return result.content.flatMap(block => block.type === 'text' ? [block.text ?? ''] : []).join('')
}
/** The prompt sections one agent's scope assembles, by name. */
async function sectionNames(ctx: Context, agent: Agent): Promise<string[]> {
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
return assembly.sections.map(section => section.name)
}
describe('dsh-tool-subagent-report', () => {
it('registers report only in continuable child scopes', async () => {
const { ctx, parent } = await setup()
expect(ctx.tools.schemas().map(schema => schema.name)).not.toContain('report')
expect(ctx.tools.schemas(parent).map(schema => schema.name)).not.toContain('report')
const { child } = await startChild(ctx, parent)
const schemas = ctx.tools.schemas(child).filter(schema => schema.name === 'report')
expect(schemas).toHaveLength(1)
const properties = (schemas[0]?.parameters as { properties: Record<string, unknown> }).properties
expect(Object.keys(properties)).toEqual(['output'])
})
it('adds no implicit capability when the package is absent', async () => {
const { ctx, parent } = await setup({ load: false })
const { child } = await startChild(ctx, parent)
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
expect((await callReport(ctx, child, 'missing')).isError).toBe(true)
})
it('does not imply parent controls and survives a global-tool allow-list', async () => {
const { ctx, parent } = await setup()
expect(ctx.tools.schemas().map(schema => schema.name)).not.toContain('send_message')
await ctx.plugin(control)
expect(ctx.tools.schemas().map(schema => schema.name)).toContain('send_message')
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'restricted child',
request: {
prompt: [{ type: 'text', text: 'restricted child' }],
parent,
toolFilter: { allow: [] },
},
signal: testSignal,
})
const child = await vi.waitFor(() => {
const live = ctx.agents.get(started.childId)
expect(live).toBeDefined()
return live as Agent
})
const names = ctx.tools.schemas(child).map(schema => schema.name)
expect(names).toContain('report')
expect(names).not.toContain('send_message')
})
it('delivers quiet reports with stable message and sender identities without waking', async () => {
const { ctx, parent, adapter } = await setup()
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/inserted', ({ agent, message }) => {
if (agent === parent) {
enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering')
}
})
const result = await callReport(ctx, child, 'CHILD_FINDING')
expect(result.isError).toBe(false)
if (result.isError) throw new Error('report unexpectedly failed')
const messageId = (result.value as { messageId: string }).messageId
expect(renderedText(result)).toContain(messageId)
expect(reports(parent)).toEqual([{
id: messageId,
text: `Background subagent ${started.childId} reported:\nCHILD_FINDING`,
sender: started.childId,
}])
expect(enqueues).toEqual(['steering'])
expect(parent.status).toBe('idle')
expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(parentRequests)
})
it('queues wakeup reports as one later parent turn', async () => {
const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } })
const { child } = await startChild(ctx, parent)
const enqueues: string[] = []
ctx.on('agent/inbox/inserted', ({ agent, message }) => {
if (agent === parent) {
enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering')
}
})
const result = await callReport(ctx, child, 'WAKE_UP')
expect(result.isError).toBe(false)
expect(enqueues).toEqual(['queued'])
await vi.waitFor(() => {
expect(adapter.requests.some(request => request.sessionId === parent.id)).toBe(true)
})
})
it('preserves accepted order across repeated reports', async () => {
const { ctx, parent } = await setup()
const { child } = await startChild(ctx, parent)
expect((await callReport(ctx, child, 'FIRST')).isError).toBe(false)
expect((await callReport(ctx, child, 'SECOND')).isError).toBe(false)
expect(reports(parent).map(report => report.text.split('\n').at(-1))).toEqual(['FIRST', 'SECOND'])
})
it('keeps an accepted report after the child settles', async () => {
const { ctx, parent, adapter } = await setup()
const { started, child } = await startChild(ctx, parent)
expect((await callReport(ctx, child, 'DURABLE_SELECTION')).isError).toBe(false)
adapter.release()
await vi.waitFor(() => {
expect(ctx.agents.get(started.childId) === undefined).toBe(true)
}, { timeout: 5_000 })
expect(reports(parent).map(report => report.text)).toEqual([
`Background subagent ${started.childId} reported:\nDURABLE_SELECTION`,
])
})
it('routes nested reports exactly one edge upward', async () => {
const { ctx, parent, adapter } = await setup()
const { child } = await startChild(ctx, parent, 'outer task')
const { started: grandchildStart, child: grandchild } = await startChild(ctx, child, 'inner task')
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 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)
expect(reports(child)[0]?.text).toContain('FROM_GRANDCHILD')
})
it('accounts wakeup reports delivered to a resident continuable parent', async () => {
const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } })
const { child } = await startChild(ctx, parent, 'outer task')
const { started: grandchildStart, child: grandchild } = await startChild(ctx, child, 'inner task')
expect((await callReport(ctx, grandchild, 'WAKE_PARENT_CHILD')).isError).toBe(false)
expect(ctx.agents.get(child.id)).toBe(child)
adapter.release()
await vi.waitFor(() => { expect(reports(child)).toHaveLength(1) })
expect(reports(child)[0]?.sender).toBe(grandchildStart.childId)
expect(reports(child)[0]?.text).toContain('WAKE_PARENT_CHILD')
})
it('normalizes a direct parent send rejection', async () => {
const { ctx, parent } = await setup()
const { child } = await startChild(ctx, parent)
vi.spyOn(parent, 'inject').mockImplementationOnce(() => {
throw new Error('parent closed during delivery')
})
await expect(ctx.subagents.reportFrom(child, [{ type: 'text', text: 'rejected' }], {
delivery: 'quiet',
signal: testSignal,
})).rejects.toMatchObject({ code: 'PARENT_UNAVAILABLE' })
expect(reports(parent)).toEqual([])
})
it('rejects roots, forged same-id senders, absent parents, cancellation, and drain', async () => {
const { ctx, parent, adapter } = await setup()
await expect(ctx.subagents.reportFrom(parent, [{ type: 'text', text: 'root' }], {
delivery: 'quiet',
signal: testSignal,
})).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
const disposable = await ctx.agents.create({
sessionId: SessionId('disposable-parent'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const { child } = await startChild(ctx, disposable.agent)
const forged = { ...child } as Agent
await expect(ctx.subagents.reportFrom(forged, [{ type: 'text', text: 'forged' }], {
delivery: 'quiet',
signal: testSignal,
})).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
const aborted = new AbortController()
aborted.abort()
expect((await callReport(ctx, child, 'cancelled', aborted.signal)).isError).toBe(true)
await disposable.dispose()
expect((await callReport(ctx, child, 'orphaned')).isError).toBe(true)
adapter.release()
const draining = ctx.subagents.drainContinuableDescendants([child])
await expect(ctx.subagents.reportFrom(child, [{ type: 'text', text: 'draining' }], {
delivery: 'quiet',
signal: testSignal,
})).rejects.toMatchObject({ code: 'DRAINING' })
await draining
})
it('revokes resident installations and defers later grants to the next Activation', async () => {
const { ctx, parent, fiber } = await setup()
const { child } = await startChild(ctx, parent)
expect(ctx.tools.schemas(child).map(schema => schema.name)).toContain('report')
expect(await sectionNames(ctx, child)).toContain('tool:report')
await fiber?.dispose()
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
expect((await callReport(ctx, child, 'revoked')).isError).toBe(true)
const late = await ctx.plugin(tool, { reportDelivery: 'quiet' })
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
await late.dispose()
})
it('rolls back prompt guidance when tool registration fails', async () => {
const { ctx, parent } = await setup({ load: false })
const { child } = await startChild(ctx, parent)
const disposeConflict = registerReportConflict(child)
expect(() => tool.installReportTool(child.ctx, ctx, 'quiet')).toThrow(/already registered in this scope/)
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
disposeConflict()
})
it('aggregates a registration failure with a prompt rollback failure', async () => {
const { ctx, parent } = await setup({ load: false })
const { child } = await startChild(ctx, parent)
const disposeConflict = registerReportConflict(child)
const rollbackFailure = new Error('prompt rollback listener failed')
let promptChanges = 0
const off = ctx.on('system-prompt/change', () => {
promptChanges++
if (promptChanges === 2) throw rollbackFailure
})
let failure: unknown
try {
tool.installReportTool(child.ctx, ctx, 'quiet')
} catch (error: unknown) {
failure = error
}
off()
expect(failure).toBeInstanceOf(AggregateError)
if (!(failure instanceof AggregateError)) throw new Error('expected aggregate installation failure')
expect(failure.errors).toHaveLength(2)
expect(String(failure.errors[0])).toContain('already registered in this scope')
expect(failure.errors[1]).toBe(rollbackFailure)
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
disposeConflict()
})
it('attempts both revocations and aggregates change-listener failures', async () => {
const { ctx, parent } = await setup({ load: false })
const { child } = await startChild(ctx, parent)
const dispose = tool.installReportTool(child.ctx, ctx, 'quiet')
const toolFailure = new Error('tool removal listener failed')
const promptFailure = new Error('prompt removal listener failed')
const offTool = ctx.on('tools/change', () => { throw toolFailure })
const offPrompt = ctx.on('system-prompt/change', () => { throw promptFailure })
let failure: unknown
try {
dispose()
} catch (error: unknown) {
failure = error
}
offPrompt()
offTool()
expect(failure).toBeInstanceOf(AggregateError)
if (!(failure instanceof AggregateError)) throw new Error('expected aggregate revocation failure')
expect(failure.errors).toEqual([toolFailure, promptFailure])
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
})
it('scopes the report guidance to the child that owns it', async () => {
const { ctx, parent } = await setup()
const { child } = await startChild(ctx, parent, 'first child')
const { child: sibling } = await startChild(ctx, parent, 'second child')
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(child))
const guidance = assembly.sections.find(section => section.name === 'tool:report')
// Pins the model-visible instruction that makes the return channel a
// contract rather than an option the child may quietly skip.
expect(guidance?.text).toContain('Deliver your result with the report tool before you finish')
expect(guidance?.text).toContain('reporting never ends your turn')
expect(await sectionNames(ctx, parent)).not.toContain('tool:report')
// A sibling installs its own copy; neither child can observe the other's.
expect(await sectionNames(ctx, sibling)).toContain('tool:report')
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name))
.not.toContain('tool:report')
})
it('rolls back materialization when a setup contribution revokes itself', async () => {
const { ctx, parent } = await setup({ load: false })
const self: { revoke?: () => void } = {}
self.revoke = ctx.subagents.registerContinuableSetup((childCtx) => {
const dispose = childCtx.tools.register({
name: 'racing-report',
description: 'racing setup',
parameters: { type: 'object', properties: {} },
output: { schema: { type: 'object', properties: {} }, render: () => [] },
execute: () => Promise.resolve({}),
})
self.revoke?.()
return dispose
})
// No session may be announced for the rejected child: the setup
// validation must reject inside the creation callback, before the factory
// publishes — a post-publication rejection would persist a resumable
// ghost that `list_agents` surfaces and `send_message` can resurrect.
// The parent was created inside setup(), so any later announcement is the
// rejected child's.
const announced: SessionId[] = []
const listener = (session: { id: SessionId }): void => { announced.push(session.id) }
const removeListener = ctx.on('session/created', listener)
await expect(ctx.subagents.startContinuable({
provider: 'spawn',
label: 'racing child',
request: {
prompt: [{ type: 'text', text: 'racing child' }],
parent,
},
signal: testSignal,
})).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' })
removeListener()
expect(announced).toEqual([])
expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id])
})
it('rolls back materialization when setup revocation lands before publication', async () => {
const { ctx, parent } = await setup({ load: false })
const self: { revoke?: () => void } = {}
let installed = false
self.revoke = ctx.subagents.registerContinuableSetup(() => {
installed = true
queueMicrotask(() => { self.revoke?.() })
return () => { installed = false }
})
const announced: SessionId[] = []
const removeListener = ctx.on('session/created', (session) => { announced.push(session.id) })
await expect(ctx.subagents.startContinuable({
provider: 'spawn',
label: 'revoked child',
request: {
prompt: [{ type: 'text', text: 'revoked child' }],
parent,
},
signal: testSignal,
})).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' })
removeListener()
expect(installed).toBe(false)
expect(announced).toEqual([])
expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id])
expect(ctx.sessions.list()).toEqual([parent.session])
})
it('accepts a report into a host-disposing but still-registered parent', async () => {
const { ctx } = await setup()
const parentHandle = await ctx.agents.create({
sessionId: SessionId('disposing-parent'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const { child } = await startChild(ctx, parentHandle.agent)
// Host-owned disposal starts asynchronously; the parent stays registered
// until quiescence, and registry presence — not disposal state — is the
// acceptance gate (pins the README contract).
const disposing = parentHandle.dispose()
const accepted = await callReport(ctx, child, 'during-close')
expect(accepted.isError).toBe(false)
await disposing
expect((await callReport(ctx, child, 'after-close')).isError).toBe(true)
})
it('keeps the namespace plugin shape and validates its default', () => {
expect('default' in tool).toBe(false)
expect(tool.name).toBe('tool-subagent-report')
expect(tool.inject).toEqual(['subagents', 'tools', 'systemPrompt'])
// Waking is the default because a report that never wakes its parent
// cannot deliver a result to an agent that already parked.
expect(tool.Config({}).reportDelivery).toBe('wakeup')
expect(() => tool.Config({ reportDelivery: 'shout' } as never)).toThrow()
})
it('wakes the parent under the default configuration', async () => {
const { ctx, parent, adapter } = await setup({ config: {} })
const { child } = await startChild(ctx, parent)
const enqueues: string[] = []
ctx.on('agent/inbox/inserted', ({ agent, message }) => {
if (agent === parent) {
enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering')
}
})
expect((await callReport(ctx, child, 'DEFAULT_WAKES')).isError).toBe(false)
expect(enqueues).toEqual(['queued'])
await vi.waitFor(() => {
expect(adapter.requests.some(request => request.sessionId === parent.id)).toBe(true)
})
})
})
/** Prove report delivery uses ordinary logged user messages (runtime-context snapshots excluded). */
function userTexts(events: readonly SessionEvent[]): string[] {
return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
}
describe('dsh-tool-subagent-report result independence', () => {
it('does not report a final assistant answer automatically or create Tasks', async () => {
const { ctx, parent, adapter } = await setup()
const { started } = await startChild(ctx, parent)
adapter.release()
await vi.waitFor(() => {
expect(ctx.agents.get(started.childId) === undefined).toBe(true)
}, { timeout: 5_000 })
// The parent does learn the child settled — that account is the
// continuation service's, carried under its own `subagent-settled` source.
// Nothing turns the child's final answer into a report it did not send.
expect(reports(parent)).toEqual([])
expect(userTexts((await ctx.sessionPersistence.load(started.childId)).events)).toEqual(['child task'])
expect(ctx.get('tasks')).toBeUndefined()
})
})