|
|
@@ -5,6 +5,7 @@ import { HarnessError } from '@deepseek-ai/dsh-llm'
|
|
|
import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
|
|
|
import SubagentService, {
|
|
|
SubagentError,
|
|
|
+ assertSubagentMaxDepth,
|
|
|
type SubagentCapabilities,
|
|
|
type SubagentProvider,
|
|
|
type SubagentResult,
|
|
|
@@ -12,1350 +13,218 @@ import SubagentService, {
|
|
|
type SubagentStartRequest,
|
|
|
} from '@deepseek-ai/dsh-subagent'
|
|
|
|
|
|
-/** A minimal parent Agent stand-in — the service only reads `parent.id`. */
|
|
|
function fakeParent(id = 'parent-1'): Agent {
|
|
|
return { id: AgentId(id) } as unknown as Agent
|
|
|
}
|
|
|
|
|
|
-const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }
|
|
|
+const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }
|
|
|
const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
|
|
|
|
|
|
-/** A scripted provider whose run settles immediately with a fixed result. */
|
|
|
+function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
|
|
|
+ return {
|
|
|
+ prompt: [{ type: 'text', text: 'do a thing' }],
|
|
|
+ parent: fakeParent(),
|
|
|
+ signal: new AbortController().signal,
|
|
|
+ ...overrides,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
class StubProvider implements SubagentProvider {
|
|
|
- startCount = 0
|
|
|
readonly inheritsParentContext = false
|
|
|
+ startCount = 0
|
|
|
+
|
|
|
constructor(
|
|
|
readonly name: string,
|
|
|
readonly capabilities: SubagentCapabilities = ALL_CAPS,
|
|
|
- private readonly result: SubagentResult = { output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' },
|
|
|
+ private readonly outcome: SubagentResult = {
|
|
|
+ output: [{ type: 'text', text: 'ok' }],
|
|
|
+ stopReason: 'completed',
|
|
|
+ },
|
|
|
) {}
|
|
|
|
|
|
- start(request: SubagentStartRequest): SubagentRun {
|
|
|
- this.startCount++
|
|
|
+ async start(request: SubagentStartRequest): Promise<SubagentRun> {
|
|
|
+ this.startCount += 1
|
|
|
return {
|
|
|
id: AgentId(`child:${this.name}:${request.parent.id}`),
|
|
|
- started: Promise.resolve(),
|
|
|
- result: Promise.resolve(this.result),
|
|
|
- cancel() {},
|
|
|
+ result: Promise.resolve(this.outcome),
|
|
|
async dispose() {},
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
|
|
|
- return { prompt: [{ type: 'text', text: 'do a thing' }], parent: fakeParent(), ...overrides }
|
|
|
+async function service(): Promise<{ ctx: Context; subagents: SubagentService }> {
|
|
|
+ const ctx = new Context()
|
|
|
+ await ctx.plugin(SubagentService)
|
|
|
+ return { ctx, subagents: ctx.subagents }
|
|
|
}
|
|
|
|
|
|
describe('SubagentService', () => {
|
|
|
- it('announces provider lifecycle: added on register, removed on dispose', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
+ it('registers, lists, looks up, starts, and removes providers', async () => {
|
|
|
+ const { ctx, subagents } = await service()
|
|
|
const added: string[] = []
|
|
|
const removed: string[] = []
|
|
|
ctx.on('subagent/provider-added', provider => void added.push(provider.name))
|
|
|
ctx.on('subagent/provider-removed', name => void removed.push(name))
|
|
|
-
|
|
|
- const dispose = ctx.subagents.registerProvider(new StubProvider('alpha'))
|
|
|
- expect(added).toEqual(['alpha'])
|
|
|
- expect(removed).toEqual([])
|
|
|
-
|
|
|
- await dispose()
|
|
|
- expect(removed).toEqual(['alpha'])
|
|
|
- })
|
|
|
-
|
|
|
- it('rolls back the registration when a provider-added listener throws', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- let threw = false
|
|
|
- const off = ctx.on('subagent/provider-added', () => {
|
|
|
- if (!threw) { threw = true; throw new Error('boom added listener') }
|
|
|
- })
|
|
|
-
|
|
|
- expect(() => ctx.subagents.registerProvider(new StubProvider('alpha'))).toThrow('boom added listener')
|
|
|
- expect(ctx.subagents.getProvider('alpha')).toBeUndefined() // nothing leaked
|
|
|
-
|
|
|
- off()
|
|
|
- ctx.subagents.registerProvider(new StubProvider('alpha'))
|
|
|
- expect(ctx.subagents.getProvider('alpha')).toBeDefined()
|
|
|
- })
|
|
|
-
|
|
|
- it('contains a throwing provider-removed listener: later mirrors still hear it, teardown completes', async () => {
|
|
|
- // provider-removed fires inside the registration's DISPOSER, so a
|
|
|
- // propagating listener would disrupt the backend's teardown; and cordis
|
|
|
- // emit halts on the first throw, so an uncontained one would starve every
|
|
|
- // mirror registered after it (a stale model-facing tool). Both are
|
|
|
- // prevented by per-listener containment.
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const warnings: string[] = []
|
|
|
- ctx.logger.warn = ((message: unknown) => void warnings.push(String(message))) as typeof ctx.logger.warn
|
|
|
- ctx.on('subagent/provider-removed', () => { throw new Error('boom removed listener') })
|
|
|
- const heard: string[] = []
|
|
|
- ctx.on('subagent/provider-removed', name => void heard.push(name))
|
|
|
-
|
|
|
- const dispose = ctx.subagents.registerProvider(new StubProvider('alpha'))
|
|
|
- expect(() => void dispose()).not.toThrow()
|
|
|
- expect(heard).toEqual(['alpha']) // the listener AFTER the thrower still ran
|
|
|
- expect(ctx.subagents.getProvider('alpha')).toBeUndefined() // teardown reached quiescence
|
|
|
- expect(warnings.some(w => w.includes('boom removed listener'))).toBe(true)
|
|
|
- })
|
|
|
-
|
|
|
- it('registers a provider and starts a run on it by name', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
const provider = new StubProvider('alpha')
|
|
|
- ctx.subagents.registerProvider(provider)
|
|
|
-
|
|
|
- expect(ctx.subagents.list()).toEqual(['alpha'])
|
|
|
- expect(ctx.subagents.getProvider('alpha')).toMatchObject({ name: 'alpha' })
|
|
|
|
|
|
- const run = ctx.subagents.start('alpha', baseRequest())
|
|
|
- expect(provider.startCount).toBe(1)
|
|
|
- await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
|
|
|
- })
|
|
|
-
|
|
|
- it('lets multiple providers coexist (the defining requirement)', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- ctx.subagents.registerProvider(new StubProvider('spawn'))
|
|
|
- ctx.subagents.registerProvider(new StubProvider('acp'))
|
|
|
-
|
|
|
- expect(ctx.subagents.list()).toEqual(['spawn', 'acp'])
|
|
|
- expect(ctx.subagents.getProvider('spawn')).toBeDefined()
|
|
|
- expect(ctx.subagents.getProvider('acp')).toBeDefined()
|
|
|
- })
|
|
|
-
|
|
|
- it('throws NO_PROVIDER when starting on an unregistered name', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- try {
|
|
|
- ctx.subagents.start('missing', baseRequest())
|
|
|
- expect.fail('expected NO_PROVIDER')
|
|
|
- } catch (error: unknown) {
|
|
|
- expect(error).toBeInstanceOf(SubagentError)
|
|
|
- expect((error as SubagentError).code).toBe('NO_PROVIDER')
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- it('rejects duplicate provider names with DUPLICATE_PROVIDER', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- ctx.subagents.registerProvider(new StubProvider('dup'))
|
|
|
- try {
|
|
|
- ctx.subagents.registerProvider(new StubProvider('dup'))
|
|
|
- expect.fail('expected DUPLICATE_PROVIDER')
|
|
|
- } catch (error: unknown) {
|
|
|
- expect(error).toBeInstanceOf(SubagentError)
|
|
|
- expect((error as SubagentError).code).toBe('DUPLICATE_PROVIDER')
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- it('claims wrapper disposal before a raw provider disposer can reenter it', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const observed: { reentrant?: Promise<void> } = {}
|
|
|
- const providerDispose = vi.fn(() => {
|
|
|
- observed.reentrant = run.dispose()
|
|
|
- return Promise.resolve()
|
|
|
- })
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'dispose-reentry',
|
|
|
- capabilities: NO_CAPS,
|
|
|
- inheritsParentContext: false,
|
|
|
- start: () => ({
|
|
|
- id: AgentId('dispose-reentry-child'),
|
|
|
- started: Promise.resolve(),
|
|
|
- result: Promise.resolve({ output: [], stopReason: 'completed' }),
|
|
|
- cancel() {},
|
|
|
- dispose: providerDispose,
|
|
|
- }),
|
|
|
- })
|
|
|
- const run = ctx.subagents.start('dispose-reentry', baseRequest())
|
|
|
-
|
|
|
- const disposal = run.dispose()
|
|
|
-
|
|
|
- expect(observed.reentrant).toBe(disposal)
|
|
|
- await disposal
|
|
|
- expect(providerDispose).toHaveBeenCalledOnce()
|
|
|
- })
|
|
|
-
|
|
|
- it('rejects a raw disposer that directly returns its reentrant wrapper promise instead of hanging', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const providerDispose = vi.fn(() => run.dispose())
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'dispose-self-cycle',
|
|
|
- capabilities: NO_CAPS,
|
|
|
- inheritsParentContext: false,
|
|
|
- start: () => ({
|
|
|
- id: AgentId('dispose-self-cycle-child'),
|
|
|
- started: Promise.resolve(),
|
|
|
- result: Promise.resolve({ output: [], stopReason: 'completed' }),
|
|
|
- cancel() {},
|
|
|
- dispose: providerDispose,
|
|
|
- }),
|
|
|
- })
|
|
|
- const run = ctx.subagents.start('dispose-self-cycle', baseRequest())
|
|
|
-
|
|
|
- await expect(run.dispose()).rejects.toThrow('run dispose returned its own wrapper disposal promise')
|
|
|
- expect(providerDispose).toHaveBeenCalledOnce()
|
|
|
- })
|
|
|
-
|
|
|
- it.each([
|
|
|
- { label: 'a non-string name', patch: { name: 42 }, message: 'name must be a string' },
|
|
|
- { label: 'null capabilities', patch: { capabilities: null }, message: 'capabilities must be an object' },
|
|
|
- { label: 'primitive capabilities', patch: { capabilities: 42 }, message: 'capabilities must be an object' },
|
|
|
- { label: 'array capabilities', patch: { capabilities: [] }, message: 'capabilities must be an object' },
|
|
|
- {
|
|
|
- label: 'a non-boolean outputSchema capability',
|
|
|
- patch: { capabilities: { ...NO_CAPS, outputSchema: 'yes' } },
|
|
|
- message: 'capability "outputSchema" must be a boolean',
|
|
|
- },
|
|
|
- {
|
|
|
- label: 'a non-boolean depthLimit capability',
|
|
|
- patch: { capabilities: { ...NO_CAPS, depthLimit: 'yes' } },
|
|
|
- message: 'capability "depthLimit" must be a boolean',
|
|
|
- },
|
|
|
- {
|
|
|
- label: 'a non-boolean toolFilter capability',
|
|
|
- patch: { capabilities: { ...NO_CAPS, toolFilter: 'yes' } },
|
|
|
- message: 'capability "toolFilter" must be a boolean',
|
|
|
- },
|
|
|
- {
|
|
|
- label: 'a non-boolean persona capability',
|
|
|
- patch: { capabilities: { ...NO_CAPS, persona: 'yes' } },
|
|
|
- message: 'capability "persona" must be a boolean',
|
|
|
- },
|
|
|
- { label: 'a non-boolean conversation-history descriptor', patch: { inheritsParentContext: 'yes' }, message: 'inheritsParentContext must be a boolean' },
|
|
|
- { label: 'a non-callable start field', patch: { start: 42 }, message: 'start must be a function' },
|
|
|
- ])('rejects a provider registration with $label before entering the registry', async ({ patch, message }) => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const provider = Object.assign(new StubProvider('invalid'), patch)
|
|
|
-
|
|
|
- expect(() => ctx.subagents.registerProvider(provider as unknown as SubagentProvider)).toThrow(message)
|
|
|
- expect(ctx.subagents.list()).toEqual([])
|
|
|
- expect(Object.isFrozen(provider)).toBe(false)
|
|
|
- })
|
|
|
-
|
|
|
- it('reads every registration field once and binds the accepted start callback to the provider', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const reads = {
|
|
|
- name: 0,
|
|
|
- capabilities: 0,
|
|
|
- outputSchema: 0,
|
|
|
- depthLimit: 0,
|
|
|
- toolFilter: 0,
|
|
|
- persona: 0,
|
|
|
- inheritsParentContext: 0,
|
|
|
- start: 0,
|
|
|
- }
|
|
|
- const capabilities = Object.defineProperties({}, {
|
|
|
- outputSchema: { enumerable: true, get: () => { reads.outputSchema += 1; return false } },
|
|
|
- depthLimit: { enumerable: true, get: () => { reads.depthLimit += 1; return false } },
|
|
|
- toolFilter: { enumerable: true, get: () => { reads.toolFilter += 1; return false } },
|
|
|
- persona: { enumerable: true, get: () => { reads.persona += 1; return false } },
|
|
|
- }) as SubagentCapabilities
|
|
|
- const acceptedStart = function (this: SubagentProvider, request: SubagentStartRequest): SubagentRun {
|
|
|
- expect(this).toBe(provider)
|
|
|
- return {
|
|
|
- id: AgentId(`one-read:${request.parent.id}`),
|
|
|
- started: Promise.resolve(),
|
|
|
- result: Promise.resolve({ output: [], stopReason: 'completed' }),
|
|
|
- cancel() {},
|
|
|
- async dispose() {},
|
|
|
- }
|
|
|
- }
|
|
|
- const provider = Object.defineProperties({}, {
|
|
|
- name: { enumerable: true, get: () => { reads.name += 1; return 'one-read' } },
|
|
|
- capabilities: { enumerable: true, get: () => { reads.capabilities += 1; return capabilities } },
|
|
|
- inheritsParentContext: { enumerable: true, get: () => { reads.inheritsParentContext += 1; return false } },
|
|
|
- start: { enumerable: true, get: () => { reads.start += 1; return acceptedStart } },
|
|
|
- }) as SubagentProvider
|
|
|
-
|
|
|
- ctx.subagents.registerProvider(provider)
|
|
|
- await expect(ctx.subagents.start('one-read', baseRequest()).result).resolves.toMatchObject({ stopReason: 'completed' })
|
|
|
- expect(reads).toEqual({
|
|
|
- name: 1,
|
|
|
- capabilities: 1,
|
|
|
- outputSchema: 1,
|
|
|
- depthLimit: 1,
|
|
|
- toolFilter: 1,
|
|
|
- persona: 1,
|
|
|
- inheritsParentContext: 1,
|
|
|
- start: 1,
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- it('unregisters a provider when its owning fiber is disposed (HMR safety)', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
-
|
|
|
- const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
|
- inner.subagents.registerProvider(new StubProvider('scoped'))
|
|
|
- }, { inject: ['subagents'] }))
|
|
|
- expect(ctx.subagents.list()).toEqual(['scoped'])
|
|
|
-
|
|
|
- await fiber.dispose()
|
|
|
- expect(ctx.subagents.list()).toEqual([])
|
|
|
- })
|
|
|
-
|
|
|
- it('snapshots a provider registration so caller mutation cannot corrupt dispatch or HMR cleanup', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const capabilities: SubagentCapabilities = {
|
|
|
- outputSchema: true,
|
|
|
- depthLimit: true,
|
|
|
- toolFilter: true,
|
|
|
- persona: true,
|
|
|
- }
|
|
|
- const provider = new StubProvider('stable', capabilities)
|
|
|
- let capabilityReads = 0
|
|
|
- let capabilityValue = capabilities
|
|
|
- Object.defineProperty(provider, 'capabilities', {
|
|
|
- configurable: true,
|
|
|
- get: () => {
|
|
|
- capabilityReads += 1
|
|
|
- return capabilityValue
|
|
|
- },
|
|
|
- set: (value: SubagentCapabilities) => { capabilityValue = value },
|
|
|
- })
|
|
|
- const added: SubagentProvider[] = []
|
|
|
- const removed: string[] = []
|
|
|
- ctx.on('subagent/provider-added', registered => void added.push(registered))
|
|
|
- ctx.on('subagent/provider-removed', name => void removed.push(name))
|
|
|
- const owner = await ctx.plugin({
|
|
|
- name: 'mutable-provider-owner',
|
|
|
- inject: ['subagents'],
|
|
|
- apply(pluginCtx: Context) {
|
|
|
- pluginCtx.subagents.registerProvider(provider)
|
|
|
- },
|
|
|
- })
|
|
|
- expect(capabilityReads).toBe(1)
|
|
|
- const accepted = ctx.subagents.getProvider('stable')
|
|
|
-
|
|
|
- const mutable = provider as unknown as {
|
|
|
- name: string
|
|
|
- capabilities: SubagentCapabilities
|
|
|
- inheritsParentContext: boolean
|
|
|
- start: SubagentProvider['start']
|
|
|
- }
|
|
|
- mutable.name = 'mutated'
|
|
|
- capabilities.outputSchema = false
|
|
|
- capabilities.depthLimit = false
|
|
|
- capabilities.toolFilter = false
|
|
|
- capabilities.persona = false
|
|
|
- mutable.capabilities = NO_CAPS
|
|
|
- mutable.inheritsParentContext = true
|
|
|
- const replacementStart = vi.fn((_request: SubagentStartRequest): SubagentRun => {
|
|
|
- throw new Error('replacement start must not run')
|
|
|
- })
|
|
|
- mutable.start = replacementStart
|
|
|
-
|
|
|
- expect(added).toEqual([accepted])
|
|
|
- expect(accepted).not.toBe(provider)
|
|
|
- expect(Object.isFrozen(accepted)).toBe(true)
|
|
|
- expect(Object.isFrozen(accepted?.capabilities)).toBe(true)
|
|
|
- expect(accepted).toMatchObject({
|
|
|
- name: 'stable',
|
|
|
- capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
|
|
|
- inheritsParentContext: false,
|
|
|
- })
|
|
|
- expect(ctx.subagents.list()).toEqual(['stable'])
|
|
|
- expect(ctx.subagents.getProvider('mutated')).toBeUndefined()
|
|
|
-
|
|
|
- const controller = new AbortController()
|
|
|
- const run = ctx.subagents.start('stable', baseRequest({
|
|
|
- signal: controller.signal,
|
|
|
- agentOptions: { model: 'mock' },
|
|
|
- outputSchema: { type: 'object', properties: { answer: { type: 'string' } } },
|
|
|
- maxDepth: 2,
|
|
|
- toolFilter: { deny: ['bash'] },
|
|
|
- persona: 'reviewer',
|
|
|
- }))
|
|
|
+ const dispose = subagents.registerProvider(provider)
|
|
|
+ expect(subagents.list()).toEqual(['alpha'])
|
|
|
+ expect(subagents.getProvider('alpha')).toBe(provider)
|
|
|
+ const run = await subagents.start('alpha', baseRequest())
|
|
|
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
|
|
|
expect(provider.startCount).toBe(1)
|
|
|
- expect(replacementStart).not.toHaveBeenCalled()
|
|
|
-
|
|
|
- await owner.dispose()
|
|
|
- expect(removed).toEqual(['stable'])
|
|
|
- expect(ctx.subagents.list()).toEqual([])
|
|
|
- expect(() => ctx.subagents.registerProvider(new StubProvider('stable'))).not.toThrow()
|
|
|
- })
|
|
|
|
|
|
- it('re-registers a name after its prior registration is disposed (not wedged)', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
-
|
|
|
- const dispose = ctx.subagents.registerProvider(new StubProvider('reuse'))
|
|
|
- expect(ctx.subagents.list()).toEqual(['reuse'])
|
|
|
await dispose()
|
|
|
- expect(ctx.subagents.list()).toEqual([])
|
|
|
-
|
|
|
- const disposeAgain = ctx.subagents.registerProvider(new StubProvider('reuse'))
|
|
|
- expect(ctx.subagents.list()).toEqual(['reuse'])
|
|
|
- await disposeAgain()
|
|
|
- expect(ctx.subagents.list()).toEqual([])
|
|
|
+ expect(added).toEqual(['alpha'])
|
|
|
+ expect(removed).toEqual(['alpha'])
|
|
|
+ expect(subagents.getProvider('alpha')).toBeUndefined()
|
|
|
})
|
|
|
|
|
|
- describe('start-time capability validation (fail loud, before any child)', () => {
|
|
|
- it.each([
|
|
|
- { field: 'outputSchema', request: baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } } }) },
|
|
|
- { field: 'maxDepth', request: baseRequest({ maxDepth: 2 }) },
|
|
|
- { field: 'toolFilter', request: baseRequest({ toolFilter: { deny: ['bash'] } }) },
|
|
|
- ])('rejects $field against a provider that lacks the capability — before start() runs', ({ request }) => {
|
|
|
- const ctx = new Context()
|
|
|
- return ctx.plugin(SubagentService).then(() => {
|
|
|
- const provider = new StubProvider('weak', NO_CAPS)
|
|
|
- ctx.subagents.registerProvider(provider)
|
|
|
- try {
|
|
|
- ctx.subagents.start('weak', request)
|
|
|
- expect.fail('expected UNSUPPORTED_CAPABILITY')
|
|
|
- } catch (error: unknown) {
|
|
|
- expect(error).toBeInstanceOf(SubagentError)
|
|
|
- expect((error as SubagentError).code).toBe('UNSUPPORTED_CAPABILITY')
|
|
|
- }
|
|
|
- // The child was never started — the check is pre-spawn.
|
|
|
- expect(provider.startCount).toBe(0)
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- it('allows a capability request when the provider supports it', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const provider = new StubProvider('strong', ALL_CAPS)
|
|
|
- ctx.subagents.registerProvider(provider)
|
|
|
- ctx.subagents.start('strong', baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } }, maxDepth: 1 }))
|
|
|
- expect(provider.startCount).toBe(1)
|
|
|
- })
|
|
|
-
|
|
|
- it.each([
|
|
|
- { label: 'null', value: null as unknown as number },
|
|
|
- { label: 'a string', value: '1' as unknown as number },
|
|
|
- { label: 'NaN', value: Number.NaN },
|
|
|
- { label: 'positive infinity', value: Number.POSITIVE_INFINITY },
|
|
|
- { label: 'negative infinity', value: Number.NEGATIVE_INFINITY },
|
|
|
- { label: 'a fraction', value: 1.5 },
|
|
|
- { label: 'a negative integer', value: -1 },
|
|
|
- { label: 'negative zero', value: -0 },
|
|
|
- { label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 },
|
|
|
- ])('rejects maxDepth=$label before the provider starts', async ({ value }) => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const provider = new StubProvider('invalid-depth', ALL_CAPS)
|
|
|
- ctx.subagents.registerProvider(provider)
|
|
|
-
|
|
|
- expect(() => ctx.subagents.start('invalid-depth', baseRequest({ maxDepth: value })))
|
|
|
- .toThrow('subagent maxDepth must be a non-negative safe integer')
|
|
|
- expect(provider.startCount).toBe(0)
|
|
|
- })
|
|
|
-
|
|
|
- it('rejects a non-string persona before the provider starts', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const provider = new StubProvider('invalid-persona', { ...ALL_CAPS, persona: true })
|
|
|
- ctx.subagents.registerProvider(provider)
|
|
|
-
|
|
|
- expect(() => ctx.subagents.start('invalid-persona', baseRequest({
|
|
|
- persona: 42 as unknown as string,
|
|
|
- }))).toThrow('subagent persona must be a string')
|
|
|
- expect(provider.startCount).toBe(0)
|
|
|
- })
|
|
|
-
|
|
|
- it('reads an optional capability accessor once so it cannot appear after validation', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- let accepted: SubagentStartRequest | undefined
|
|
|
- const provider: SubagentProvider = {
|
|
|
- name: 'weak-getter',
|
|
|
- capabilities: NO_CAPS,
|
|
|
- inheritsParentContext: false,
|
|
|
- start: (request) => {
|
|
|
- accepted = request
|
|
|
- return {
|
|
|
- id: AgentId('weak-getter-child'),
|
|
|
- started: Promise.resolve(),
|
|
|
- result: Promise.resolve({ output: [], stopReason: 'completed' }),
|
|
|
- cancel() {},
|
|
|
- async dispose() {},
|
|
|
- }
|
|
|
- },
|
|
|
- }
|
|
|
- ctx.subagents.registerProvider(provider)
|
|
|
- let reads = 0
|
|
|
- const request = baseRequest()
|
|
|
- Object.defineProperty(request, 'toolFilter', {
|
|
|
- enumerable: true,
|
|
|
- get: () => {
|
|
|
- reads += 1
|
|
|
- return reads === 1 ? undefined : { deny: ['bash'] }
|
|
|
- },
|
|
|
- })
|
|
|
-
|
|
|
- ctx.subagents.start('weak-getter', request)
|
|
|
-
|
|
|
- expect(reads).toBe(1)
|
|
|
- expect(accepted?.toolFilter).toBeUndefined()
|
|
|
- })
|
|
|
+ it('rolls registration back when provider-added throws', async () => {
|
|
|
+ const { ctx, subagents } = await service()
|
|
|
+ ctx.on('subagent/provider-added', () => { throw new Error('added boom') })
|
|
|
+ expect(() => { subagents.registerProvider(new StubProvider('alpha')) }).toThrow('added boom')
|
|
|
+ expect(subagents.getProvider('alpha')).toBeUndefined()
|
|
|
})
|
|
|
|
|
|
- it('rejects an exotic public prompt before the provider starts', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const provider = new StubProvider('prompt-boundary')
|
|
|
- ctx.subagents.registerProvider(provider)
|
|
|
- class ExoticTextBlock {
|
|
|
- readonly type = 'text'
|
|
|
- readonly text = 'hello'
|
|
|
- }
|
|
|
-
|
|
|
- expect(() => ctx.subagents.start('prompt-boundary', baseRequest({
|
|
|
- prompt: [new ExoticTextBlock()] as unknown as SubagentStartRequest['prompt'],
|
|
|
- }))).toThrow('subagent prompt must be losslessly JSON-serializable')
|
|
|
- expect(provider.startCount).toBe(0)
|
|
|
+ it('rejects duplicate and absent provider names with typed errors', async () => {
|
|
|
+ const { subagents } = await service()
|
|
|
+ subagents.registerProvider(new StubProvider('dup'))
|
|
|
+ expect(() => { subagents.registerProvider(new StubProvider('dup')) })
|
|
|
+ .toThrow(expect.objectContaining({ code: 'DUPLICATE_PROVIDER' }))
|
|
|
+ await expect(subagents.start('missing', baseRequest()))
|
|
|
+ .rejects.toMatchObject({ code: 'NO_PROVIDER' })
|
|
|
})
|
|
|
|
|
|
it.each([
|
|
|
- {
|
|
|
- label: 'agent options',
|
|
|
- overrides: { agentOptions: { model: Number.NaN as unknown as string } },
|
|
|
- message: 'subagent agent options must be losslessly JSON-serializable',
|
|
|
- },
|
|
|
- {
|
|
|
- label: 'tool filter',
|
|
|
- overrides: { toolFilter: { deny: [Number.NaN as unknown as string] } },
|
|
|
- message: 'subagent tool filter must be losslessly JSON-serializable',
|
|
|
- },
|
|
|
- {
|
|
|
- label: 'output schema',
|
|
|
- overrides: {
|
|
|
- outputSchema: {
|
|
|
- type: 'object',
|
|
|
- properties: { answer: { type: Number.NaN } },
|
|
|
- } as unknown as NonNullable<SubagentStartRequest['outputSchema']>,
|
|
|
- },
|
|
|
- message: 'schema annotation must be JSON data',
|
|
|
- },
|
|
|
- ])('rejects non-JSON $label before the provider starts', async ({ overrides, message }) => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const provider = new StubProvider('invalid-request-data', ALL_CAPS)
|
|
|
- ctx.subagents.registerProvider(provider)
|
|
|
-
|
|
|
- expect(() => ctx.subagents.start('invalid-request-data', baseRequest(overrides)))
|
|
|
- .toThrow(message)
|
|
|
+ ['outputSchema', { outputSchema: { type: 'object', properties: {} } }],
|
|
|
+ ['depthLimit', { maxDepth: 1 }],
|
|
|
+ ['toolFilter', { toolFilter: { deny: ['bash'] } }],
|
|
|
+ ['persona', { persona: 'reviewer' }],
|
|
|
+ ] as const)('rejects unsupported %s before provider startup', async (_capability, override) => {
|
|
|
+ const { subagents } = await service()
|
|
|
+ const provider = new StubProvider('weak', NO_CAPS)
|
|
|
+ subagents.registerProvider(provider)
|
|
|
+ await expect(subagents.start('weak', baseRequest(override)))
|
|
|
+ .rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' })
|
|
|
expect(provider.startCount).toBe(0)
|
|
|
})
|
|
|
|
|
|
- it('reads each nested prompt value once into the provider snapshot', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const provider = new StubProvider('unstable-prompt')
|
|
|
- ctx.subagents.registerProvider(provider)
|
|
|
- let reads = 0
|
|
|
- const block = Object.defineProperties({}, {
|
|
|
- type: { enumerable: true, value: 'text' },
|
|
|
- text: {
|
|
|
- enumerable: true,
|
|
|
- get: () => {
|
|
|
- reads += 1
|
|
|
- return reads === 1 ? 'hello' : new Map([['not', 'json']])
|
|
|
- },
|
|
|
- },
|
|
|
- })
|
|
|
-
|
|
|
- expect(() => ctx.subagents.start('unstable-prompt', baseRequest({
|
|
|
- prompt: [block] as unknown as SubagentStartRequest['prompt'],
|
|
|
- }))).not.toThrow()
|
|
|
- expect(reads).toBe(1)
|
|
|
- expect(provider.startCount).toBe(1)
|
|
|
- })
|
|
|
-
|
|
|
- it('emits subagent/start then subagent/end around a run', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- ctx.subagents.registerProvider(new StubProvider('events'))
|
|
|
-
|
|
|
- const started = vi.fn()
|
|
|
- const ended = vi.fn()
|
|
|
- ctx.on('subagent/start', started)
|
|
|
- ctx.on('subagent/end', ended)
|
|
|
-
|
|
|
- const run = ctx.subagents.start('events', baseRequest())
|
|
|
- await run.started
|
|
|
- expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id }))
|
|
|
-
|
|
|
- await run.result
|
|
|
- // `subagent/end` fires from a `.then` on the result — let the microtask run.
|
|
|
- await Promise.resolve()
|
|
|
- expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' }))
|
|
|
+ it('validates depth and schema semantics before provider startup', async () => {
|
|
|
+ const { subagents } = await service()
|
|
|
+ const provider = new StubProvider('strong')
|
|
|
+ subagents.registerProvider(provider)
|
|
|
+ await expect(subagents.start('strong', baseRequest({ maxDepth: -1 })))
|
|
|
+ .rejects.toThrow('non-negative safe integer')
|
|
|
+ await expect(subagents.start('strong', baseRequest({ outputSchema: { type: 'string' } as never })))
|
|
|
+ .rejects.toThrow()
|
|
|
+ expect(provider.startCount).toBe(0)
|
|
|
+ expect(() => { assertSubagentMaxDepth(undefined) }).not.toThrow()
|
|
|
})
|
|
|
|
|
|
- it('captures a provider run once and gives callers and telemetry one normalized result', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const reads = {
|
|
|
- id: 0,
|
|
|
- started: 0,
|
|
|
- result: 0,
|
|
|
- cancel: 0,
|
|
|
- sendMessage: 0,
|
|
|
- dispose: 0,
|
|
|
- resume: 0,
|
|
|
- output: 0,
|
|
|
- structured: 0,
|
|
|
- stopReason: 0,
|
|
|
- }
|
|
|
- const methodReceivers: string[] = []
|
|
|
- const providerResult = Object.defineProperties({}, {
|
|
|
- output: {
|
|
|
- enumerable: true,
|
|
|
- get: () => {
|
|
|
- reads.output += 1
|
|
|
- return reads.output === 1
|
|
|
- ? [{ type: 'text', text: 'accepted output' }]
|
|
|
- : [{ type: 'text', text: 'drifted output' }]
|
|
|
- },
|
|
|
- },
|
|
|
- structured: {
|
|
|
- enumerable: true,
|
|
|
- get: () => {
|
|
|
- reads.structured += 1
|
|
|
- return { verdict: reads.structured === 1 ? 'accepted' : 'drifted' }
|
|
|
- },
|
|
|
- },
|
|
|
- stopReason: {
|
|
|
- enumerable: true,
|
|
|
- get: () => {
|
|
|
- reads.stopReason += 1
|
|
|
- return reads.stopReason === 1 ? 'completed' : 'error'
|
|
|
- },
|
|
|
- },
|
|
|
- }) as SubagentResult
|
|
|
- const providerRun = Object.defineProperties({}, {
|
|
|
- id: {
|
|
|
- enumerable: true,
|
|
|
- get: () => {
|
|
|
- reads.id += 1
|
|
|
- return AgentId(reads.id === 1 ? 'accepted-child' : 'drifted-child')
|
|
|
- },
|
|
|
- },
|
|
|
- started: {
|
|
|
- enumerable: true,
|
|
|
- get: () => {
|
|
|
- reads.started += 1
|
|
|
- if (reads.started !== 1) throw new Error('started reread')
|
|
|
- return Promise.resolve()
|
|
|
- },
|
|
|
- },
|
|
|
- result: {
|
|
|
- enumerable: true,
|
|
|
- get: () => {
|
|
|
- reads.result += 1
|
|
|
- if (reads.result !== 1) throw new Error('result reread')
|
|
|
- return Promise.resolve(providerResult)
|
|
|
- },
|
|
|
- },
|
|
|
- cancel: {
|
|
|
- enumerable: true,
|
|
|
- get: () => {
|
|
|
- reads.cancel += 1
|
|
|
- if (reads.cancel !== 1) throw new Error('cancel reread')
|
|
|
- return function (this: SubagentRun): void {
|
|
|
- expect(this).toBe(providerRun)
|
|
|
- methodReceivers.push('cancel')
|
|
|
- }
|
|
|
- },
|
|
|
- },
|
|
|
- sendMessage: {
|
|
|
- enumerable: true,
|
|
|
- get: () => {
|
|
|
- reads.sendMessage += 1
|
|
|
- if (reads.sendMessage !== 1) throw new Error('sendMessage reread')
|
|
|
- return function (this: SubagentRun): void {
|
|
|
- expect(this).toBe(providerRun)
|
|
|
- methodReceivers.push('sendMessage')
|
|
|
- }
|
|
|
- },
|
|
|
- },
|
|
|
- dispose: {
|
|
|
- enumerable: true,
|
|
|
- get: () => {
|
|
|
- reads.dispose += 1
|
|
|
- if (reads.dispose !== 1) throw new Error('dispose reread')
|
|
|
- return async function (this: SubagentRun): Promise<void> {
|
|
|
- expect(this).toBe(providerRun)
|
|
|
- methodReceivers.push('dispose')
|
|
|
- }
|
|
|
- },
|
|
|
- },
|
|
|
- resume: {
|
|
|
- enumerable: true,
|
|
|
- get: () => {
|
|
|
- reads.resume += 1
|
|
|
- if (reads.resume !== 1) throw new Error('resume reread')
|
|
|
- return function (this: SubagentRun): SubagentRun {
|
|
|
- expect(this).toBe(providerRun)
|
|
|
- methodReceivers.push('resume')
|
|
|
- return providerRun
|
|
|
- }
|
|
|
- },
|
|
|
- },
|
|
|
- }) as SubagentRun
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'stateful-run',
|
|
|
+ it('publishes lifecycle only after async provider start and keeps parent scope', async () => {
|
|
|
+ const { ctx, subagents } = await service()
|
|
|
+ const ready = Promise.withResolvers<SubagentRun>()
|
|
|
+ const result = Promise.withResolvers<SubagentResult>()
|
|
|
+ subagents.registerProvider({
|
|
|
+ name: 'deferred',
|
|
|
capabilities: NO_CAPS,
|
|
|
inheritsParentContext: false,
|
|
|
- start: () => providerRun,
|
|
|
+ start: () => ready.promise,
|
|
|
})
|
|
|
- const ended = vi.fn()
|
|
|
- ctx.on('subagent/end', ended)
|
|
|
+ const parent = fakeParent('delegator')
|
|
|
+ const events: string[] = []
|
|
|
+ const keys: unknown[] = []
|
|
|
+ ctx.on('subagent/start', function () { events.push('start'); keys.push(carrierKeyOf(this)) })
|
|
|
+ ctx.on('subagent/end', function () { events.push('end'); keys.push(carrierKeyOf(this)) })
|
|
|
|
|
|
- const run = ctx.subagents.start('stateful-run', baseRequest())
|
|
|
- expect(Object.is(run, providerRun)).toBe(false)
|
|
|
- expect(Object.isFrozen(run)).toBe(true)
|
|
|
- run.cancel()
|
|
|
- run.sendMessage?.([])
|
|
|
- expect(Object.is(run.resume?.([]), providerRun)).toBe(true)
|
|
|
- await run.dispose()
|
|
|
- const result = await run.result
|
|
|
- await run.started
|
|
|
+ const starting = subagents.start('deferred', baseRequest({ parent }))
|
|
|
await Promise.resolve()
|
|
|
-
|
|
|
- expect(reads).toEqual({
|
|
|
- id: 1,
|
|
|
- started: 1,
|
|
|
- result: 1,
|
|
|
- cancel: 1,
|
|
|
- sendMessage: 1,
|
|
|
- dispose: 1,
|
|
|
- resume: 1,
|
|
|
- output: 1,
|
|
|
- structured: 1,
|
|
|
- stopReason: 1,
|
|
|
- })
|
|
|
- expect(methodReceivers).toEqual(['cancel', 'sendMessage', 'resume', 'dispose'])
|
|
|
- expect(result).toEqual({
|
|
|
- output: [{ type: 'text', text: 'accepted output' }],
|
|
|
- structured: { verdict: 'accepted' },
|
|
|
- stopReason: 'completed',
|
|
|
- })
|
|
|
- expect(Object.isFrozen(result)).toBe(true)
|
|
|
- expect(Object.isFrozen(result.output)).toBe(true)
|
|
|
- expect(ended).toHaveBeenCalledWith({
|
|
|
- provider: 'stateful-run',
|
|
|
- id: 'accepted-child',
|
|
|
- stopReason: 'completed',
|
|
|
- lastAssistantMessage: [{ type: 'text', text: 'accepted output' }],
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- it.each([
|
|
|
- { label: 'a non-string id', field: 'id', value: 42, message: 'run id must be a string' },
|
|
|
- { label: 'a non-Promise started field', field: 'started', value: undefined, message: 'run started must be a Promise' },
|
|
|
- { label: 'a non-Promise result field', field: 'result', value: undefined, message: 'run result must be a Promise' },
|
|
|
- { label: 'a non-callable cancel field', field: 'cancel', value: undefined, message: 'run cancel must be a function' },
|
|
|
- { label: 'a non-callable sendMessage field', field: 'sendMessage', value: 42, message: 'run sendMessage must be a function' },
|
|
|
- { label: 'a non-callable resume field', field: 'resume', value: 42, message: 'run resume must be a function' },
|
|
|
- ])('rolls back a provider run with $label', async ({ field, value, message }) => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const providerDispose = vi.fn(async () => {})
|
|
|
- const providerRun = {
|
|
|
- id: AgentId('invalid-handle-child'),
|
|
|
- started: Promise.resolve(),
|
|
|
- result: Promise.resolve({ output: [], stopReason: 'completed' } satisfies SubagentResult),
|
|
|
- cancel() {},
|
|
|
- dispose: providerDispose,
|
|
|
- [field]: value,
|
|
|
- } as unknown as SubagentRun
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'invalid-handle',
|
|
|
- capabilities: NO_CAPS,
|
|
|
- inheritsParentContext: false,
|
|
|
- start: () => providerRun,
|
|
|
- })
|
|
|
-
|
|
|
- expect(() => ctx.subagents.start('invalid-handle', baseRequest())).toThrow(message)
|
|
|
- expect(providerDispose).toHaveBeenCalledOnce()
|
|
|
- })
|
|
|
-
|
|
|
- it.each([
|
|
|
- { label: 'null', value: null },
|
|
|
- { label: 'a primitive', value: 42 },
|
|
|
- ])('rejects $label returned by provider.start before reading a disposer', async ({ value }) => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'invalid-run-shell',
|
|
|
- capabilities: NO_CAPS,
|
|
|
- inheritsParentContext: false,
|
|
|
- start: () => value as unknown as SubagentRun,
|
|
|
- })
|
|
|
-
|
|
|
- expect(() => ctx.subagents.start('invalid-run-shell', baseRequest())).toThrow('must return a SubagentRun object')
|
|
|
- })
|
|
|
-
|
|
|
- it('rejects a run without a callable disposer before accepting ownership', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'invalid-dispose',
|
|
|
- capabilities: NO_CAPS,
|
|
|
- inheritsParentContext: false,
|
|
|
- start: () => ({ dispose: 42 }) as unknown as SubagentRun,
|
|
|
- })
|
|
|
-
|
|
|
- expect(() => ctx.subagents.start('invalid-dispose', baseRequest())).toThrow('run dispose must be a function')
|
|
|
- })
|
|
|
-
|
|
|
- it('observes accepted provider promises when a later handle field is malformed', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const providerDispose = vi.fn(async () => {})
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'rejected-malformed-handle',
|
|
|
- capabilities: NO_CAPS,
|
|
|
- inheritsParentContext: false,
|
|
|
- start: () => ({
|
|
|
- id: AgentId('rejected-malformed-child'),
|
|
|
- started: Promise.reject(new Error('readiness already rejected')),
|
|
|
- result: Promise.reject(new Error('result already rejected')),
|
|
|
- cancel: 42,
|
|
|
- dispose: providerDispose,
|
|
|
- }) as unknown as SubagentRun,
|
|
|
- })
|
|
|
-
|
|
|
- expect(() => ctx.subagents.start('rejected-malformed-handle', baseRequest())).toThrow('run cancel must be a function')
|
|
|
- expect(providerDispose).toHaveBeenCalledOnce()
|
|
|
- // Let both provider rejections run: the seam's immediate observers keep
|
|
|
- // them from surfacing as unhandled after no wrapper was returned.
|
|
|
+ expect(events).toEqual([])
|
|
|
+ ready.resolve({ id: AgentId('child'), result: result.promise, async dispose() {} })
|
|
|
+ const run = await starting
|
|
|
+ expect(events).toEqual(['start'])
|
|
|
+ result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' })
|
|
|
+ await run.result
|
|
|
await Promise.resolve()
|
|
|
+ expect(events).toEqual(['start', 'end'])
|
|
|
+ expect(keys).toEqual([parent, parent])
|
|
|
})
|
|
|
|
|
|
- it('starts rollback before surfacing a hostile run accessor failure', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const disposalGate = Promise.withResolvers<undefined>()
|
|
|
- const order: string[] = []
|
|
|
- const providerRun = Object.defineProperties({}, {
|
|
|
- dispose: {
|
|
|
- get: () => {
|
|
|
- order.push('dispose:get')
|
|
|
- return async function (this: SubagentRun): Promise<void> {
|
|
|
- expect(this).toBe(providerRun)
|
|
|
- order.push('dispose:call')
|
|
|
- await disposalGate.promise
|
|
|
- order.push('dispose:quiescent')
|
|
|
- }
|
|
|
- },
|
|
|
- },
|
|
|
- id: { get: () => { order.push('id:get'); return AgentId('hostile-handle-child') } },
|
|
|
- started: { get: () => { order.push('started:get'); return Promise.resolve() } },
|
|
|
- result: { get: () => { order.push('result:get'); throw new Error('result accessor exploded') } },
|
|
|
- }) as SubagentRun
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'hostile-handle',
|
|
|
- capabilities: NO_CAPS,
|
|
|
- inheritsParentContext: false,
|
|
|
- start: () => providerRun,
|
|
|
- })
|
|
|
-
|
|
|
- expect(() => ctx.subagents.start('hostile-handle', baseRequest())).toThrow('result accessor exploded')
|
|
|
- expect(order).toEqual(['dispose:get', 'id:get', 'started:get', 'result:get', 'dispose:call'])
|
|
|
- disposalGate.resolve(undefined)
|
|
|
- await vi.waitFor(() => { expect(order).toContain('dispose:quiescent') })
|
|
|
- })
|
|
|
-
|
|
|
- it('rolls back when binding a hostile optional run method fails', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const providerDispose = vi.fn(async () => {})
|
|
|
- const hostileCancel = new Proxy(() => {}, {
|
|
|
- get(_target, property) {
|
|
|
- if (property === 'length') throw new Error('cancel bind exploded')
|
|
|
- return undefined
|
|
|
- },
|
|
|
- })
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'hostile-bind',
|
|
|
- capabilities: NO_CAPS,
|
|
|
- inheritsParentContext: false,
|
|
|
- start: () => ({
|
|
|
- id: AgentId('hostile-bind-child'),
|
|
|
- started: Promise.resolve(),
|
|
|
- result: Promise.resolve({ output: [], stopReason: 'completed' }),
|
|
|
- cancel: hostileCancel,
|
|
|
- dispose: providerDispose,
|
|
|
- }),
|
|
|
- })
|
|
|
-
|
|
|
- expect(() => ctx.subagents.start('hostile-bind', baseRequest())).toThrow('cancel bind exploded')
|
|
|
- expect(providerDispose).toHaveBeenCalledOnce()
|
|
|
- })
|
|
|
-
|
|
|
- it.each([
|
|
|
- { label: 'an Error', thrown: new Error('cleanup exploded'), warning: 'cleanup exploded' },
|
|
|
- { label: 'a non-Error value', thrown: 'naked cleanup fault', warning: 'dispose threw a non-Error value' },
|
|
|
- ])('contains rollback failure from $label while preserving the malformed-handle fault', async ({ thrown, warning }) => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const warnings: string[] = []
|
|
|
- ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'rollback-failure',
|
|
|
+ it('emits no run lifecycle when provider startup rejects', async () => {
|
|
|
+ const { ctx, subagents } = await service()
|
|
|
+ subagents.registerProvider({
|
|
|
+ name: 'failed',
|
|
|
capabilities: NO_CAPS,
|
|
|
inheritsParentContext: false,
|
|
|
- start: () => ({
|
|
|
- id: 42,
|
|
|
- started: Promise.resolve(),
|
|
|
- result: Promise.resolve({ output: [], stopReason: 'completed' }),
|
|
|
- cancel() {},
|
|
|
- dispose: () => {
|
|
|
- // Deliberately violate the seam contract to exercise normalization.
|
|
|
- throw thrown
|
|
|
- },
|
|
|
- }) as unknown as SubagentRun,
|
|
|
- })
|
|
|
-
|
|
|
- expect(() => ctx.subagents.start('rollback-failure', baseRequest())).toThrow('run id must be a string')
|
|
|
- await vi.waitFor(() => { expect(warnings.some(message => message.includes(warning))).toBe(true) })
|
|
|
- })
|
|
|
-
|
|
|
- it('waits for provider readiness and observes an early result rejection without reordering lifecycle', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const readiness = Promise.withResolvers<undefined>()
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'delayed-start',
|
|
|
- capabilities: NO_CAPS,
|
|
|
- inheritsParentContext: false,
|
|
|
- start: () => ({
|
|
|
- id: AgentId('delayed-child'),
|
|
|
- started: readiness.promise,
|
|
|
- // Already rejected: SubagentService must attach its result handler in
|
|
|
- // the same synchronous start() call, before awaiting readiness.
|
|
|
- result: Promise.reject(new Error('early infrastructure fault')),
|
|
|
- cancel() {},
|
|
|
- async dispose() {},
|
|
|
- }),
|
|
|
- })
|
|
|
- const lifecycle: string[] = []
|
|
|
- ctx.on('subagent/start', () => void lifecycle.push('start'))
|
|
|
- ctx.on('subagent/end', info => void lifecycle.push(`end:${info.stopReason}`))
|
|
|
-
|
|
|
- const run = ctx.subagents.start('delayed-start', baseRequest())
|
|
|
- await expect(run.result).rejects.toThrow('early infrastructure fault')
|
|
|
- expect(lifecycle).toEqual([])
|
|
|
-
|
|
|
- readiness.resolve(undefined)
|
|
|
- await run.started
|
|
|
- expect(lifecycle).toEqual(['start', 'end:error'])
|
|
|
- })
|
|
|
-
|
|
|
- it('emits no lifecycle pair when readiness rejects before a child exists', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const readiness = Promise.withResolvers<undefined>()
|
|
|
- const result = Promise.withResolvers<SubagentResult>()
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'never-started',
|
|
|
- capabilities: NO_CAPS,
|
|
|
- inheritsParentContext: false,
|
|
|
- start: () => ({
|
|
|
- id: AgentId('never-started-child'),
|
|
|
- started: readiness.promise,
|
|
|
- result: result.promise,
|
|
|
- cancel() {},
|
|
|
- async dispose() {},
|
|
|
- }),
|
|
|
+ start: async () => { throw new Error('setup rolled back') },
|
|
|
})
|
|
|
const lifecycle = vi.fn()
|
|
|
ctx.on('subagent/start', lifecycle)
|
|
|
ctx.on('subagent/end', lifecycle)
|
|
|
-
|
|
|
- const run = ctx.subagents.start('never-started', baseRequest())
|
|
|
- readiness.reject(new Error('publication rolled back'))
|
|
|
- await expect(run.started).rejects.toThrow('publication rolled back')
|
|
|
- result.resolve({ output: [], stopReason: 'aborted' })
|
|
|
- await run.result
|
|
|
- await Promise.resolve()
|
|
|
+ await expect(subagents.start('failed', baseRequest())).rejects.toThrow('setup rolled back')
|
|
|
expect(lifecycle).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
- it('pins start and end to the parent accepted at start despite caller mutation', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const gate = Promise.withResolvers<SubagentResult>()
|
|
|
- let acceptedRequest: SubagentStartRequest | undefined
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'deferred',
|
|
|
- capabilities: NO_CAPS,
|
|
|
- inheritsParentContext: false,
|
|
|
- start: (accepted) => {
|
|
|
- acceptedRequest = accepted
|
|
|
- return {
|
|
|
- id: AgentId('deferred-child'),
|
|
|
- started: Promise.resolve(),
|
|
|
- result: gate.promise,
|
|
|
- cancel() {},
|
|
|
- async dispose() {},
|
|
|
- }
|
|
|
- },
|
|
|
+ it('emits an enriched end event and maps result rejection to error telemetry', async () => {
|
|
|
+ const { ctx, subagents } = await service()
|
|
|
+ const completed = new StubProvider('completed', NO_CAPS, {
|
|
|
+ output: [{ type: 'text', text: 'answer' }],
|
|
|
+ stopReason: 'completed',
|
|
|
})
|
|
|
- const accepted = fakeParent('accepted-parent')
|
|
|
- const replacement = fakeParent('replacement-parent')
|
|
|
- const keys: unknown[] = []
|
|
|
- ctx.on('subagent/start', function () { keys.push(carrierKeyOf(this)) })
|
|
|
- ctx.on('subagent/end', function () { keys.push(carrierKeyOf(this)) })
|
|
|
- const request = baseRequest({ parent: accepted })
|
|
|
-
|
|
|
- const run = ctx.subagents.start('deferred', request)
|
|
|
- request.parent = replacement
|
|
|
- request.prompt[0] = { type: 'text', text: 'mutated prompt' }
|
|
|
- expect(acceptedRequest?.parent).toBe(accepted)
|
|
|
- expect(acceptedRequest?.prompt).toEqual([{ type: 'text', text: 'do a thing' }])
|
|
|
- expect(acceptedRequest?.prompt).not.toBe(request.prompt)
|
|
|
- gate.resolve({ output: [], stopReason: 'completed' })
|
|
|
- await run.result
|
|
|
- await Promise.resolve()
|
|
|
-
|
|
|
- expect(keys).toEqual([accepted, accepted])
|
|
|
- })
|
|
|
-
|
|
|
- it('carries lastAssistantMessage (the child output) onto the end event', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- ctx.subagents.registerProvider(new StubProvider(
|
|
|
- 'enriched',
|
|
|
- ALL_CAPS,
|
|
|
- { output: [{ type: 'text', text: 'the child answer' }], stopReason: 'completed' },
|
|
|
- ))
|
|
|
-
|
|
|
- const started = vi.fn()
|
|
|
+ subagents.registerProvider(completed)
|
|
|
const ended = vi.fn()
|
|
|
- ctx.on('subagent/start', started)
|
|
|
ctx.on('subagent/end', ended)
|
|
|
-
|
|
|
- const run = ctx.subagents.start('enriched', baseRequest())
|
|
|
- await run.started
|
|
|
- expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'enriched', id: run.id }))
|
|
|
-
|
|
|
+ const run = await subagents.start('completed', baseRequest())
|
|
|
await run.result
|
|
|
await Promise.resolve()
|
|
|
expect(ended).toHaveBeenCalledWith(expect.objectContaining({
|
|
|
- provider: 'enriched',
|
|
|
- id: run.id,
|
|
|
+ provider: 'completed',
|
|
|
+ lastAssistantMessage: [{ type: 'text', text: 'answer' }],
|
|
|
stopReason: 'completed',
|
|
|
- lastAssistantMessage: [{ type: 'text', text: 'the child answer' }],
|
|
|
}))
|
|
|
- })
|
|
|
|
|
|
- it('observe-only: a mutating subagent/end listener cannot corrupt the caller or later listeners', async () => {
|
|
|
- // The subagent/end emit fires from a detached `.then` registered before
|
|
|
- // start() returns — i.e. BEFORE the caller's own `await run.result`
|
|
|
- // continuation. If the event shared the result.output reference, a mutating
|
|
|
- // listener would change the SubagentResult the caller consumes or the value
|
|
|
- // a later observer sees. The service freezes one normalized result and the
|
|
|
- // lifecycle payload before dispatching either public surface.
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- ctx.subagents.registerProvider(new StubProvider(
|
|
|
- 'clone',
|
|
|
- ALL_CAPS,
|
|
|
- { output: [{ type: 'text', text: 'original' }], stopReason: 'completed' },
|
|
|
- ))
|
|
|
-
|
|
|
- ctx.on('subagent/end', (info) => {
|
|
|
- // A hostile/buggy listener reaches in and mutates the event's array.
|
|
|
- const blocks = info.lastAssistantMessage
|
|
|
- if (blocks?.[0]?.type === 'text') blocks[0].text = 'HIJACKED'
|
|
|
- blocks?.push({ type: 'text', text: 'injected' })
|
|
|
- })
|
|
|
- const later = vi.fn()
|
|
|
- ctx.on('subagent/end', later)
|
|
|
-
|
|
|
- const run = ctx.subagents.start('clone', baseRequest())
|
|
|
- const result = await run.result
|
|
|
- await Promise.resolve() // let the detached settle hook (and its listener) run
|
|
|
- // The caller and the listener after the mutator both retain the accepted value.
|
|
|
- expect(result.output).toEqual([{ type: 'text', text: 'original' }])
|
|
|
- expect(Object.isFrozen(result.output)).toBe(true)
|
|
|
- expect(later).toHaveBeenCalledWith(expect.objectContaining({
|
|
|
- stopReason: 'completed',
|
|
|
- lastAssistantMessage: [{ type: 'text', text: 'original' }],
|
|
|
- }))
|
|
|
- const laterInfo = later.mock.calls[0]![0] as Record<string, unknown>
|
|
|
- expect(Object.isFrozen(laterInfo)).toBe(true)
|
|
|
- expect(Object.isFrozen(laterInfo.lastAssistantMessage)).toBe(true)
|
|
|
- })
|
|
|
-
|
|
|
- it('omits lastAssistantMessage on the reject path (no SubagentResult was produced)', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'rej',
|
|
|
- capabilities: NO_CAPS,
|
|
|
- inheritsParentContext: false,
|
|
|
- start: () => ({
|
|
|
- id: AgentId('rej-child'),
|
|
|
- started: Promise.resolve(),
|
|
|
- result: Promise.reject(new Error('infra fault')),
|
|
|
- cancel() {},
|
|
|
- dispose: async () => {},
|
|
|
- }),
|
|
|
- })
|
|
|
-
|
|
|
- const ended = vi.fn()
|
|
|
- ctx.on('subagent/end', ended)
|
|
|
- const run = ctx.subagents.start('rej', baseRequest())
|
|
|
- await run.result.catch(() => {})
|
|
|
- await Promise.resolve()
|
|
|
-
|
|
|
- const endInfo = ended.mock.calls[0]![0] as Record<string, unknown>
|
|
|
- expect(endInfo.stopReason).toBe('error')
|
|
|
- expect('lastAssistantMessage' in endInfo).toBe(false) // no output exists on reject
|
|
|
- })
|
|
|
-
|
|
|
- it('rejects an invalid provider output and maps the contract fault to error telemetry', async () => {
|
|
|
- // A function is outside the lossless JSON vocabulary. The service-owned
|
|
|
- // result promise rejects instead of exposing the malformed provider value;
|
|
|
- // its already-attached lifecycle observer maps that infrastructure fault to
|
|
|
- // error telemetry without producing an unhandled rejection.
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const nonJsonOutput = [{ type: 'text', text: 'x', evil: () => 0 }] as unknown as SubagentResult['output']
|
|
|
- const disposalGate = Promise.withResolvers<undefined>()
|
|
|
- const providerDispose = vi.fn(async () => { await disposalGate.promise })
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'unclone',
|
|
|
+ const failure = Promise.withResolvers<SubagentResult>()
|
|
|
+ subagents.registerProvider({
|
|
|
+ name: 'infra',
|
|
|
capabilities: NO_CAPS,
|
|
|
inheritsParentContext: false,
|
|
|
- start: () => ({
|
|
|
- id: AgentId('unclone-child'),
|
|
|
- started: Promise.resolve(),
|
|
|
- result: Promise.resolve({ output: nonJsonOutput, stopReason: 'completed' } as SubagentResult),
|
|
|
- cancel() {},
|
|
|
- dispose: providerDispose,
|
|
|
- }),
|
|
|
- })
|
|
|
-
|
|
|
- const ended = vi.fn()
|
|
|
- ctx.on('subagent/end', ended)
|
|
|
- const run = ctx.subagents.start('unclone', baseRequest())
|
|
|
- let resultSettled = false
|
|
|
- void run.result.catch(() => { resultSettled = true })
|
|
|
- await vi.waitFor(() => { expect(providerDispose).toHaveBeenCalledOnce() })
|
|
|
- expect(resultSettled).toBe(false)
|
|
|
- disposalGate.resolve(undefined)
|
|
|
- await expect(run.result).rejects.toThrow('subagent result must be losslessly JSON-serializable')
|
|
|
- await run.dispose()
|
|
|
- await Promise.resolve()
|
|
|
-
|
|
|
- expect(providerDispose).toHaveBeenCalledOnce()
|
|
|
- const endInfo = ended.mock.calls[0]![0] as Record<string, unknown>
|
|
|
- expect(endInfo.stopReason).toBe('error')
|
|
|
- expect('lastAssistantMessage' in endInfo).toBe(false)
|
|
|
- })
|
|
|
-
|
|
|
- it.each([
|
|
|
- {
|
|
|
- label: 'a non-array output',
|
|
|
- value: { output: { type: 'text', text: 'not an array' }, stopReason: 'completed' },
|
|
|
- message: 'subagent result output must be an array',
|
|
|
- },
|
|
|
- {
|
|
|
- label: 'a non-string stopReason',
|
|
|
- value: { output: [], stopReason: 42 },
|
|
|
- message: 'subagent result stopReason must be a string',
|
|
|
- },
|
|
|
- ])('rejects a provider result with $label', async ({ value, message }) => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'invalid-shape',
|
|
|
- capabilities: NO_CAPS,
|
|
|
- inheritsParentContext: false,
|
|
|
- start: () => ({
|
|
|
- id: AgentId('invalid-shape-child'),
|
|
|
- started: Promise.resolve(),
|
|
|
- result: Promise.resolve(value as unknown as SubagentResult),
|
|
|
- cancel() {},
|
|
|
- async dispose() {},
|
|
|
- }),
|
|
|
- })
|
|
|
- const ended = vi.fn()
|
|
|
- ctx.on('subagent/end', ended)
|
|
|
-
|
|
|
- const run = ctx.subagents.start('invalid-shape', baseRequest())
|
|
|
- await expect(run.result).rejects.toThrow(message)
|
|
|
- await Promise.resolve()
|
|
|
-
|
|
|
- expect(ended).toHaveBeenCalledWith(expect.objectContaining({
|
|
|
- provider: 'invalid-shape',
|
|
|
- id: 'invalid-shape-child',
|
|
|
- stopReason: 'error',
|
|
|
- }))
|
|
|
- })
|
|
|
-
|
|
|
- it('emits subagent/end with stopReason "error" when the run result promise rejects', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- // A provider whose run.result REJECTS (an infrastructure fault — the seam
|
|
|
- // contract says child-level failures resolve with stopReason 'error', but a
|
|
|
- // rejection is still surfaced as an 'error' telemetry event).
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'rejecter',
|
|
|
- capabilities: NO_CAPS,
|
|
|
- inheritsParentContext: false,
|
|
|
- start: () => ({
|
|
|
- id: AgentId('rej-child'),
|
|
|
- started: Promise.resolve(),
|
|
|
- result: Promise.reject(new Error('infra fault')),
|
|
|
- cancel() {},
|
|
|
- dispose: async () => {},
|
|
|
- }),
|
|
|
+ async start() {
|
|
|
+ return { id: AgentId('infra-child'), result: failure.promise, async dispose() {} }
|
|
|
+ },
|
|
|
})
|
|
|
-
|
|
|
- const ended = vi.fn()
|
|
|
- ctx.on('subagent/end', ended)
|
|
|
- const run = ctx.subagents.start('rejecter', baseRequest())
|
|
|
- // Observe (and swallow) the rejection the consumer would see, then let the
|
|
|
- // detached `.then` settle the telemetry emit.
|
|
|
- await run.result.catch(() => {})
|
|
|
+ const failedRun = await subagents.start('infra', baseRequest())
|
|
|
+ failure.reject(new Error('transport'))
|
|
|
+ await expect(failedRun.result).rejects.toThrow('transport')
|
|
|
await Promise.resolve()
|
|
|
- expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'rejecter', id: run.id, stopReason: 'error' }))
|
|
|
- })
|
|
|
-
|
|
|
- it('contains a throwing subagent/start listener per-listener: a later listener still observes the event and start() returns the run', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- ctx.subagents.registerProvider(new StubProvider('contain'))
|
|
|
- // Two listeners; the FIRST throws. Per-listener containment means the second
|
|
|
- // must STILL run (a single try/catch around ctx.emit would let the first
|
|
|
- // throw halt the dispatch and starve the second — the round-2 regression).
|
|
|
- const second = vi.fn()
|
|
|
- ctx.on('subagent/start', () => { throw new Error('bad start listener') })
|
|
|
- ctx.on('subagent/start', second)
|
|
|
-
|
|
|
- const run = ctx.subagents.start('contain', baseRequest())
|
|
|
- expect(run.id).toBeDefined()
|
|
|
- await run.started
|
|
|
- expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain', id: run.id }))
|
|
|
- await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
|
|
|
- })
|
|
|
-
|
|
|
- it('contains asynchronous lifecycle-listener rejections without serializing later listeners', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- const warnings: string[] = []
|
|
|
- ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
|
|
- const laterStart = vi.fn()
|
|
|
- const laterEnd = vi.fn()
|
|
|
- const laterRemoved = vi.fn()
|
|
|
- const asyncStart = (async () => { await Promise.resolve(); throw new Error('async start listener') }) as unknown as () => void
|
|
|
- const asyncEnd = (async () => { await Promise.resolve(); throw new Error('async end listener') }) as unknown as () => void
|
|
|
- const asyncRemoved = (async () => { await Promise.resolve(); throw new Error('async removed listener') }) as unknown as () => void
|
|
|
- ctx.on('subagent/start', asyncStart)
|
|
|
- ctx.on('subagent/start', laterStart)
|
|
|
- ctx.on('subagent/end', asyncEnd)
|
|
|
- ctx.on('subagent/end', laterEnd)
|
|
|
- ctx.on('subagent/provider-removed', asyncRemoved)
|
|
|
- ctx.on('subagent/provider-removed', laterRemoved)
|
|
|
- const unregister = ctx.subagents.registerProvider(new StubProvider('async-listeners'))
|
|
|
-
|
|
|
- const run = ctx.subagents.start('async-listeners', baseRequest())
|
|
|
- await run.started
|
|
|
- expect(laterStart).toHaveBeenCalledOnce()
|
|
|
- await run.result
|
|
|
- await vi.waitFor(() => {
|
|
|
- expect(laterEnd).toHaveBeenCalledOnce()
|
|
|
- expect(warnings.some(message => message.includes('async start listener'))).toBe(true)
|
|
|
- expect(warnings.some(message => message.includes('async end listener'))).toBe(true)
|
|
|
- })
|
|
|
-
|
|
|
- await unregister()
|
|
|
- expect(laterRemoved).toHaveBeenCalledWith('async-listeners')
|
|
|
- await vi.waitFor(() => {
|
|
|
- expect(warnings.some(message => message.includes('async removed listener'))).toBe(true)
|
|
|
- })
|
|
|
+ expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'infra', stopReason: 'error' }))
|
|
|
})
|
|
|
|
|
|
- it('contains a listener whose thrown value cannot be stringified', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- ctx.subagents.registerProvider(new StubProvider('hostile-listener'))
|
|
|
+ it('contains synchronous and asynchronous lifecycle observer failures', async () => {
|
|
|
+ const { ctx, subagents } = await service()
|
|
|
const warnings: string[] = []
|
|
|
- ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
|
|
- const hostile = {
|
|
|
- [Symbol.toPrimitive]() { throw new Error('render failed') },
|
|
|
- }
|
|
|
- const second = vi.fn()
|
|
|
- ctx.on('subagent/start', () => { throw hostile })
|
|
|
- ctx.on('subagent/start', second)
|
|
|
-
|
|
|
- const run = ctx.subagents.start('hostile-listener', baseRequest())
|
|
|
- await run.started
|
|
|
-
|
|
|
- expect(second).toHaveBeenCalledOnce()
|
|
|
- expect(warnings.some(message => message.includes('<unrenderable thrown value>'))).toBe(true)
|
|
|
- await run.result
|
|
|
- })
|
|
|
-
|
|
|
- it('rejects a throwing provider result accessor and maps it to error telemetry', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- ctx.subagents.registerProvider({
|
|
|
- name: 'hostile-result',
|
|
|
- capabilities: NO_CAPS,
|
|
|
- inheritsParentContext: false,
|
|
|
- start: () => ({
|
|
|
- id: AgentId('hostile-result-child'),
|
|
|
- started: Promise.resolve(),
|
|
|
- result: Promise.resolve({
|
|
|
- output: [],
|
|
|
- get stopReason(): 'completed' { throw new Error('stop reason exploded') },
|
|
|
- }),
|
|
|
- cancel() {},
|
|
|
- async dispose() {},
|
|
|
- }),
|
|
|
- })
|
|
|
- const ended = vi.fn()
|
|
|
- ctx.on('subagent/end', ended)
|
|
|
-
|
|
|
- const run = ctx.subagents.start('hostile-result', baseRequest())
|
|
|
- await run.started
|
|
|
- await expect(run.result).rejects.toThrow('stop reason exploded')
|
|
|
- await Promise.resolve()
|
|
|
-
|
|
|
- expect(ended).toHaveBeenCalledWith(expect.objectContaining({
|
|
|
- provider: 'hostile-result',
|
|
|
- id: 'hostile-result-child',
|
|
|
- stopReason: 'error',
|
|
|
- }))
|
|
|
- })
|
|
|
-
|
|
|
- it('contains a throwing subagent/end listener per-listener: a later listener still observes the settle, no unhandled rejection', async () => {
|
|
|
- const ctx = new Context()
|
|
|
- await ctx.plugin(SubagentService)
|
|
|
- ctx.subagents.registerProvider(new StubProvider('contain-end'))
|
|
|
- const second = vi.fn()
|
|
|
- ctx.on('subagent/end', () => { throw new Error('bad end listener') })
|
|
|
- ctx.on('subagent/end', second)
|
|
|
+ ctx.logger.warn = ((message: unknown) => void warnings.push(String(message))) as typeof ctx.logger.warn
|
|
|
+ const heard: string[] = []
|
|
|
+ ctx.on('subagent/provider-removed', () => { throw new Error('sync boom') })
|
|
|
+ // Runtime listeners may return thenables even though the declaration's observable result is void.
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment
|
|
|
+ ctx.on('subagent/provider-removed', async () => { throw new Error('async boom') })
|
|
|
+ ctx.on('subagent/provider-removed', () => { throw { toString: () => { throw new Error('coercion') } } })
|
|
|
+ ctx.on('subagent/provider-removed', name => void heard.push(name))
|
|
|
+ const dispose = subagents.registerProvider(new StubProvider('contained'))
|
|
|
|
|
|
- const run = ctx.subagents.start('contain-end', baseRequest())
|
|
|
- await run.result
|
|
|
- // Let the detached `.then` + the contained emit run.
|
|
|
- await Promise.resolve()
|
|
|
+ await dispose()
|
|
|
await Promise.resolve()
|
|
|
- expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain-end', id: run.id, stopReason: 'completed' }))
|
|
|
+ expect(heard).toEqual(['contained'])
|
|
|
+ expect(warnings.some(message => message.includes('sync boom'))).toBe(true)
|
|
|
+ expect(warnings.some(message => message.includes('async boom'))).toBe(true)
|
|
|
+ expect(warnings.some(message => message.includes('<unrenderable thrown value>'))).toBe(true)
|
|
|
})
|
|
|
|
|
|
- it('SubagentError extends the shared HarnessError base', () => {
|
|
|
- const err = new SubagentError('boom', 'NO_PROVIDER')
|
|
|
- expect(err).toBeInstanceOf(HarnessError)
|
|
|
- expect(err.name).toBe('SubagentError')
|
|
|
- expect(err.code).toBe('NO_PROVIDER')
|
|
|
+ it('SubagentError participates in the harness error taxonomy', () => {
|
|
|
+ const error = new SubagentError('boom', 'NO_PROVIDER')
|
|
|
+ expect(error).toBeInstanceOf(HarnessError)
|
|
|
+ expect(error.name).toBe('SubagentError')
|
|
|
+ expect(error.code).toBe('NO_PROVIDER')
|
|
|
})
|
|
|
})
|