feat(subagent): catalog one-shot child sessions

This commit is contained in:
Dudu-0223
2026-07-27 16:58:37 +08:00
committed by Tianyi Cui
parent de6e572e51
commit 774ee34b9a
85 changed files with 1034 additions and 539 deletions
@@ -201,7 +201,9 @@ describe('SubagentService.startContinuable', () => {
const descriptor = loaded.events[descriptorIndex] as SessionEvent<'subagent/descriptor'>
expect(descriptor.data).toEqual({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'child task',
agentProvider: 'mock',
agentModel: 'mock',
})
@@ -294,7 +296,9 @@ describe('SubagentService.startContinuable', () => {
expect(descriptor?.data).toEqual({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'child task',
})
await drainManager(ctx)
})
@@ -326,7 +330,9 @@ describe('SubagentService.startContinuable', () => {
expect(child.session.events.find(event => event.type === 'subagent/descriptor')?.data)
.toEqual({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'child task',
toolFilter: { deny: ['noop'] },
})
await drainManager(ctx)
@@ -526,8 +532,9 @@ describe('SubagentService.followup residency routing', () => {
it('reports an unresumable child whose persisted log has no supported descriptor', async () => {
const { ctx, parent } = await setup([textResponse('one shot')])
// A ONE-SHOT child persists a log but never seeds a descriptor.
// A one-shot child has durable identity but no supported continuation state.
const run = await ctx.subagents.start('spawn', {
label: 'one-shot work',
prompt: message('one-shot work'),
parent,
signal: testSignal,
@@ -774,6 +781,7 @@ describe('continuable durability and teardown', () => {
])
const { ctx, parent } = await setupWith(adapter)
const run = await ctx.subagents.start('spawn', {
label: 'one-shot task',
prompt: message('one-shot task'),
parent,
signal: testSignal,
@@ -1374,6 +1382,7 @@ describe('continuable public surface', () => {
it('keeps one-shot runs free of a steering capability', async () => {
const { ctx, parent } = await setup([textResponse('one shot')])
const run = await ctx.subagents.start('spawn', {
label: 'one-shot work',
prompt: message('one-shot work'),
parent,
signal: testSignal,
@@ -98,7 +98,7 @@ function childEvents(descriptor: unknown): SessionEvent[] {
}
function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION) {
return { version, provider: 'spawn', label }
return { version, mode: 'continuable' as const, provider: 'spawn', label }
}
describe('SubagentService.listChildren', () => {
@@ -121,7 +121,7 @@ describe('SubagentService.listChildren', () => {
child.append('subagent/descriptor', descriptorPayload('query-only child'))
await expect(ctx.subagents.listChildren(parentId)).resolves.toEqual([
{ kind: 'child', id: childId, label: 'query-only child', status: 'running' },
{ kind: 'child', id: childId, label: 'query-only child', mode: 'continuable', activity: 'running' },
])
})
@@ -132,15 +132,46 @@ describe('SubagentService.listChildren', () => {
)
})
it('lists a persisted continuable child as complete with its durable label', async () => {
it('lists a persisted continuable child as inactive 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' },
{ kind: 'child', id: childId, label: 'summarize the doc', mode: 'continuable', activity: 'inactive' },
])
})
it('lists one-shot and continuable children from the same trace', async () => {
const { ctx, parent } = await setup([textResponse('once'), textResponse('again')])
const oneShot = await ctx.subagents.start('spawn', {
label: 'one-shot child',
prompt: [{ type: 'text', text: 'finish once' }],
parent,
signal: new AbortController().signal,
})
const oneShotId = oneShot.id
await oneShot.result
await oneShot.dispose()
const continuableId = await startChild(ctx, parent, 'continuable child')
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toHaveLength(2)
expect(entries).toContainEqual({
kind: 'child',
id: oneShotId,
label: 'one-shot child',
mode: 'one-shot',
activity: 'inactive',
})
expect(entries).toContainEqual({
kind: 'child',
id: continuableId,
label: 'continuable child',
mode: 'continuable',
activity: 'inactive',
})
})
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.
@@ -159,7 +190,7 @@ describe('SubagentService.listChildren', () => {
}, 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' },
{ kind: 'child', id: childId, label: 'persisted parent case', mode: 'continuable', activity: 'inactive' },
])
})
@@ -197,8 +228,12 @@ describe('SubagentService.listChildren', () => {
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' })
expect(entries).toContainEqual({
kind: 'child', id: settled, label: 'settled child', mode: 'continuable', activity: 'inactive',
})
expect(entries).toContainEqual({
kind: 'child', id: liveId, label: 'live child', mode: 'continuable', activity: 'running',
})
})
it('diagnoses duplicate descriptors as corrupt without hiding healthy siblings', async () => {
@@ -217,7 +252,9 @@ describe('SubagentService.listChildren', () => {
}, 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' })
expect(entries).toContainEqual({
kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable', activity: 'inactive',
})
})
it('diagnoses an invalid child event surface as corrupt', async () => {
@@ -244,7 +281,7 @@ describe('SubagentService.listChildren', () => {
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 }))
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 }))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: malformed, reason: 'corrupt' }])
})
@@ -275,10 +312,15 @@ describe('SubagentService.listChildren', () => {
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' }))
}, childEvents({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
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' },
{ kind: 'child', id: foreign, label: 'orphan provider', mode: 'continuable', activity: 'inactive' },
])
})
@@ -393,8 +435,8 @@ describe('SubagentService.listChildren', () => {
}, 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' },
{ kind: 'child', id: plain, label: 'twin child', mode: 'continuable', activity: 'inactive' },
{ kind: 'child', id: compacted, label: 'twin child', mode: 'continuable', activity: 'inactive' },
])
})
@@ -406,7 +448,7 @@ describe('SubagentService.listChildren', () => {
}, childEvents(descriptorPayload('grandchild')))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: childId, label: 'direct child', status: 'complete' },
{ kind: 'child', id: childId, label: 'direct child', mode: 'continuable', activity: 'inactive' },
])
})
+122 -22
View File
@@ -10,6 +10,7 @@ import SubagentService, {
SUBAGENT_DESCRIPTOR_VERSION,
SubagentError,
assertSubagentMaxDepth,
type ResolvedSubagentStartRequest,
type SubagentCapabilities,
type SubagentProvider,
type SubagentResult,
@@ -27,6 +28,7 @@ const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false,
function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
return {
label: 'do a thing',
prompt: [{ type: 'text', text: 'do a thing' }],
parent: fakeParent(),
signal: new AbortController().signal,
@@ -37,7 +39,7 @@ function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentSta
class StubProvider implements SubagentProvider {
readonly inheritsParentContext = false
startCount = 0
lastRequest: SubagentStartRequest | undefined
lastRequest: ResolvedSubagentStartRequest | undefined
constructor(
readonly name: string,
@@ -48,7 +50,7 @@ class StubProvider implements SubagentProvider {
},
) {}
async start(request: SubagentStartRequest): Promise<SubagentRun> {
async start(request: ResolvedSubagentStartRequest): Promise<SubagentRun> {
this.startCount += 1
this.lastRequest = request
return {
@@ -104,16 +106,23 @@ describe('SubagentService', () => {
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
})
it('borrows ordinary start requests and exposes no provider continuation operations', async () => {
it('resolves the one-shot descriptor 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)
expect(provider.lastRequest).toEqual({
...request,
descriptor: {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'one-shot',
provider: 'one-shot',
label: 'do a thing',
},
})
expect(provider.lastRequest).not.toBe(request)
expectTypeOf<Parameters<SubagentService['start']>[1]>().toExtend<SubagentStartRequest>()
expect('resume' in subagents).toBe(false)
expect('resume' in provider).toBe(false)
@@ -299,15 +308,17 @@ describe('subagent descriptors', () => {
it('omits absent fields, recovers a complete payload, and rejects unsupported versions', () => {
expect(foldSubagentDescriptor([])).toBeUndefined()
const minimal = snapshotSubagentDescriptor({ provider: 'spawn', label: 'child work' })
const minimal = snapshotSubagentDescriptor({ mode: 'one-shot', provider: 'spawn', label: 'child work' })
expect(minimal).toEqual({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'one-shot',
provider: 'spawn',
label: 'child work',
})
expect(foldSubagentDescriptor([event(minimal)])).toEqual(minimal)
const complete = {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable' as const,
provider: 'spawn',
label: 'complete child',
agentProvider: 'deepseek',
@@ -316,6 +327,7 @@ describe('subagent descriptors', () => {
toolFilter: { allow: ['read'], deny: ['bash'] },
}
expect(snapshotSubagentDescriptor({
mode: 'continuable',
provider: complete.provider,
label: complete.label,
agentProvider: complete.agentProvider,
@@ -325,15 +337,28 @@ describe('subagent descriptors', () => {
})).toEqual(complete)
expect(foldSubagentDescriptor([event(complete)])).toEqual(complete)
expect(foldSubagentDescriptor([
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', label: 'l', toolFilter: { allow: ['read'] } }),
event({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
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'] } }),
event({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: { deny: ['bash'] },
}),
])).toMatchObject({ toolFilter: { deny: ['bash'] } })
expect(foldSubagentDescriptor([
event({ version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' }),
])).toBeUndefined()
expect(() => snapshotSubagentDescriptor({
mode: 'continuable',
provider: 'spawn',
label: 'bad',
toolFilter: { deny: [Symbol('not-json')] as unknown as string[] },
@@ -346,19 +371,94 @@ describe('subagent descriptors', () => {
['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'],
['missing mode', { version: SUBAGENT_DESCRIPTOR_VERSION }, 'mode must be "one-shot" or "continuable"'],
['invalid mode', { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'later' }, 'mode must be "one-shot" or "continuable"'],
['unknown one-shot field', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'one-shot',
provider: 'spawn',
label: 'l',
persona: 'reviewer',
}, 'payload has unknown field "persona"'],
['unknown payload field', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
extra: true,
}, 'payload has unknown field "extra"'],
['missing provider', { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable' }, 'provider must be a string'],
['missing label', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
}, 'label must be a string'],
['invalid label', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 7,
}, 'label must be a string'],
['invalid provider', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 7,
}, 'provider must be a string'],
['invalid agent provider', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
agentProvider: 7,
}, 'agentProvider must be a string'],
['invalid agent model', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
agentModel: [],
}, 'agentModel must be a string'],
['invalid persona', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
persona: {},
}, 'persona must be a string'],
['non-object tool filter', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: [],
}, 'toolFilter must be an object'],
['unknown tool-filter field', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: { except: ['bash'] },
}, 'toolFilter has unknown field "except"'],
['empty tool filter', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: {},
}, 'toolFilter must declare allow and/or deny'],
['non-array allow list', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: { allow: 'read' },
}, 'toolFilter.allow must be an array of strings'],
['non-string deny item', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
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)
})