feat(subagent): durable child catalog and list_agents
Implements the durable-subagent-catalog RFC: SubagentControlService.listChildren() enumerates a parent's direct continuable children from one sessionQuery trace, validates each child's sole subagent/descriptor event (now carrying the durable creation label), and returns one ordered SubagentListEntry[] with per-child corrupt/unsupported/unavailable diagnostics. The list_agents tool ships as a separately loadable plugin of dsh-tool-subagent-control requiring sessionQuery at load; send_message stays usable without it.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
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 type { SubagentListEntry } 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 { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts'
|
||||
import * as tool from '../src/list-agents.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-list-agents-'))
|
||||
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(TestSessionQueryService)
|
||||
await ctx.plugin(tool)
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
return { ctx, parent }
|
||||
}
|
||||
|
||||
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) {
|
||||
return ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId(`call-${++calls}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...agent !== undefined ? { agent: agent as never } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/** Wait until a continuable child released its current Activation. */
|
||||
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/list-agents', () => {
|
||||
it('registers list_agents once, globally, with no parameters', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const schemas = ctx.tools.schemas().filter(schema => schema.name === 'list_agents')
|
||||
expect(schemas).toHaveLength(1)
|
||||
const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
|
||||
expect(Object.keys(props)).toEqual([])
|
||||
expect(schemas[0]!.description).toContain('send_message')
|
||||
})
|
||||
|
||||
it('renders the empty result as (no subagents)', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
await ctx.sessions.flush(parent.session)
|
||||
const result = await callTool(ctx, 'list_agents', {}, parent)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe('(no subagents)')
|
||||
})
|
||||
|
||||
it('renders children and diagnostics in array order with the fixed text forms', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'real child',
|
||||
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
|
||||
signal: testToolSignal,
|
||||
})
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
// Pin the render deterministically past the service: the tool is a thin
|
||||
// adapter, so its fixed text forms are what this test pins.
|
||||
const entries: SubagentListEntry[] = [
|
||||
{ kind: 'child', id: started.childId, label: 'real child', status: 'complete' },
|
||||
{ kind: 'diagnostic', id: SessionId('broken-child'), reason: 'corrupt' },
|
||||
]
|
||||
ctx.subagents.listChildren = () => Promise.resolve(entries)
|
||||
const result = await callTool(ctx, 'list_agents', {}, parent)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(
|
||||
`${started.childId} [complete] — real child\nbroken-child [diagnostic: corrupt]`,
|
||||
)
|
||||
})
|
||||
|
||||
it('lists a real settled child end-to-end with its durable label', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'summarize the doc',
|
||||
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
|
||||
signal: testToolSignal,
|
||||
})
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
const result = await callTool(ctx, 'list_agents', {}, parent)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(`${started.childId} [complete] — summarize the doc`)
|
||||
})
|
||||
|
||||
it('fails loud when invoked without a calling agent', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const result = await callTool(ctx, 'list_agents', {})
|
||||
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)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
const fiber = await ctx.plugin(tool)
|
||||
expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(true)
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(false)
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape and requires sessionQuery at load', () => {
|
||||
expect('default' in tool).toBe(false)
|
||||
expect(tool.name).toBe('tool-subagent-list-agents')
|
||||
expect(tool.inject).toEqual(['tools', 'subagents', 'sessionQuery'])
|
||||
expect(typeof tool.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -82,6 +82,7 @@ describe('dsh-tool-subagent-control', () => {
|
||||
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,
|
||||
})
|
||||
@@ -109,6 +110,7 @@ describe('dsh-tool-subagent-control', () => {
|
||||
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,
|
||||
})
|
||||
@@ -144,6 +146,7 @@ describe('dsh-tool-subagent-control', () => {
|
||||
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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user