feat(subagent): catalog one-shot child sessions
This commit is contained in:
@@ -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' },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user