Files
deepseek-harness/packages/subagent/subagent/tests/service.spec.ts
T
Tianyi Cui cb03c8c284 fix(scope): align trust and input boundaries
Rewrite the agent-scope RFC with executable examples and an explicit security non-goal. Harden subagent scalar and depth validation, and pin live tool-filter semantics across code, tests, and generated docs.
2026-07-12 11:17:57 +08:00

1362 lines
52 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
import SubagentService, {
SubagentError,
type SubagentCapabilities,
type SubagentProvider,
type SubagentResult,
type SubagentRun,
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 NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
/** A scripted provider whose run settles immediately with a fixed result. */
class StubProvider implements SubagentProvider {
startCount = 0
readonly inheritsParentContext = false
constructor(
readonly name: string,
readonly capabilities: SubagentCapabilities = ALL_CAPS,
private readonly result: SubagentResult = { output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' },
) {}
start(request: SubagentStartRequest): SubagentRun {
this.startCount++
return {
id: AgentId(`child:${this.name}:${request.parent.id}`),
started: Promise.resolve(),
result: Promise.resolve(this.result),
cancel() {},
async dispose() {},
}
}
}
function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
return { prompt: [{ type: 'text', text: 'do a thing' }], parent: fakeParent(), ...overrides }
}
describe('SubagentService', () => {
it('announces provider lifecycle: added on register, removed on dispose', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
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',
}))
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([])
})
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('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.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)
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('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',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: () => providerRun,
})
const ended = vi.fn()
ctx.on('subagent/end', ended)
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
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.
await Promise.resolve()
})
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',
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() {},
}),
})
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()
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() {},
}
},
})
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()
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 }))
await run.result
await Promise.resolve()
expect(ended).toHaveBeenCalledWith(expect.objectContaining({
provider: 'enriched',
id: run.id,
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',
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 () => {},
}),
})
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(() => {})
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)
})
})
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'))
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)
const run = ctx.subagents.start('contain-end', baseRequest())
await run.result
// Let the detached `.then` + the contained emit run.
await Promise.resolve()
await Promise.resolve()
expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain-end', id: run.id, stopReason: 'completed' }))
})
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')
})
})