Files
deepseek-harness/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts
T
_KermanandTianyi Cui fb79a944f5 refactor(llm): separate durable producer sources from request inputs
Give durable user context its producer-owned source kind instead of a generic plugin wrapper. The V3-to-V4 conversion preserves the frozen rename and collision rules, all own JSON metadata, event coordinates, and historical request-only kinds. Native catalog and JSONL admission retain source validation so unknown attribution can survive without its producer while malformed owned sources are refused.

Use an explicit user-only RequestUserInput for auxiliary LLM prompts that do not own durable message identity. Auto Review and compaction remove their request-only source registrations while retaining prompt content and freezing; provider serializers and projections keep the same wire data, assistant replay isolation, image ordering, and tool correlation. Session writes, Agent delivery, and recorded title requests continue to require identified durable messages.

Bind the saved compatibility policy only to user sources and qualify tmux location attribution without weakening fixed system, model, or tool semantics. Rebuild the current inventory and unpublished V4 acknowledgement on the actual tool-role parent, preserving its rationale and all accepted historical records. The catalog format version records tooling metadata; Session remains V4. Developer-role policy and activation belong to their own feature.

The latest-master Messages adapter, serialization, request-input typing, EpochHeader reservation, and attribution checks pass 103 tests across five files. The retained V3 Messages input-history snapshot passes through the source profile; Host/Client typechecks and all 41 documentation gates pass. The generated comparison records four changed roots and 447 unchanged type fingerprints. Latest-master ARIA changes are retained, with producer source labels applied independently; new Messages fixtures retain their producer names as declared source kinds and use the fixed system-message helper; recorded Session generations are unchanged by this regrouping.

Co-authored-by: Tianyi Cui <[email protected]>
2026-09-18 19:07:12 +08:00

530 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 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[] = []
const contexts = new Set<Context>()
afterEach(async () => {
vi.restoreAllMocks()
for (const ctx of contexts) await ctx.fiber.dispose()
contexts.clear()
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()
contexts.add(ctx)
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(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: readonly { 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 !== 'runtime-context'
? 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()
contexts.add(ctx)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
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 !== 'runtime-context'
? 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')
})
})