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:
@@ -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"
|
||||
|
||||
@@ -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 } : {},
|
||||
|
||||
@@ -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 } : {},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user