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:
Dudu-0223
2026-07-26 02:32:34 +08:00
committed by Tianyi Cui
parent c7acc8fc6c
commit 4240c7dd7b
55 changed files with 1128 additions and 94 deletions

View File

@@ -34,6 +34,7 @@
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -42,6 +43,9 @@
"@deepseek-ai/dsh-session-persistence": {
"optional": true
},
"@deepseek-ai/dsh-session-query": {
"optional": true
},
"@deepseek-ai/dsh-tasks": {
"optional": true
}
@@ -54,6 +58,7 @@
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -59,6 +59,8 @@ declare module '@deepseek-ai/dsh-llm' {
export interface ContinuableStartSpec {
/** The `ctx.subagents` provider whose continuable-creation capability establishes the child. */
readonly provider: string
/** The initial delegation's short `description`, persisted as the child's creation label. */
readonly label: string
/**
* The delegation request. The manager reserves the stable child id, resolves
* the durable descriptor, and composes the child itself.
@@ -297,6 +299,7 @@ export class SubagentContinuationManager {
const agentModel = request.agentOptions?.model ?? parent.options.model
const descriptor = snapshotSubagentDescriptor({
provider: spec.provider,
label: spec.label,
...agentProvider !== undefined ? { agentProvider } : {},
...agentModel !== undefined ? { agentModel } : {},
...request.persona !== undefined ? { persona: request.persona } : {},

View File

@@ -47,6 +47,12 @@ export interface SubagentDescriptorData {
readonly version: number
/** The `ctx.subagents` provider name that established the child. */
readonly provider: string
/**
* The initial delegation's short `description`, kept as the child's durable
* creation label so enumeration can identify the conversation without
* replaying parent tool results or exposing the child prompt.
*/
readonly label: string
/** Resolved child `agentOptions.provider`, when one was declared. */
readonly agentProvider?: string
/** Resolved child `agentOptions.model`, when one was declared. */
@@ -61,6 +67,8 @@ export interface SubagentDescriptorData {
export interface SubagentDescriptorInput {
/** The `ctx.subagents` provider name that will establish the child. */
readonly provider: string
/** The initial delegation's short `description`, the durable creation label. */
readonly label: string
/** Requested child `agentOptions.provider`. */
readonly agentProvider?: string
/** Requested child `agentOptions.model`. */
@@ -74,6 +82,7 @@ export interface SubagentDescriptorInput {
const DESCRIPTOR_KEYS = new Set([
'version',
'provider',
'label',
'agentProvider',
'agentModel',
'persona',
@@ -151,6 +160,10 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef
if (typeof provider !== 'string') {
throw new Error('persisted subagent descriptor provider must be a string')
}
const label = value['label']
if (typeof label !== 'string') {
throw new Error('persisted subagent descriptor label must be a string')
}
const agentProvider = optionalString(value, 'agentProvider')
const agentModel = optionalString(value, 'agentModel')
const persona = optionalString(value, 'persona')
@@ -160,6 +173,7 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef
return {
version: SUBAGENT_DESCRIPTOR_VERSION,
provider,
label,
...agentProvider !== undefined ? { agentProvider } : {},
...agentModel !== undefined ? { agentModel } : {},
...persona !== undefined ? { persona } : {},
@@ -180,6 +194,7 @@ export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): Suba
const candidate: SubagentDescriptorData = {
version: SUBAGENT_DESCRIPTOR_VERSION,
provider: input.provider,
label: input.label,
...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {},
...input.agentModel !== undefined ? { agentModel: input.agentModel } : {},
...input.persona !== undefined ? { persona: input.persona } : {},

View File

@@ -36,6 +36,11 @@ import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import {
assertSessionHeadersCompatible,
SessionQueryError,
} from '@deepseek-ai/dsh-session-query'
import type { SessionQueryService, SessionRecord } from '@deepseek-ai/dsh-session-query'
import type {
ContinuableCreateRequest,
ContinuableCreateSpec,
@@ -47,6 +52,7 @@ import type {
SubagentStartRequest,
} from './types.ts'
import { SubagentError } from './error.ts'
import { foldSubagentDescriptor } from './descriptor.ts'
import { assertSubagentMaxDepth } from './depth.ts'
import { createActivationObserver, createLifecycleEmitter, observeRun } from './lifecycle.ts'
import type { ActivationObserver, LifecycleEmitter } from './lifecycle.ts'
@@ -96,6 +102,28 @@ export type {
} from './continuation.ts'
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
/**
* One direct-child enumeration result. Descriptor-less ordinary children are
* omitted; a per-child inspection failure remains visible as a diagnostic.
*/
export type SubagentListEntry =
| {
readonly kind: 'child'
/** Durable child session id, stable across Activations. */
readonly id: SessionId
/** Durable creation label from the child's descriptor. */
readonly label: string
/** Whether the child is currently live or exists only in persistence. */
readonly status: 'running' | 'complete'
}
| {
readonly kind: 'diagnostic'
/** Traced candidate session id. */
readonly id: SessionId
/** Fixed reason the candidate could not be returned as a child. */
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
}
declare module 'cordis' {
interface Context {
subagents: SubagentService
@@ -218,6 +246,80 @@ export class SubagentService extends Service {
await manager.drainDescendants(parents)
}
/**
* Enumerate one session's direct continuable children from the durable,
* live-preferred corpus without loading or resuming an Agent. The lineage
* trace supplies stable candidate order and live status; each candidate is
* then inspected independently for exactly one supported descriptor in its
* own suffix.
* @param parentSessionId - parent whose direct children are listed.
* @returns child and diagnostic entries in lineage-trace order.
*/
async listChildren(parentSessionId: SessionId): Promise<SubagentListEntry[]> {
const query = this.ctx.get('sessionQuery')
if (query === undefined) {
throw new SubagentError(
'listing subagents requires session query (load a dsh-session-query backend)',
'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE',
)
}
const trace = await query.traceSession(parentSessionId)
const entries: SubagentListEntry[] = []
for (const node of trace.descendants) {
const entry = await this.inspectChild(query, parentSessionId, node.session)
if (entry !== undefined) entries.push(entry)
}
return entries
}
/** Inspect one traced candidate without materializing its Agent. */
private async inspectChild(
query: SessionQueryService,
parentSessionId: SessionId,
candidate: SessionRecord,
): Promise<SubagentListEntry | undefined> {
const childId = candidate.header.id
try {
const records = await query.listEvents(childId)
// Fork seeds replay ancestor events, so only this child's suffix owns its descriptor.
const seedLength = candidate.header.seedLength ?? 0
const descriptorSeqs = records
.filter(record => record.seq >= seedLength && record.type === 'subagent/descriptor')
.map(record => record.seq)
if (descriptorSeqs.length === 0) return undefined
if (descriptorSeqs.length > 1) {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
// The length-one branch proves this index exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const seq = descriptorSeqs[0]!
const window = await query.readEvent({ sessionId: childId, seq })
assertSessionHeadersCompatible(window.session, candidate.header)
if (window.session.parentSession !== parentSessionId || window.target.type !== 'subagent/descriptor') {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
let descriptor: ReturnType<typeof foldSubagentDescriptor>
try {
descriptor = foldSubagentDescriptor([window.target])
} catch {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
if (descriptor === undefined) {
return { kind: 'diagnostic', id: childId, reason: 'unsupported' }
}
return {
kind: 'child',
id: childId,
label: descriptor.label,
status: candidate.live ? 'running' : 'complete',
}
} catch (error: unknown) {
const reason = perChildDiagnosticReason(error)
if (reason === undefined) throw error
return { kind: 'diagnostic', id: childId, reason }
}
}
/**
* Register a provider under its name. Registration is effect-scoped and HMR
* safe; removing a provider blocks new starts but does not revoke runs that
@@ -349,3 +451,19 @@ export class SubagentService extends Service {
}
export default SubagentService
/** Map isolated session-query failures to the fixed child diagnostic taxonomy. */
function perChildDiagnosticReason(error: unknown): 'corrupt' | 'unavailable' | undefined {
if (!(error instanceof SessionQueryError)) return undefined
switch (error.code) {
case 'SESSION_QUERY_SESSION_NOT_FOUND':
case 'SESSION_QUERY_EVENT_NOT_FOUND':
case 'SESSION_QUERY_PERSISTENCE_FAILED':
return 'unavailable'
case 'SESSION_QUERY_INVALID_SURFACE':
case 'SESSION_QUERY_SOURCE_CONFLICT':
return 'corrupt'
default:
return undefined
}
}

View File

@@ -88,6 +88,7 @@ const testSignal = new AbortController().signal
function startSpec(parent: Agent, provider = 'spawn', signal: AbortSignal = testSignal) {
return {
provider,
label: 'child task',
request: { prompt: [{ type: 'text' as const, text: 'child task' }], parent },
signal,
}

View File

@@ -0,0 +1,402 @@
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 { createUserMessage } from '@deepseek-ai/dsh-llm'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
import SubagentService, {
SUBAGENT_DESCRIPTOR_VERSION,
SubagentError,
} from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
/** Boot the continuable stack plus a concrete session-query service. */
async function setup(script: Script, options: { sessionQuery?: boolean } = {}) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-list-'))
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(SubagentFork, { providerName: 'fork' })
if (options.sessionQuery !== false) await ctx.plugin(TestSessionQueryService)
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent }
}
const testSignal = new AbortController().signal
/** Start one continuable child through the real service path and await Activation release. */
async function startChild(
ctx: Context,
parent: ReturnType<Context['agentLoop']['create']>,
label: string,
): Promise<SessionId> {
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label,
request: { prompt: [{ type: 'text', text: `task: ${label}` }], parent },
signal: testSignal,
})
await vi.waitFor(() => {
expect(ctx.agents.get(started.childId)).toBeUndefined()
}, { timeout: 5_000 })
return started.childId
}
/** Author one persisted child session directly against the persistence backend. */
async function authorChild(
ctx: Context,
id: string,
header: Partial<SessionHeader>,
events: SessionEvent[],
): Promise<SessionId> {
const sessionId = SessionId(id)
await ctx.sessionPersistence.create({
version: SESSION_FORMAT_VERSION,
id: sessionId,
createdAt: 1,
...header,
})
await ctx.sessionPersistence.append(sessionId, events)
return sessionId
}
/** Minimal complete-turn child log with one descriptor payload. */
function childEvents(descriptor: unknown): SessionEvent[] {
return [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{
type: 'user/message',
seq: 1,
time: 2,
data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
{ type: 'subagent/descriptor', seq: 2, time: 3, data: descriptor },
{ type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[]
}
function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION) {
return { version, provider: 'spawn', label }
}
describe('SubagentService.listChildren', () => {
it('fails loud before any work when session query is not loaded', async () => {
const { ctx, parent } = await setup([], { sessionQuery: false })
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
expect.objectContaining({ code: 'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE' }) as Error,
)
})
it('lists a persisted continuable child as complete with its durable label', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'summarize the doc')
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: childId, label: 'summarize the doc', status: 'complete' },
])
})
it('accepts a persisted (non-live) parent target after restart', async () => {
const { ctx } = await setup([])
// A parent that exists only in persistence — the restart shape.
const coldParent = SessionId('00000000-0000-4000-8000-00000000cccc')
await ctx.sessionPersistence.create({
version: SESSION_FORMAT_VERSION,
id: coldParent,
createdAt: 1,
})
await ctx.sessionPersistence.append(coldParent, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[])
const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000cdcd', {
parentSession: coldParent,
}, childEvents(descriptorPayload('persisted parent case')))
const entries = await ctx.subagents.listChildren(coldParent)
expect(entries).toEqual([
{ kind: 'child', id: childId, label: 'persisted parent case', status: 'complete' },
])
})
it('orders children by createdAt then id and omits ordinary forks without a diagnostic', async () => {
const { ctx, parent } = await setup([])
// Authored headers pin the ordering key deterministically: same createdAt
// ties break on id, different createdAt orders ascending.
const late = await authorChild(ctx, '00000000-0000-4000-8000-000000000003', {
parentSession: parent.id,
createdAt: 9,
}, childEvents(descriptorPayload('late child')))
const tieB = await authorChild(ctx, '00000000-0000-4000-8000-000000000002', {
parentSession: parent.id,
createdAt: 5,
}, childEvents(descriptorPayload('tie b')))
const tieA = await authorChild(ctx, '00000000-0000-4000-8000-000000000001', {
parentSession: parent.id,
createdAt: 5,
}, childEvents(descriptorPayload('tie a')))
// An ordinary session fork shares parentSession but has no descriptor.
const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork'))
await ctx.sessions.flush(fork)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries.map(entry => entry.id)).toEqual([tieA, tieB, late])
expect(entries.every(entry => entry.kind === 'child')).toBe(true)
})
it('reports a live child as running while keeping settled siblings complete', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const settled = await startChild(ctx, parent, 'settled child')
// A live child session outside persistence: publish a live session with a
// descriptor and the parent lineage, without starting an Activation.
const liveId = SessionId('live-child')
const live = ctx.sessions.create(liveId, { meta: { parentSession: parent.id } })
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
live.append('subagent/descriptor', descriptorPayload('live child'))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toContainEqual({ kind: 'child', id: settled, label: 'settled child', status: 'complete' })
expect(entries).toContainEqual({ kind: 'child', id: liveId, label: 'live child', status: 'running' })
})
it('diagnoses duplicate descriptors as corrupt without hiding healthy siblings', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const healthy = await startChild(ctx, parent, 'healthy sibling')
const events = childEvents(descriptorPayload('twice'))
events.splice(3, 0, {
type: 'subagent/descriptor',
seq: 3,
time: 3,
data: descriptorPayload('twice again'),
} as SessionEvent)
events[4] = { ...events[4]!, seq: 4 }
const corrupt = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', {
parentSession: parent.id,
}, events)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toContainEqual({ kind: 'diagnostic', id: corrupt, reason: 'corrupt' })
expect(entries).toContainEqual({ kind: 'child', id: healthy, label: 'healthy sibling', status: 'complete' })
})
it('diagnoses an invalid child event surface as corrupt', async () => {
const { ctx, parent } = await setup([])
// The surface-eligible user/message lacks its required surfaceOp, so the
// per-child listEvents fold fails with SESSION_QUERY_INVALID_SURFACE.
const invalid = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ee', {
parentSession: parent.id,
}, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{
type: 'user/message',
seq: 1,
time: 2,
data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
},
{ type: 'subagent/descriptor', seq: 2, time: 3, data: descriptorPayload('broken surface') },
] as SessionEvent[])
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'corrupt' }])
})
it('diagnoses a malformed descriptor payload as corrupt', async () => {
const { ctx, parent } = await setup([])
const malformed = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ff', {
parentSession: parent.id,
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 7 }))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: malformed, reason: 'corrupt' }])
})
it('diagnoses an unknown descriptor version as unsupported', async () => {
const { ctx, parent } = await setup([])
const future = await authorChild(ctx, '00000000-0000-4000-8000-0000000000aa', {
parentSession: parent.id,
}, childEvents(descriptorPayload('from the future', SUBAGENT_DESCRIPTOR_VERSION + 1)))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'unsupported' }])
})
it('ignores an ancestor descriptor replayed inside a fork seed', async () => {
const { ctx, parent } = await setup([])
// A fork child whose seed replays a parent log containing a descriptor:
// the seed's descriptor is the ANCESTOR's, not this child's.
const seed = childEvents(descriptorPayload('ancestor label'))
await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', {
parentSession: parent.id,
seedLength: seed.length,
}, seed)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([])
})
it('does not filter by provider availability: children of unmounted providers stay listed', async () => {
const { ctx, parent } = await setup([])
const foreign = await authorChild(ctx, '00000000-0000-4000-8000-0000000000bb', {
parentSession: parent.id,
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'not-mounted', label: 'orphan provider' }))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: foreign, label: 'orphan provider', status: 'complete' },
])
})
it('maps a per-child read failure to one unavailable diagnostic after a successful trace', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'flaky storage')
const query = ctx.get('sessionQuery')!
const originalListEvents = query.listEvents.bind(query)
query.listEvents = (sessionId) => {
if (sessionId === childId) {
return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
}
return originalListEvents(sessionId)
}
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
})
it('maps a mid-scan disappearance to unavailable', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'vanishing child')
const query = ctx.get('sessionQuery')!
query.listEvents = () =>
Promise.reject(new SessionQueryError('gone', 'SESSION_QUERY_SESSION_NOT_FOUND'))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
})
it('diagnoses a read whose header no longer names this parent as corrupt', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'reparented child')
const query = ctx.get('sessionQuery')!
const originalReadEvent = query.readEvent.bind(query)
query.readEvent = async (request) => {
const window = await originalReadEvent(request)
return {
...window,
session: { ...window.session, parentSession: SessionId('someone-else') },
}
}
const entries = await ctx.subagents.listChildren(parent.id)
// The exact read's conflicting immutable header is per-child corruption.
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
})
it('diagnoses a read whose target is no longer the descriptor event as corrupt', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'shifted log')
const query = ctx.get('sessionQuery')!
const originalReadEvent = query.readEvent.bind(query)
query.readEvent = async (request) => {
const window = await originalReadEvent(request)
return { ...window, target: { ...window.target, type: 'turn/start' } as typeof window.target }
}
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
})
it('fails the whole call when the initial trace fails', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'never listed')
const query = ctx.get('sessionQuery')!
query.traceSession = () =>
Promise.reject(new SessionQueryError('listing failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
expect.objectContaining({ code: 'SESSION_QUERY_PERSISTENCE_FAILED' }) as Error,
)
})
it('propagates an unrecognized per-child failure as an operation failure', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'strange failure')
const query = ctx.get('sessionQuery')!
query.listEvents = () => Promise.reject(new Error('not a query failure'))
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow('not a query failure')
})
it('propagates a configuration/window query failure instead of diagnosing the child', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'misconfigured query')
const query = ctx.get('sessionQuery')!
query.listEvents = () =>
Promise.reject(new SessionQueryError('bad window', 'SESSION_QUERY_INVALID_WINDOW'))
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
expect.objectContaining({ code: 'SESSION_QUERY_INVALID_WINDOW' }) as Error,
)
})
it('lists compacted and uncompacted children identically', async () => {
const { ctx, parent } = await setup([])
const plain = await authorChild(ctx, '00000000-0000-4000-8000-00000000c0de', {
parentSession: parent.id,
createdAt: 1,
}, childEvents(descriptorPayload('twin child')))
// The compacted twin: a compaction checkpoint replaces the whole surface,
// while the append-only log retains the model-hidden descriptor event.
const compactedEvents = childEvents(descriptorPayload('twin child'))
compactedEvents.push({
type: 'user/message',
seq: 4,
time: 5,
data: createUserMessage({
content: [{ type: 'text', text: 'summary of everything' }],
source: { kind: 'plugin', plugin: 'compact' },
}),
surfaceOp: { op: 'replace', start: 1, end: 1 },
sourceEventSeqs: [1],
})
const compacted = await authorChild(ctx, '00000000-0000-4000-8000-00000000c1de', {
parentSession: parent.id,
createdAt: 2,
}, compactedEvents)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: plain, label: 'twin child', status: 'complete' },
{ kind: 'child', id: compacted, label: 'twin child', status: 'complete' },
])
})
it('excludes grandchildren: only direct descendants are candidates', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'direct child')
await authorChild(ctx, '00000000-0000-4000-8000-0000000000cc', {
parentSession: childId,
}, childEvents(descriptorPayload('grandchild')))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: childId, label: 'direct child', status: 'complete' },
])
})
it('returns an empty array for a parent with no children', async () => {
const { ctx, parent } = await setup([])
await ctx.sessions.flush(parent.session)
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([])
})
it('SubagentError from listChildren is typed with its stable code', async () => {
const { ctx, parent } = await setup([], { sessionQuery: false })
const caught: unknown = await ctx.subagents.listChildren(parent.id).catch((error: unknown) => error)
expect(caught).toBeInstanceOf(SubagentError)
expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE')
})
})

