Steer, inject, and followup now land as durable user/message events on the session surface; the steering/message event type and its ConversationNode kind are removed from the client projection. Update tests, docs, generated catalogs, and agent notes to match, and align the steering e2e fixture and prompt inventory assertions with the durable user/message landing.
188 lines
7.5 KiB
TypeScript
188 lines
7.5 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 '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')
|
|
})
|
|
})
|