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 'cordis' import { CallId } 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 SubagentService from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as tool from '../src/index.ts' const testToolSignal = new AbortController().signal const roots: string[] = [] afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) async function setup(script: ConstructorParameters[0]) { 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(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(tool) const adapter = new MockAdapter(script) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent, adapter } } 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: CallId(`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 { 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 }).properties ?? {} expect(Object.keys(props).sort()).toEqual(['message', 'subagent_id']) // The continuable path has no Task, so the schema must not promise one. expect(schemas[0]!.description).not.toContain('task_output') expect(schemas[0]!.description).not.toContain('task id') // Follow-up ordering is model-visible: it cannot redirect the open turn. expect(schemas[0]!.description).toContain('next turn') }) it('cold-resumes a settled child and reports the queued next turn', 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', { subagent_id: started.childId, message: 'and then?', }, parent) expect(result.isError).toBe(false) expect(text(result)).toBe(`message queued as the next turn for subagent ${started.childId}`) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) const followUp = loaded.events.findLast(event => event.type === 'user/message') // Durable provenance records the calling agent without granting authority. expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({ kind: 'coordinator', form: 'relay', senderSessionId: parent.id, }) }) it('queues behind an open turn instead of joining it', 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', { subagent_id: started.childId, message: 'also consider Y', }, parent) expect(result.isError).toBe(false) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) const prompts = loaded.events.flatMap(event => event.type === 'user/message' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) // A follow-up is its own later turn, never steering inside the first one. expect(prompts).toEqual(['long work', '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', { subagent_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 = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) const result = await callTool(ctx, 'send_message', { subagent_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', { subagent_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(SubagentService) const fiber = await ctx.plugin(tool) expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true) await fiber.dispose() expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).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') }) })