| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187 |
- 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<typeof MockAdapter>[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<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(['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',
- 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')
- })
- })
|