View File

@@ -130,6 +130,7 @@ describe('SubagentService', () => {
const { subagents } = await service()
await expect(subagents.startContinuable({
provider: 'unused',
label: 'unused child',
request: baseRequest(),
signal: new AbortController().signal,
})).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' })
@@ -298,15 +299,17 @@ describe('subagent descriptors', () => {
it('omits absent fields, recovers a complete payload, and rejects unsupported versions', () => {
expect(foldSubagentDescriptor([])).toBeUndefined()
const minimal = snapshotSubagentDescriptor({ provider: 'spawn' })
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',
@@ -314,6 +317,7 @@ describe('subagent descriptors', () => {
}
expect(snapshotSubagentDescriptor({
provider: complete.provider,
label: complete.label,
agentProvider: complete.agentProvider,
agentModel: complete.agentModel,
persona: complete.persona,
@@ -321,16 +325,17 @@ describe('subagent descriptors', () => {
})).toEqual(complete)
expect(foldSubagentDescriptor([event(complete)])).toEqual(complete)
expect(foldSubagentDescriptor([
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', toolFilter: { allow: ['read'] } }),
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', toolFilter: { deny: ['bash'] } }),
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')
})
@@ -343,15 +348,17 @@ describe('subagent descriptors', () => {
['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', agentProvider: 7 }, 'agentProvider must be a string'],
['invalid agent model', { version: 1, provider: 'spawn', agentModel: [] }, 'agentModel must be a string'],
['invalid persona', { version: 1, provider: 'spawn', persona: {} }, 'persona must be a string'],
['non-object tool filter', { version: 1, provider: 'spawn', toolFilter: [] }, 'toolFilter must be an object'],
['unknown tool-filter field', { version: 1, provider: 'spawn', toolFilter: { except: ['bash'] } }, 'toolFilter has unknown field "except"'],
['empty tool filter', { version: 1, provider: 'spawn', toolFilter: {} }, 'toolFilter must declare allow and/or deny'],
['non-array allow list', { version: 1, provider: 'spawn', toolFilter: { allow: 'read' } }, 'toolFilter.allow must be an array of strings'],
['non-string deny item', { version: 1, provider: 'spawn', toolFilter: { deny: [7] } }, 'toolFilter.deny must be an array of strings'],
['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)
})

View File

@@ -29,6 +29,9 @@
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../tasks/tasks"
},