Files
deepseek-harness/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts
T

527 lines
23 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 { ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { SessionId } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as tool from '../src/index.ts'
import { parkParent } from './park-parent.ts'
import { TestSessionQuery } from './test-session-query.ts'
import { loadStoredSession } from '../../subagent/tests/persistence-helpers.ts'
/** One scripted response that may wait on a caller-released gate before streaming. */
interface GatedEntry {
chunks: StreamChunk[]
gate?: Promise<undefined>
}
/** Adapter whose entries can hold a model call open until the test releases it. */
class GatedAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
constructor(private script: GatedEntry[]) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.script.shift()
if (!entry) throw new Error('GatedAdapter: script exhausted')
if (entry.gate) await entry.gate
for (const chunk of entry.chunks) {
if (options.signal?.aborted) throw new Error('aborted')
yield chunk
}
}
}
const testToolSignal = new AbortController().signal
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
})
async function setupWith(adapter: MockAdapter | GatedAdapter, park = true) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-control-'))
roots.push(root)
await ctx.plugin(JsonlSessionPersistence, { root })
await ctx.plugin(TestSessionQuery)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(SubagentFork, { providerName: 'fork' })
await ctx.plugin(tool)
ctx.llm.registerAdapter(['mock'], adapter)
const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
if (park) parkParent(ctx, parent)
return { ctx, parent, adapter }
}
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
return setupWith(new MockAdapter(script))
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
let calls = 0
function callTool(
ctx: Context,
name: string,
args: unknown,
agent?: unknown,
signal: AbortSignal = testToolSignal,
) {
return ctx.tools.execute({
signal,
callId: ToolCallId(`call-${++calls}`),
name,
arguments: args,
...agent !== undefined ? { agent: agent as never } : {},
})
}
/** Wait until a child's Activation released its handle. */
async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
await vi.waitFor(() => {
expect(ctx.agents.get(childId)).toBeUndefined()
}, { timeout: 5_000 })
}
describe('dsh-tool-subagent-control', () => {
it('registers send_message once, globally, with the two required parameters', async () => {
const { ctx } = await setup([])
const schemas = ctx.tools.schemas().filter(schema => schema.name === 'send_message')
expect(schemas).toHaveLength(1)
const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
expect(Object.keys(props).sort()).toEqual(['agent_id', 'message'])
// The continuable path has no Task, so the schema must not promise one.
expect(schemas[0]!.description).not.toContain('job_output')
expect(schemas[0]!.description).not.toContain('job id')
expect(schemas[0]!.description).toContain('nearest step')
expect(schemas[0]!.description).toContain('direct continuable child')
expect(schemas[0]!.description).toContain('If you are a resident continuable child')
expect(props.agent_id).toMatchObject({
description: 'The agent id of your direct continuable child, or your direct parent when you are a resident continuable child.',
})
})
it('keeps the send_message definition and ordering byte-identical in a fork child', async () => {
const release = Promise.withResolvers<undefined>()
const { ctx, parent, adapter } = await setupWith(new GatedAdapter([
{ chunks: textResponse('parent done') },
{ chunks: textResponse('child done'), gate: release.promise },
]), false)
parent.followup(createUserMessage({
content: [{ type: 'text', text: 'parent work' }],
source: { kind: 'user' },
}))
await parent.whenIdle()
parkParent(ctx, parent)
const started = await ctx.subagents.startContinuable({
provider: 'fork',
label: 'fork child',
request: { prompt: [{ type: 'text', text: 'fork task' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const child = ctx.agents.get(started.childId)
if (child === undefined) throw new Error('expected a live fork child')
const parentSchemas = ctx.tools.schemas(parent)
const childSchemas = ctx.tools.schemas(child)
expect(JSON.stringify(childSchemas)).toBe(JSON.stringify(parentSchemas))
expect(childSchemas.map(schema => schema.name)).not.toContain('report')
release.resolve(undefined)
await waitNoActivation(ctx, started.childId)
const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
const promptIndex = loaded.events.findIndex(event => event.type === 'user/message'
&& event.data.content.some(block => block.type === 'text' && block.text === 'fork task'))
expect(loaded.meta.isSeeded).toBe(true)
expect(promptIndex).toBeGreaterThanOrEqual(loaded.inheritedEventCount)
const prompt = loaded.events[promptIndex]
if (prompt?.type !== 'user/message') throw new Error('expected the initial fork task')
const texts = prompt.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
expect(texts[0]).toBe('fork task')
expect(texts[1]).toContain(`Your parent agent id is ${JSON.stringify(parent.id)}`)
expect(texts[1]).toContain(`send_message({ agent_id: ${JSON.stringify(parent.id)}`)
expect(texts[1]).not.toContain('report tool')
})
it('JSON-encodes a caller-supplied parent id in the initial return instruction', async () => {
const { ctx } = await setup([textResponse('child done')])
const parent = await ctx.agentLoop.create(SessionId('parent"\nagent'), { provider: 'mock', model: 'mock' })
parkParent(ctx, parent)
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'encoded parent',
request: { prompt: [{ type: 'text', text: 'encoded task' }], parent },
signal: testToolSignal,
})
await waitNoActivation(ctx, started.childId)
const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
const prompt = loaded.events.find(event => event.type === 'user/message'
&& event.data.content.some(block => block.type === 'text' && block.text === 'encoded task'))
if (prompt?.type !== 'user/message') throw new Error('expected the encoded initial task')
const guidance = prompt.data.content.findLast(block => block.type === 'text')?.text ?? ''
expect(guidance).toContain(`Your parent agent id is ${JSON.stringify(parent.id)}`)
expect(guidance).toContain(`agent_id: ${JSON.stringify(parent.id)}`)
expect(guidance).not.toContain(parent.id)
})
it('lets a continuable child steer its direct parent with send_message', async () => {
const release = Promise.withResolvers<undefined>()
const { ctx, parent, adapter } = await setupWith(new GatedAdapter([
{ chunks: textResponse('child done'), gate: release.promise },
]))
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'child task',
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)
if (child === undefined) throw new Error('expected a live child')
const delivered: Array<{ agent: Agent; message: ReturnType<typeof createUserMessage> }> = []
ctx.on('agent/inbox/inserted', ({ agent, message }) => {
if (agent === parent && message.source.kind === 'agent-message') delivered.push({ agent, message })
})
const result = await callTool(ctx, 'send_message', {
agent_id: parent.id,
message: 'CHILD_FINDING',
}, child)
expect(result.isError).toBe(false)
expect(text(result)).toBe(`message delivered to agent ${parent.id}`)
expect(delivered).toHaveLength(1)
expect(delivered[0]?.message.source).toEqual({
kind: 'agent-message',
form: 'relay',
senderSessionId: started.childId,
})
expect(delivered[0]?.message.content).toEqual([
{ type: 'text', text: `Agent ${started.childId} sent a message: ` },
{ type: 'text', text: 'CHILD_FINDING' },
])
release.resolve(undefined)
await waitNoActivation(ctx, started.childId)
})
it('cold-resumes a settled child and reports delivery', async () => {
const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'child task',
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
signal: testToolSignal,
})
await waitNoActivation(ctx, started.childId)
const result = await callTool(ctx, 'send_message', {
agent_id: started.childId,
message: 'and then?',
}, parent)
expect(result.isError).toBe(false)
expect(text(result)).toBe(`message delivered to agent ${started.childId}`)
await waitNoActivation(ctx, started.childId)
const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
const followUp = loaded.events.findLast(event => event.type === 'user/message')
// The durable message source records the calling agent without granting authority.
expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({
kind: 'agent-message',
form: 'relay',
senderSessionId: parent.id,
})
expect(followUp?.type === 'user/message' && followUp.data.content).toEqual([
{ type: 'text', text: `Agent ${parent.id} sent a message: ` },
{ type: 'text', text: 'and then?' },
])
})
it('steers the nearest step of an open turn', async () => {
const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')])
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'long work',
request: { prompt: [{ type: 'text', text: 'long work' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const result = await callTool(ctx, 'send_message', {
agent_id: started.childId,
message: 'also consider Y',
}, parent)
expect(result.isError).toBe(false)
await waitNoActivation(ctx, started.childId)
const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
? event.data.content.flatMap(block => block.type === 'text'
&& !block.text.startsWith('Your parent agent id is ')
? [block.text]
: [])
: [])
expect(prompts).toEqual([
'long work',
`Agent ${parent.id} sent a message: `,
'also consider Y',
])
})
it('reports a delivery failure as an errored, not-delivered result', async () => {
const { ctx, parent } = await setup([])
const result = await callTool(ctx, 'send_message', {
agent_id: 'no-such-child',
message: 'hello?',
}, parent)
expect(result.isError).toBe(true)
expect(text(result)).toContain('unavailable')
})
it('rejects a caller that is not the child\'s durable direct parent', async () => {
const { ctx, parent } = await setup([textResponse('first')])
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'child task',
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
signal: testToolSignal,
})
await waitNoActivation(ctx, started.childId)
const stranger = await ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' })
const result = await callTool(ctx, 'send_message', {
agent_id: started.childId,
message: 'mine now',
}, stranger)
expect(result.isError).toBe(true)
expect(text(result)).toContain('another parent session')
})
it('fails loud when invoked without a calling agent', async () => {
const { ctx } = await setup([])
const result = await callTool(ctx, 'send_message', { agent_id: 'x', message: 'y' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('requires a calling agent')
})
it('unregisters with its plugin fiber (HMR safety)', async () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const fiber = await ctx.plugin(tool)
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true)
expect(ctx.tools.schemas().some(schema => schema.name === 'interrupt_agent')).toBe(true)
await fiber.dispose()
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(false)
expect(ctx.tools.schemas().some(schema => schema.name === 'interrupt_agent')).toBe(false)
})
it('has the namespace-plugin export shape (no stray default)', () => {
expect('default' in tool).toBe(false)
expect(tool.name).toBe('tool-subagent-control')
expect(tool.inject).toEqual(['tools', 'subagents'])
expect(typeof tool.apply).toBe('function')
})
})
describe('dsh-tool-subagent-control interrupt_agent', () => {
it('registers interrupt_agent with the single agent_id parameter and current-turn wording', async () => {
const { ctx } = await setup([])
const schemas = ctx.tools.schemas().filter(schema => schema.name === 'interrupt_agent')
expect(schemas).toHaveLength(1)
const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
expect(Object.keys(props)).toEqual(['agent_id'])
expect(schemas[0]!.description).toContain('current turn')
expect(schemas[0]!.description).toContain('send_message')
})
it('interrupts a running direct child with the parent cause, parking its queue', async () => {
const releaseFirst = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('held'), gate: releaseFirst.promise },
{ chunks: textResponse('parked answer') },
{ chunks: textResponse('waking answer') },
])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'long work',
request: { prompt: [{ type: 'text', text: 'long work' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const queued = await callTool(ctx, 'send_message', {
agent_id: started.childId,
message: 'parked follow-up',
}, parent)
expect(queued.isError).toBe(false)
const cancelSpy = vi.spyOn(child, 'cancel')
const result = await callTool(ctx, 'interrupt_agent', { agent_id: started.childId }, parent)
expect(result.isError).toBe(false)
expect(text(result)).toBe(`interrupt requested for agent ${started.childId}`)
expect(cancelSpy).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' }, { keepInbox: true })
releaseFirst.resolve(undefined)
await child.whenIdle()
// Parked, not resumed: the steering waits for another waking send.
expect(adapter.requests).toHaveLength(1)
expect(child.inbox.nextStep).toHaveLength(1)
const waking = await callTool(ctx, 'send_message', {
agent_id: started.childId,
message: 'wake up',
}, parent)
expect(waking.isError).toBe(false)
await waitNoActivation(ctx, started.childId)
const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
? event.data.content.flatMap(block => block.type === 'text'
&& !block.text.startsWith('Your parent agent id is ')
? [block.text]
: [])
: [])
expect(prompts).toEqual([
'long work',
`Agent ${parent.id} sent a message: `,
'parked follow-up',
`Agent ${parent.id} sent a message: `,
'wake up',
])
})
it('lets a deep live ancestor interrupt a descendant it did not directly create', async () => {
const releaseChild = Promise.withResolvers<undefined>()
const releaseGrandchild = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('child'), gate: releaseChild.promise },
{ chunks: textResponse('grandchild'), gate: releaseGrandchild.promise },
])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'child',
request: { prompt: [{ type: 'text', text: 'child work' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const grandchild = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'grandchild',
request: { prompt: [{ type: 'text', text: 'grandchild work' }], parent: child },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const grandchildAgent = ctx.agents.get(grandchild.childId)!
const cancelSpy = vi.spyOn(grandchildAgent, 'cancel')
const result = await callTool(ctx, 'interrupt_agent', { agent_id: grandchild.childId }, parent)
expect(result.isError).toBe(false)
expect(cancelSpy).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' }, { keepInbox: true })
releaseChild.resolve(undefined)
releaseGrandchild.resolve(undefined)
await waitNoActivation(ctx, grandchild.childId)
await waitNoActivation(ctx, started.childId)
})
it('rejects self, sibling, and unrelated callers without touching the target', async () => {
const releaseA = Promise.withResolvers<undefined>()
const releaseB = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('a'), gate: releaseA.promise },
{ chunks: textResponse('b'), gate: releaseB.promise },
])
const { ctx, parent } = await setupWith(adapter)
const target = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'target',
request: { prompt: [{ type: 'text', text: 'a' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const sibling = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'sibling',
request: { prompt: [{ type: 'text', text: 'b' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const targetAgent = ctx.agents.get(target.childId)!
const siblingAgent = ctx.agents.get(sibling.childId)!
const stranger = await ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' })
const cancelSpy = vi.spyOn(targetAgent, 'cancel')
const self = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, targetAgent)
expect(self.isError).toBe(true)
expect(text(self)).toContain('cannot interrupt itself')
const fromSibling = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, siblingAgent)
expect(fromSibling.isError).toBe(true)
expect(text(fromSibling)).toContain('not a live descendant')
const fromStranger = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, stranger)
expect(fromStranger.isError).toBe(true)
expect(text(fromStranger)).toContain('not a live descendant')
expect(cancelSpy).not.toHaveBeenCalled()
releaseA.resolve(undefined)
releaseB.resolve(undefined)
await waitNoActivation(ctx, target.childId)
await waitNoActivation(ctx, sibling.childId)
})
it('accepts an absent target as a no-op without cold-resuming it', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'settled child',
request: { prompt: [{ type: 'text', text: 'child work' }], parent },
signal: testToolSignal,
})
await waitNoActivation(ctx, started.childId)
const settled = await callTool(ctx, 'interrupt_agent', { agent_id: started.childId }, parent)
expect(settled.isError).toBe(false)
expect(text(settled)).toBe(`interrupt requested for agent ${started.childId}`)
const unknown = await callTool(ctx, 'interrupt_agent', { agent_id: 'no-such-agent' }, parent)
expect(unknown.isError).toBe(false)
// No cold resume: the settled target never rematerialized.
expect(ctx.agents.get(started.childId)).toBeUndefined()
})
it('fails loud when invoked without a calling agent', async () => {
const { ctx } = await setup([])
const result = await callTool(ctx, 'interrupt_agent', { agent_id: 'x' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('requires a calling agent')
})
})