fix(scope): close remaining ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-12 03:51:55 +08:00
parent 3dca90261c
commit 36b8370027
79 changed files with 3957 additions and 817 deletions

View File

@@ -21,7 +21,7 @@ Unlike the bash seam (one executor per context, second load throws), **multiple
| `registerProvider(provider)` | Register a frozen acceptance snapshot under `provider.name`; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. |
| `getProvider(name)` | Look up the frozen registry snapshot (`undefined` if absent). |
| `list()` | Registered provider names (insertion order). |
| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), validate every requested START-TIME capability (`UNSUPPORTED_CAPABILITY` for the first unmet one — before any child is created), then delegate to `provider.start`. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. |
| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), read every caller field once into one acceptance snapshot, validate every requested START-TIME capability and scalar value before any child is created, and materialize prompt/schema/options/filter data through a single-pass lossless-JSON snapshot before delegating to `provider.start`. Return a frozen service-owned run wrapper whose provider fields are captured once, whose methods remain bound to the provider handle, and whose `result` is one detached, deeply frozen normalization shared by the caller and telemetry. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. |
## Capabilities: two kinds, discovered two ways
@@ -32,12 +32,12 @@ Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: `
## Run lifecycle
`provider.start(request)` returns a `SubagentRun`: a handle with `started` (the publication/readiness promise), `result` (the terminal outcome), `cancel()`, `dispose()`, and the optional runtime methods. `started` resolves only after the provider has established a real child and rejects if the attempt fails or is cancelled first. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session.
`provider.start(request)` returns a provider-owned `SubagentRun`; `SubagentService.start` captures that handle once and returns a frozen service-owned wrapper with `started` (the publication/readiness promise), a normalized `result` (the terminal outcome), bound `cancel()` and `dispose()`, and bound optional runtime methods. `started` resolves only after the provider has established a real child and rejects if the attempt fails or is cancelled first. `result` resolves with one detached, deeply frozen `SubagentResult` (`output`, optional `structured`, `stopReason`) that the service and caller share — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), but malformed provider data rejects as an infrastructure contract fault. The consumer maps a non-`completed` reason to an `isError` tool result and MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session.
The service also announces provider lifecycle: `subagent/provider-added` (the frozen registry snapshot) fires after a registration and `subagent/provider-removed` (the accepted name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". Run lifecycle is gated by provider readiness: `subagent/start` (payload `SubagentRunInfo`) fires only after `run.started` fulfills, and `subagent/end` (payload `SubagentRunEndInfo`) fires only for that announced run; readiness rejection emits neither. For spawn/fork, the start listener can resolve the published child via `ctx.agents.get(info.id)`; a remote provider need not have a local registry entry. Both events are **observe-only** plain emits. The service observes `result` immediately even while readiness is pending, clones its output before the caller can mutate it, and buffers that end payload until start has fired; a rejecting result cannot become an unhandled detached promise, start always precedes end, and a listener cannot corrupt the caller's result. `subagent/end` carries the cloned output as `lastAssistantMessage` on the settle path and omits it on infrastructure rejection. Any run-affecting decision is out of scope for this observe-only surface.
The service also announces provider lifecycle: `subagent/provider-added` (the frozen registry snapshot) fires after a registration and `subagent/provider-removed` (the accepted name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". Run lifecycle is gated by provider readiness: the service captures the provider handle's public fields once, `subagent/start` (payload `SubagentRunInfo`) fires only after the accepted `started` promise fulfills, and `subagent/end` (payload `SubagentRunEndInfo`) uses the same accepted id and normalized result; readiness rejection emits neither. For spawn/fork, the start listener can resolve the published child via `ctx.agents.get(info.id)`; a remote provider need not have a local registry entry. Both events are **observe-only** plain emits whose service-owned payloads are deeply frozen before per-listener dispatch. The service observes the normalized `result` immediately even while readiness is pending and buffers that end payload until start has fired; a malformed provider result rejects the returned result promise and becomes contained `error` telemetry, a rejection cannot become an unhandled detached promise, start always precedes end, and one listener cannot corrupt either the caller or later listeners. `subagent/end` carries the same frozen output as `lastAssistantMessage` on a valid settle path and omits it on infrastructure or result-contract failure. Any run-affecting decision is out of scope for this observe-only surface.
## Scope (first cut)
The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background, poll, and spill semantics are outside this seam; long-running-tool handling is shared work across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
See `src/types.ts` for the full contracts.

View File

@@ -25,6 +25,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
@@ -32,6 +33,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -34,10 +34,11 @@
import { Context, Service } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
import type {
SubagentCapabilities,
@@ -115,7 +116,7 @@ declare module 'cordis' {
}
}
/** Identifying detail for a started subagent run (the `subagent/start` payload). */
/** Deep-frozen, observe-only identifying detail for a started subagent run. */
export interface SubagentRunInfo {
/** The provider that started the run. */
provider: string
@@ -123,7 +124,7 @@ export interface SubagentRunInfo {
id: AgentId
}
/** Outcome detail for a settled subagent run (the `subagent/end` payload). */
/** Deep-frozen, observe-only outcome detail for a settled subagent run. */
export interface SubagentRunEndInfo {
/** The provider that ran it. */
provider: string
@@ -186,11 +187,12 @@ export class SubagentService extends Service {
// mutate or reuse the provider object before its old fiber unloads. Binding
// preserves the provider method's receiver while making replacement of the
// public callback field after registration inert.
const inputCapabilities = provider.capabilities
const capabilities: SubagentCapabilities = Object.freeze({
outputSchema: provider.capabilities.outputSchema,
depthLimit: provider.capabilities.depthLimit,
toolFilter: provider.capabilities.toolFilter,
persona: provider.capabilities.persona,
outputSchema: inputCapabilities.outputSchema,
depthLimit: inputCapabilities.depthLimit,
toolFilter: inputCapabilities.toolFilter,
persona: inputCapabilities.persona,
})
const snapshot: SubagentProvider = Object.freeze({
name: provider.name,
@@ -244,10 +246,16 @@ export class SubagentService extends Service {
/**
* Start a subagent run on the named provider. Resolves the provider (throws
* `NO_PROVIDER` if absent), validates every requested START-TIME capability
* `NO_PROVIDER` if absent), reads the caller request once into a coherent
* acceptance snapshot, validates every requested START-TIME capability
* against {@link SubagentProvider.capabilities} (throws `UNSUPPORTED_CAPABILITY`
* for the first unmet one — fail loud, before any child is created), then
* delegates to {@link SubagentProvider.start}, then emits `subagent/start` /
* validates the request's scalar values, materializes model-bound data in one
* lossless-JSON traversal, and delegates the detached request to
* {@link SubagentProvider.start}. The returned handle is a service-owned,
* frozen wrapper: provider fields are captured once, methods stay bound to the
* provider handle, and `result` resolves to one detached, deeply frozen value
* shared by the caller and lifecycle telemetry. Emits `subagent/start` /
* `subagent/end` only after the run's readiness boundary fulfills. A provider
* that fails before establishing a child emits neither event.
* @param name - the provider to run on.
@@ -255,32 +263,94 @@ export class SubagentService extends Service {
* @returns the live run (its `result` resolves when the child settles).
*/
start(name: string, request: SubagentStartRequest): SubagentRun {
// Parent is the lifecycle scope identity accepted at start. Never reread it
// from the caller-owned request after the provider/result async boundary,
// or start/end could be dispatched into different agent scopes.
const parent = request.parent
const provider = this.providers.get(name)
if (!provider) {
throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
}
this.assertCapabilities(provider, request)
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
// Read every top-level field exactly once before capability checks or
// detachment. A stateful accessor must not look absent to validation and then
// appear in the provider request (or vice versa).
const input = this.snapshotStartRequest(request)
const parent = input.parent
this.assertCapabilities(provider, input)
if (input.maxDepth !== undefined && (
!Number.isSafeInteger(input.maxDepth)
|| input.maxDepth < 0
|| Object.is(input.maxDepth, -0)
)) {
throw new TypeError('subagent maxDepth must be a non-negative safe integer')
}
if (input.persona !== undefined && typeof input.persona !== 'string') {
throw new TypeError('subagent persona must be a string')
}
// Model/session-bound values are validated and detached in a single
// recursive pass. A check followed by structuredClone would reread getters
// and could erase an exotic prototype returned only to the clone.
const prompt = snapshotJsonValue(input.prompt)
if (prompt === undefined) {
throw new TypeError('subagent prompt must be losslessly JSON-serializable')
}
const outputSchema = input.outputSchema === undefined
? undefined
: snapshotJsonValue(input.outputSchema)
if (input.outputSchema !== undefined && outputSchema === undefined) {
throw new OutputSchemaError(['schema annotation must be JSON data; the complete schema must be losslessly JSON-serializable'])
}
if (outputSchema !== undefined) assertSupportedOutputSchema(outputSchema)
const agentOptions = input.agentOptions === undefined
? undefined
: snapshotJsonValue(input.agentOptions)
if (input.agentOptions !== undefined && agentOptions === undefined) {
throw new TypeError('subagent agent options must be losslessly JSON-serializable')
}
const toolFilter = input.toolFilter === undefined
? undefined
: snapshotJsonValue(input.toolFilter)
if (input.toolFilter !== undefined && toolFilter === undefined) {
throw new TypeError('subagent tool filter must be losslessly JSON-serializable')
}
// Detach every data field before crossing into a provider. Parent/signal
// are live identity capabilities and stay exact; the mutable request record
// and its arrays/objects are never retained, so every backend (including an
// async out-of-process one) observes the request accepted at start.
const accepted: SubagentStartRequest = {
prompt: structuredClone(request.prompt),
prompt,
parent,
...request.signal !== undefined ? { signal: request.signal } : {},
...request.agentOptions !== undefined ? { agentOptions: structuredClone(request.agentOptions) } : {},
...request.outputSchema !== undefined ? { outputSchema: structuredClone(request.outputSchema) } : {},
...request.maxDepth !== undefined ? { maxDepth: request.maxDepth } : {},
...request.toolFilter !== undefined ? { toolFilter: structuredClone(request.toolFilter) } : {},
...request.persona !== undefined ? { persona: request.persona } : {},
...input.signal !== undefined ? { signal: input.signal } : {},
...agentOptions !== undefined ? { agentOptions } : {},
...outputSchema !== undefined ? { outputSchema } : {},
...input.maxDepth !== undefined ? { maxDepth: input.maxDepth } : {},
...toolFilter !== undefined ? { toolFilter } : {},
...input.persona !== undefined ? { persona: input.persona } : {},
}
const run = provider.start(accepted)
const providerRun = provider.start(accepted)
// Provider-owned run objects can be accessor-backed too. Capture every
// public field exactly once, bind methods to the provider's original handle,
// and expose only this service-owned wrapper. The normalized result promise
// is also the one lifecycle telemetry observes, so the caller and observers
// cannot receive different values from stateful accessors.
const id = providerRun.id
const started = providerRun.started
const providerResult = providerRun.result
const cancel = providerRun.cancel.bind(providerRun)
const sendMessage = providerRun.sendMessage?.bind(providerRun)
const dispose = providerRun.dispose.bind(providerRun)
const resume = providerRun.resume?.bind(providerRun)
const result = providerResult.then(value => this.snapshotRunResult(value))
const run: SubagentRun = Object.freeze({
id,
started,
result,
cancel,
dispose,
...sendMessage === undefined
? {}
: { sendMessage },
...resume === undefined
? {}
: { resume },
})
// Observe result settlement IMMEDIATELY, before waiting on readiness. A
// provider may fail both promises in the same turn; deferring the rejection
@@ -296,26 +366,16 @@ export class SubagentService extends Service {
// remains observable by the run's consumer, but telemetry must not claim
// that a child started.
}
void run.result.then(
(result) => {
// Snapshot before the caller's own `await run.result` continuation. Even
// when readiness is still pending, buffering the clone rather than the
// caller-owned result keeps the eventual observe-only event immutable
// with respect to consumer mutation.
let lastAssistantMessage: SubagentResult['output'] | undefined
try {
lastAssistantMessage = structuredClone(result.output)
} catch (error: unknown) {
this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`)
}
void result.then(
(value) => {
deliverEnd({
provider: name,
id: run.id,
stopReason: result.stopReason,
...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {},
id,
stopReason: value.stopReason,
lastAssistantMessage: value.output,
})
},
() => { deliverEnd({ provider: name, id: run.id, stopReason: 'error' }) },
() => { deliverEnd({ provider: name, id, stopReason: 'error' }) },
)
// Readiness is the publication boundary owned by the provider. For
@@ -324,10 +384,10 @@ export class SubagentService extends Service {
// per-listener containment, then flush an outcome that settled unusually
// early. A readiness rejection is handled here and deliberately emits no
// false start/end pair; the result path above remains independently handled.
void run.started.then(
void started.then(
() => {
readiness = 'started'
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent)
this.emitLifecycle('subagent/start', { provider: name, id }, parent)
if (pendingEnd !== undefined) {
const info = pendingEnd
pendingEnd = undefined
@@ -342,6 +402,54 @@ export class SubagentService extends Service {
return run
}
/** Normalize one provider result into the immutable seam value. */
private snapshotRunResult(value: SubagentResult): SubagentResult {
// Capture every provider-owned field once before validation. In particular,
// lifecycle telemetry must not reread accessors after the caller receives
// the result and observe a different terminal outcome.
const output = value.output
const structured = value.structured
const stopReason = value.stopReason
if (!Array.isArray(output)) {
throw new TypeError('subagent result output must be an array')
}
if (typeof stopReason !== 'string') {
throw new TypeError('subagent result stopReason must be a string')
}
const accepted: SubagentResult = {
output,
...structured === undefined ? {} : { structured },
stopReason,
}
const snapshot = snapshotJsonValue(accepted)
if (snapshot === undefined) {
throw new TypeError('subagent result must be losslessly JSON-serializable')
}
return deepFreeze(snapshot)
}
/** Read one coherent caller request into immutable data properties. */
private snapshotStartRequest(request: SubagentStartRequest): Readonly<SubagentStartRequest> {
const prompt = request.prompt
const parent = request.parent
const signal = request.signal
const agentOptions = request.agentOptions
const outputSchema = request.outputSchema
const maxDepth = request.maxDepth
const toolFilter = request.toolFilter
const persona = request.persona
return Object.freeze({
prompt,
parent,
...signal !== undefined ? { signal } : {},
...agentOptions !== undefined ? { agentOptions } : {},
...outputSchema !== undefined ? { outputSchema } : {},
...maxDepth !== undefined ? { maxDepth } : {},
...toolFilter !== undefined ? { toolFilter } : {},
...persona !== undefined ? { persona } : {},
})
}
/**
* Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch
* each subscriber individually and log (never propagate) a thrown one, so one
@@ -374,14 +482,15 @@ export class SubagentService extends Service {
// parent-scoped listener observes only its own delegations); the
// provider-removed registry notification stays unfiltered. The carrier is
// args[0] of the dispatch call, exactly as cordis' own emit spells it.
const acceptedInfo = typeof info === 'string' ? info : deepFreeze(info)
const dispatchArgs: unknown[] = parent === undefined
? [name, info]
: [scopeTarget(this, parent), name, info]
? [name, acceptedInfo]
: [scopeTarget(this, parent), name, acceptedInfo]
for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) {
try {
callback(info)
callback(acceptedInfo)
} catch (error: unknown) {
this.ctx.logger.warn(`subagent: ${name} listener threw: ${String(error)}`)
this.ctx.logger.warn(`subagent: ${name} listener threw: ${renderThrown(error)}`)
}
}
}
@@ -409,4 +518,13 @@ export class SubagentService extends Service {
}
}
/** Render an arbitrary thrown value without allowing coercion to throw again. */
function renderThrown(value: unknown): string {
try {
return value instanceof Error ? `${value.name}: ${value.message}` : String(value)
} catch {
return '<unrenderable thrown value>'
}
}
export default SubagentService

View File

@@ -173,6 +173,16 @@ describe('SubagentService', () => {
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))
@@ -184,6 +194,7 @@ describe('SubagentService', () => {
pluginCtx.subagents.registerProvider(provider)
},
})
expect(capabilityReads).toBe(1)
const accepted = ctx.subagents.getProvider('stable')
const mutable = provider as unknown as {
@@ -216,7 +227,10 @@ describe('SubagentService', () => {
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'] },
@@ -277,6 +291,142 @@ describe('SubagentService', () => {
ctx.subagents.start('strong', baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } }, maxDepth: 1 }))
expect(provider.startCount).toBe(1)
})
it.each([
{ label: 'NaN', value: Number.NaN },
{ label: 'a fraction', value: 1.5 },
{ label: 'a negative integer', value: -1 },
{ label: 'negative zero', value: -0 },
])('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 () => {
@@ -299,6 +449,165 @@ describe('SubagentService', () => {
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('waits for provider readiness and observes an early result rejection without reordering lifecycle', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
@@ -428,12 +737,13 @@ describe('SubagentService', () => {
}))
})
it('observe-only: a subagent/end listener mutating lastAssistantMessage cannot corrupt the caller\'s result', async () => {
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. The service
// deep-clones output onto the event, so the listener mutates only its copy.
// 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(
@@ -448,12 +758,22 @@ describe('SubagentService', () => {
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's result.output is untouched by the listener's mutation.
// 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 () => {
@@ -483,17 +803,14 @@ describe('SubagentService', () => {
expect('lastAssistantMessage' in endInfo).toBe(false) // no output exists on reject
})
it('contains a structuredClone failure: emits subagent/end without lastAssistantMessage (no unhandled rejection)', async () => {
// The clone runs inside onFulfilled, OUTSIDE emitLifecycle's per-listener
// containment. An uncloneable output (here a content block carrying a
// function) would otherwise throw and become an unhandled rejection on the
// detached `.then`. The handler must instead log and emit the event WITHOUT
// lastAssistantMessage, still carrying the real stopReason.
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 warn = vi.fn(); ctx.logger.warn = warn as never
// An output value structuredClone cannot handle (a function is uncloneable).
const uncloneable = [{ type: 'text', text: 'x', evil: () => 0 }] as unknown as SubagentResult['output']
const nonJsonOutput = [{ type: 'text', text: 'x', evil: () => 0 }] as unknown as SubagentResult['output']
ctx.subagents.registerProvider({
name: 'unclone',
capabilities: NO_CAPS,
@@ -501,7 +818,7 @@ describe('SubagentService', () => {
start: () => ({
id: AgentId('unclone-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: uncloneable, stopReason: 'completed' } as SubagentResult),
result: Promise.resolve({ output: nonJsonOutput, stopReason: 'completed' } as SubagentResult),
cancel() {},
dispose: async () => {},
}),
@@ -510,13 +827,52 @@ describe('SubagentService', () => {
const ended = vi.fn()
ctx.on('subagent/end', ended)
const run = ctx.subagents.start('unclone', baseRequest())
await run.result
await expect(run.result).rejects.toThrow('subagent result must be losslessly JSON-serializable')
await Promise.resolve()
const endInfo = ended.mock.calls[0]![0] as Record<string, unknown>
expect(endInfo.stopReason).toBe('completed') // the real outcome is preserved
expect('lastAssistantMessage' in endInfo).toBe(false) // clone failed → omitted, not crashed
expect(warn).toHaveBeenCalledWith(expect.stringContaining('could not clone'))
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 () => {
@@ -566,6 +922,60 @@ describe('SubagentService', () => {
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
})
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)

View File

@@ -17,6 +17,9 @@
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../llm/llm"
},