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.
366 lines
16 KiB
TypeScript
366 lines
16 KiB
TypeScript
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import { type Agent } from '@deepseek-ai/dsh-agent'
|
|
|
|
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
|
import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
|
|
import SubagentService, {
|
|
foldSubagentDescriptor,
|
|
snapshotSubagentDescriptor,
|
|
SUBAGENT_DESCRIPTOR_VERSION,
|
|
SubagentError,
|
|
assertSubagentMaxDepth,
|
|
type SubagentCapabilities,
|
|
type SubagentProvider,
|
|
type SubagentResult,
|
|
type SubagentRun,
|
|
type SubagentStartRequest,
|
|
} from '@deepseek-ai/dsh-subagent'
|
|
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
|
|
|
function fakeParent(id = 'parent-1'): Agent {
|
|
return { id: SessionId(id) } as unknown as Agent
|
|
}
|
|
|
|
const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }
|
|
const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
|
|
|
|
function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
|
|
return {
|
|
prompt: [{ type: 'text', text: 'do a thing' }],
|
|
parent: fakeParent(),
|
|
signal: new AbortController().signal,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
class StubProvider implements SubagentProvider {
|
|
readonly inheritsParentContext = false
|
|
startCount = 0
|
|
lastRequest: SubagentStartRequest | undefined
|
|
|
|
constructor(
|
|
readonly name: string,
|
|
readonly capabilities: SubagentCapabilities = ALL_CAPS,
|
|
private readonly outcome: SubagentResult = {
|
|
output: [{ type: 'text', text: 'ok' }],
|
|
stopReason: 'completed',
|
|
},
|
|
) {}
|
|
|
|
async start(request: SubagentStartRequest): Promise<SubagentRun> {
|
|
this.startCount += 1
|
|
this.lastRequest = request
|
|
return {
|
|
id: SessionId(`child:${this.name}:${request.parent.id}`),
|
|
localAgent: undefined,
|
|
result: Promise.resolve(this.outcome),
|
|
async dispose() {},
|
|
}
|
|
}
|
|
}
|
|
|
|
async function service(): Promise<{ ctx: Context; subagents: SubagentService }> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SubagentService)
|
|
return { ctx, subagents: ctx.subagents }
|
|
}
|
|
|
|
describe('SubagentService', () => {
|
|
it('registers, lists, looks up, starts, and removes providers', async () => {
|
|
const { ctx, subagents } = await service()
|
|
const added: string[] = []
|
|
const removed: string[] = []
|
|
ctx.on('subagent/provider-added', provider => void added.push(provider.name))
|
|
ctx.on('subagent/provider-removed', name => void removed.push(name))
|
|
const provider = new StubProvider('alpha')
|
|
|
|
const dispose = subagents.registerProvider(provider)
|
|
expect(subagents.list()).toEqual(['alpha'])
|
|
expect(subagents.getProvider('alpha')).toBe(provider)
|
|
const run = await subagents.start('alpha', baseRequest())
|
|
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
|
|
expect(provider.startCount).toBe(1)
|
|
|
|
dispose()
|
|
expect(added).toEqual(['alpha'])
|
|
expect(removed).toEqual(['alpha'])
|
|
expect(subagents.getProvider('alpha')).toBeUndefined()
|
|
})
|
|
|
|
it('rolls registration back when provider-added throws', async () => {
|
|
const { ctx, subagents } = await service()
|
|
ctx.on('subagent/provider-added', () => { throw new Error('added boom') })
|
|
expect(() => { subagents.registerProvider(new StubProvider('alpha')) }).toThrow('added boom')
|
|
expect(subagents.getProvider('alpha')).toBeUndefined()
|
|
})
|
|
|
|
it('rejects duplicate and absent provider names with typed errors', async () => {
|
|
const { subagents } = await service()
|
|
subagents.registerProvider(new StubProvider('dup'))
|
|
expect(() => { subagents.registerProvider(new StubProvider('dup')) })
|
|
.toThrow(expect.objectContaining({ code: 'DUPLICATE_PROVIDER' }))
|
|
await expect(subagents.start('missing', baseRequest()))
|
|
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
|
|
})
|
|
|
|
it('borrows ordinary start requests and exposes no provider continuation operations', async () => {
|
|
const { subagents } = await service()
|
|
const provider = new StubProvider('one-shot')
|
|
subagents.registerProvider(provider)
|
|
const request = baseRequest()
|
|
await subagents.start('one-shot', request)
|
|
|
|
// One-shot start borrows the caller's exact request; the seam has no
|
|
// provider-facing resume or steer surface to dispatch through.
|
|
expect(provider.lastRequest).toBe(request)
|
|
expectTypeOf<Parameters<SubagentService['start']>[1]>().toExtend<SubagentStartRequest>()
|
|
expect('resume' in subagents).toBe(false)
|
|
expect('resume' in provider).toBe(false)
|
|
})
|
|
|
|
it('does not expose manager teardown and treats a scoped drain as a no-op when no manager was bound', async () => {
|
|
const { subagents } = await service()
|
|
// Without `ctx.agents` no manager exists, so nothing was ever materialized.
|
|
expect('drainContinuable' in subagents).toBe(false)
|
|
await expect(subagents.drainContinuableDescendants([])).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('rejects continuable operations when their runtime services are absent', async () => {
|
|
const { subagents } = await service()
|
|
await expect(subagents.startContinuable({
|
|
provider: 'unused',
|
|
label: 'unused child',
|
|
request: baseRequest(),
|
|
signal: new AbortController().signal,
|
|
})).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' })
|
|
await expect(subagents.followup(
|
|
fakeParent(),
|
|
SessionId('child'),
|
|
[{ type: 'text', text: 'hello' }],
|
|
{ source: { kind: 'user' }, signal: new AbortController().signal },
|
|
)).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' })
|
|
})
|
|
|
|
it.each([
|
|
['outputSchema', { outputSchema: { type: 'object', properties: {} } }],
|
|
['depthLimit', { maxDepth: 1 }],
|
|
['toolFilter', { toolFilter: { deny: ['bash'] } }],
|
|
['persona', { persona: 'reviewer' }],
|
|
] as const)('rejects unsupported %s before provider startup', async (_capability, override) => {
|
|
const { subagents } = await service()
|
|
const provider = new StubProvider('weak', NO_CAPS)
|
|
subagents.registerProvider(provider)
|
|
await expect(subagents.start('weak', baseRequest(override)))
|
|
.rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' })
|
|
expect(provider.startCount).toBe(0)
|
|
})
|
|
|
|
it('validates depth and schema semantics before provider startup', async () => {
|
|
const { subagents } = await service()
|
|
const provider = new StubProvider('strong')
|
|
subagents.registerProvider(provider)
|
|
await expect(subagents.start('strong', baseRequest({ maxDepth: -1 })))
|
|
.rejects.toThrow('non-negative safe integer')
|
|
await expect(subagents.start('strong', baseRequest({ outputSchema: { type: 'string' } as never })))
|
|
.rejects.toThrow()
|
|
expect(provider.startCount).toBe(0)
|
|
expect(() => { assertSubagentMaxDepth(undefined) }).not.toThrow()
|
|
})
|
|
|
|
it('publishes lifecycle only after async provider start and keeps parent scope', async () => {
|
|
const { ctx, subagents } = await service()
|
|
const ready = Promise.withResolvers<SubagentRun>()
|
|
const result = Promise.withResolvers<SubagentResult>()
|
|
subagents.registerProvider({
|
|
name: 'deferred',
|
|
capabilities: NO_CAPS,
|
|
inheritsParentContext: false,
|
|
start: () => ready.promise,
|
|
})
|
|
const parent = fakeParent('delegator')
|
|
const events: string[] = []
|
|
const keys: unknown[] = []
|
|
const runIds: string[] = []
|
|
ctx.on('subagent/start', function (info) { events.push('start'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) })
|
|
ctx.on('subagent/end', function (info) { events.push('end'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) })
|
|
|
|
const starting = subagents.start('deferred', baseRequest({ parent }))
|
|
await Promise.resolve()
|
|
expect(events).toEqual([])
|
|
ready.resolve({ id: SessionId('child'), localAgent: undefined, result: result.promise, async dispose() {} })
|
|
const run = await starting
|
|
expect(events).toEqual(['start'])
|
|
result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' })
|
|
await run.result
|
|
await Promise.resolve()
|
|
expect(events).toEqual(['start', 'end'])
|
|
expect(keys).toEqual([parent, parent])
|
|
expect(runIds[0]).toBe(runIds[1])
|
|
})
|
|
|
|
it('mints distinct lifecycle identities when provider and child ids repeat', async () => {
|
|
const { ctx, subagents } = await service()
|
|
subagents.registerProvider(new StubProvider('reused'))
|
|
const runIds: string[] = []
|
|
ctx.on('subagent/start', info => void runIds.push(info.runId))
|
|
|
|
const first = await subagents.start('reused', baseRequest())
|
|
const second = await subagents.start('reused', baseRequest())
|
|
await Promise.all([first.result, second.result])
|
|
|
|
expect(runIds).toHaveLength(2)
|
|
expect(new Set(runIds).size).toBe(2)
|
|
})
|
|
|
|
it('emits no run lifecycle when provider startup rejects', async () => {
|
|
const { ctx, subagents } = await service()
|
|
subagents.registerProvider({
|
|
name: 'failed',
|
|
capabilities: NO_CAPS,
|
|
inheritsParentContext: false,
|
|
start: async () => { throw new Error('setup rolled back') },
|
|
})
|
|
const lifecycle = vi.fn()
|
|
ctx.on('subagent/start', lifecycle)
|
|
ctx.on('subagent/end', lifecycle)
|
|
await expect(subagents.start('failed', baseRequest())).rejects.toThrow('setup rolled back')
|
|
expect(lifecycle).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('emits an enriched end event and maps result rejection to error telemetry', async () => {
|
|
const { ctx, subagents } = await service()
|
|
const completed = new StubProvider('completed', NO_CAPS, {
|
|
output: [{ type: 'text', text: 'answer' }],
|
|
stopReason: 'completed',
|
|
})
|
|
subagents.registerProvider(completed)
|
|
const ended = vi.fn()
|
|
ctx.on('subagent/end', ended)
|
|
const run = await subagents.start('completed', baseRequest())
|
|
await run.result
|
|
await Promise.resolve()
|
|
expect(ended).toHaveBeenCalledWith(expect.objectContaining({
|
|
provider: 'completed',
|
|
lastAssistantMessage: [{ type: 'text', text: 'answer' }],
|
|
stopReason: 'completed',
|
|
}))
|
|
|
|
const failure = Promise.withResolvers<SubagentResult>()
|
|
subagents.registerProvider({
|
|
name: 'infra',
|
|
capabilities: NO_CAPS,
|
|
inheritsParentContext: false,
|
|
async start() {
|
|
return { id: SessionId('infra-child'), localAgent: undefined, result: failure.promise, async dispose() {} }
|
|
},
|
|
})
|
|
const failedRun = await subagents.start('infra', baseRequest())
|
|
failure.reject(new Error('transport'))
|
|
await expect(failedRun.result).rejects.toThrow('transport')
|
|
await Promise.resolve()
|
|
expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'infra', stopReason: 'error' }))
|
|
})
|
|
|
|
it('contains synchronous and asynchronous lifecycle observer failures', async () => {
|
|
const { ctx, subagents } = await service()
|
|
const warnings: string[] = []
|
|
ctx.logger.warn = ((message: unknown) => void warnings.push(String(message))) as typeof ctx.logger.warn
|
|
const heard: string[] = []
|
|
ctx.on('subagent/provider-removed', () => { throw new Error('sync boom') })
|
|
// Runtime listeners may return thenables even though the declaration's observable result is void.
|
|
// oxlint-disable-next-line typescript/no-misused-promises -- exercises rejected-listener containment
|
|
ctx.on('subagent/provider-removed', async () => { throw new Error('async boom') })
|
|
ctx.on('subagent/provider-removed', () => { throw { toString: () => { throw new Error('coercion') } } })
|
|
ctx.on('subagent/provider-removed', name => void heard.push(name))
|
|
const dispose = subagents.registerProvider(new StubProvider('contained'))
|
|
|
|
dispose()
|
|
await Promise.resolve()
|
|
expect(heard).toEqual(['contained'])
|
|
expect(warnings.some(message => message.includes('sync boom'))).toBe(true)
|
|
expect(warnings.some(message => message.includes('async boom'))).toBe(true)
|
|
expect(warnings.some(message => message.includes('<unrenderable thrown value>'))).toBe(true)
|
|
})
|
|
|
|
it('SubagentError participates in the harness error taxonomy', () => {
|
|
const error = new SubagentError('boom', 'NO_PROVIDER')
|
|
expect(error).toBeInstanceOf(HarnessError)
|
|
expect(error.name).toBe('SubagentError')
|
|
expect(error.code).toBe('NO_PROVIDER')
|
|
})
|
|
})
|
|
|
|
describe('subagent descriptors', () => {
|
|
const event = (data: unknown): SessionEvent<'subagent/descriptor'> => ({
|
|
type: 'subagent/descriptor',
|
|
data,
|
|
} as unknown as SessionEvent<'subagent/descriptor'>)
|
|
|
|
it('omits absent fields, recovers a complete payload, and rejects unsupported versions', () => {
|
|
expect(foldSubagentDescriptor([])).toBeUndefined()
|
|
const minimal = snapshotSubagentDescriptor({ provider: 'spawn', label: 'child work' })
|
|
expect(minimal).toEqual({
|
|
version: SUBAGENT_DESCRIPTOR_VERSION,
|
|
provider: 'spawn',
|
|
label: 'child work',
|
|
})
|
|
expect(foldSubagentDescriptor([event(minimal)])).toEqual(minimal)
|
|
const complete = {
|
|
version: SUBAGENT_DESCRIPTOR_VERSION,
|
|
provider: 'spawn',
|
|
label: 'complete child',
|
|
agentProvider: 'deepseek',
|
|
agentModel: 'chat',
|
|
persona: 'reviewer',
|
|
toolFilter: { allow: ['read'], deny: ['bash'] },
|
|
}
|
|
expect(snapshotSubagentDescriptor({
|
|
provider: complete.provider,
|
|
label: complete.label,
|
|
agentProvider: complete.agentProvider,
|
|
agentModel: complete.agentModel,
|
|
persona: complete.persona,
|
|
toolFilter: complete.toolFilter,
|
|
})).toEqual(complete)
|
|
expect(foldSubagentDescriptor([event(complete)])).toEqual(complete)
|
|
expect(foldSubagentDescriptor([
|
|
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', label: 'l', toolFilter: { allow: ['read'] } }),
|
|
])).toMatchObject({ toolFilter: { allow: ['read'] } })
|
|
expect(foldSubagentDescriptor([
|
|
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', label: 'l', toolFilter: { deny: ['bash'] } }),
|
|
])).toMatchObject({ toolFilter: { deny: ['bash'] } })
|
|
expect(foldSubagentDescriptor([
|
|
event({ version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' }),
|
|
])).toBeUndefined()
|
|
expect(() => snapshotSubagentDescriptor({
|
|
provider: 'spawn',
|
|
label: 'bad',
|
|
toolFilter: { deny: [Symbol('not-json')] as unknown as string[] },
|
|
})).toThrow('not losslessly JSON-serializable')
|
|
})
|
|
|
|
it.each([
|
|
['string payload', 'invalid', 'payload must be an object'],
|
|
['null payload', null, 'payload must be an object'],
|
|
['array payload', [], 'payload must be an object'],
|
|
['missing version', { provider: 'spawn' }, 'version must be a number'],
|
|
['string version', { version: '1', provider: 'spawn' }, 'version must be a number'],
|
|
['unknown payload field', { version: 1, provider: 'spawn', extra: true }, 'payload has unknown field "extra"'],
|
|
['missing provider', { version: 1 }, 'provider must be a string'],
|
|
['missing label', { version: 1, provider: 'spawn' }, 'label must be a string'],
|
|
['invalid label', { version: 1, provider: 'spawn', label: 7 }, 'label must be a string'],
|
|
['invalid provider', { version: 1, provider: 7 }, 'provider must be a string'],
|
|
['invalid agent provider', { version: 1, provider: 'spawn', label: 'l', agentProvider: 7 }, 'agentProvider must be a string'],
|
|
['invalid agent model', { version: 1, provider: 'spawn', label: 'l', agentModel: [] }, 'agentModel must be a string'],
|
|
['invalid persona', { version: 1, provider: 'spawn', label: 'l', persona: {} }, 'persona must be a string'],
|
|
['non-object tool filter', { version: 1, provider: 'spawn', label: 'l', toolFilter: [] }, 'toolFilter must be an object'],
|
|
['unknown tool-filter field', { version: 1, provider: 'spawn', label: 'l', toolFilter: { except: ['bash'] } }, 'toolFilter has unknown field "except"'],
|
|
['empty tool filter', { version: 1, provider: 'spawn', label: 'l', toolFilter: {} }, 'toolFilter must declare allow and/or deny'],
|
|
['non-array allow list', { version: 1, provider: 'spawn', label: 'l', toolFilter: { allow: 'read' } }, 'toolFilter.allow must be an array of strings'],
|
|
['non-string deny item', { version: 1, provider: 'spawn', label: 'l', toolFilter: { deny: [7] } }, 'toolFilter.deny must be an array of strings'],
|
|
])('rejects a malformed persisted descriptor: %s', (_case, data, detail) => {
|
|
expect(() => foldSubagentDescriptor([event(data)])).toThrow(detail)
|
|
})
|
|
})
|