fix(scope): harden final ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-12 05:13:17 +08:00
parent 36b8370027
commit a9cb70d896
52 changed files with 2839 additions and 514 deletions

View File

@@ -9,11 +9,11 @@ The shared **in-process subagent run driver**. A library with no provider or imp
Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`):
1. reads every public request and seed field once before asynchronous owner setup: the parent and signal remain identity capabilities, while tool filter, seed, agent options, output schema, and prompt are each materialized by the shared one-pass lossless-JSON snapshot. It computes child depth = `depthOf(parent) + 1`, rejects `request.maxDepth` overflow with `SubagentDepthError`, reports an invalid schema as `OutputSchemaError`, and derives both the child prefix and `seedLength` from the same detached seed;
2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, and manual `run.dispose()` all dispose this exact node, preventing publication after it becomes inactive and awaiting the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child (and rejects if publication never happens), while cancellation during creation is recorded and applied when a child exists;
2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, manual `run.dispose()`, and cancellation before readiness all dispose this exact node, preventing publication after it becomes inactive and sharing the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child and rejects when pre-readiness cancellation rolls the transaction back;
3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent;
4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field).
`SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope); `cancel()` records its request even before publication and cancels the child immediately once available. A cancel landing before any `turn/end` still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`.
`SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope). Before readiness, `cancel()` deactivates the unpublished owner so no agent, session, or lifecycle event can escape; after readiness it cancels the live child immediately. Either path records the cancellation, so a cancel landing before any `turn/end` settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`.
### `InProcessRunOptions`

View File

@@ -110,7 +110,10 @@ async function quiesceFiber(fiber: Fiber): Promise<void> {
* before the turn starts). The final `assistant/message` is the result output,
* the matching `turn/end.reason` the stop reason. `dispose()` delegates to the
* factory's {@link AgentHandle.dispose} (stop loop → await quiescence → remove
* session); `cancel()` cancels the child's in-flight turn.
* session). `cancel()` cancels a published child's in-flight turn; before
* readiness it instead deactivates the unpublished run-owner transaction, so
* `started` rejects, no agent/session lifecycle is published, and `result`
* resolves `aborted`.
*
* Throws {@link SubagentDepthError} before creating anything when the child's
* depth (parent depth + 1) would exceed `request.maxDepth`.
@@ -219,9 +222,10 @@ export function startInProcessRun(
// Install it after provider ownership succeeds but BEFORE awaiting creation,
// so an inactive provider cannot leave an orphaned listener and abort/dispose
// during async setup is still recorded and applied the moment a child exists.
// `cancelled` records that a cancel was requested at all, so the pre-turn
// cancel window — where the child clears the queued prompt before any
// `turn/end` is logged — settles as `aborted` (honoring the cancel contract)
// `cancelled` records that a cancel was requested at all. Before readiness,
// cancellation deactivates the unpublished run-owner transaction so the
// factory cannot publish an agent or session. After readiness, it cancels the
// live child. Either path settles as `aborted` (honoring the cancel contract)
// rather than falling through to the no-turn `error` mapping.
let cancelled = false
// An accessor, not an inline read: `cancelled` mutates from closures (the
@@ -230,13 +234,6 @@ export function startInProcessRun(
const isCancelled = (): boolean => cancelled
let child: Agent | undefined
let handle: AgentHandle | undefined
let disposeRequested = false
const isDisposeRequested = (): boolean => disposeRequested
const requestCancel = (reason: string): void => {
cancelled = true
child?.cancel(reason)
}
const onAbort = (): void => { requestCancel('subagent cancelled') }
// One run-owned Cordis fiber is the common ownership node. Install the
// provider effect FIRST: a start racing an already-unloading provider fails
@@ -250,11 +247,28 @@ export function startInProcessRun(
let ownerFiber: (Fiber & PromiseLike<Fiber>) | undefined
let ownerSetupError: unknown
let ownerDisposing: Promise<void> | undefined
const disposeOwner = (): Promise<void> => (ownerDisposing ??= ownerFiber === undefined
? Promise.resolve()
: quiesceFiber(ownerFiber))
let manualDisposeRequested = false
const isManualDisposeRequested = (): boolean => manualDisposeRequested
const disposeOwner = (): Promise<void> => {
if (ownerDisposing !== undefined) return ownerDisposing
// An already-aborted request is observed before the owner fiber is minted.
// Do not memoize that no-op: the post-plugin cancellation check below must
// still be able to claim and deactivate the real fiber.
if (ownerFiber === undefined) return Promise.resolve()
ownerDisposing = quiesceFiber(ownerFiber)
// Pre-readiness cancellation is synchronous fire-and-forget at the public
// `cancel()` boundary. Observe a teardown rejection here; dispose() still
// awaits the same memoized promise and reports it to an explicit caller.
void ownerDisposing.catch(() => undefined)
return ownerDisposing
}
const requestCancel = (reason: string): void => {
cancelled = true
if (child === undefined) {
if (ownerFiber !== undefined) void disposeOwner()
return
}
child.cancel(reason)
}
const onAbort = (): void => { requestCancel('subagent cancelled') }
const unlinkProvider = ctx.effect(() => () => {
requestCancel('subagent provider disposed')
return disposeOwner()
@@ -265,6 +279,10 @@ export function startInProcessRun(
ownerFiber = parent.ctx.plugin(Object.assign(subagentRunOwner, {
inject: ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'],
}))
// `signal.aborted` is checked before this fiber exists. Once it does, make
// that recorded cancellation effective immediately; awaiting creation must
// observe an inactive owner instead of reaching the publication boundary.
if (isCancelled()) void disposeOwner()
} catch (error: unknown) {
ownerSetupError = error
}
@@ -299,8 +317,6 @@ export function startInProcessRun(
})
handle = created
child = created.agent
if (isCancelled()) created.agent.cancel('subagent cancelled')
return created.agent
})()
@@ -322,10 +338,9 @@ export function startInProcessRun(
// without manufacturing an unreachable runtime branch.
liveChild = child as Agent
} catch (error: unknown) {
if (isManualDisposeRequested()) return { output: [], stopReason: 'aborted' }
if (isCancelled()) return { output: [], stopReason: 'aborted' }
throw error instanceof Error ? error : new Error('subagent child creation failed with a non-Error value', { cause: error })
}
if (isCancelled() || isDisposeRequested()) return { output: [], stopReason: 'aborted' }
liveChild.send(prompt)
await liveChild.whenIdle()
// Deliberately NO re-prompt when a structured child finishes cleanly
@@ -348,8 +363,6 @@ export function startInProcessRun(
async dispose(): Promise<void> {
return (disposing ??= (async () => {
signal?.removeEventListener('abort', onAbort)
disposeRequested = true
manualDisposeRequested = true
requestCancel('subagent disposed during creation')
// Removing provider ownership and disposing the common run-owner fiber
// are the same quiescence transaction; parent disposal may already have

View File

@@ -269,6 +269,38 @@ describe('startInProcessRun', () => {
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
})
it('observes detached pre-readiness teardown failure and reports it to explicit dispose', async () => {
const { ctx, parent } = await setup([])
function inertOwner(): void {}
const ownerFiber = ctx.plugin(inertOwner)
await ownerFiber
const disposeFailure = new Error('owner dispose exploded')
const disposeSpy = vi.spyOn(ownerFiber, 'dispose').mockImplementation(() => { throw disposeFailure })
const rejectingOwnerCtx = {
agents: { create: () => Promise.reject(new Error('creation stopped by cancellation')) },
} as unknown as Context
const parentWithFailingTeardown = {
options: parent.options,
session: parent.session,
ctx: {
plugin(plugin: (inner: Context) => void) {
plugin(rejectingOwnerCtx)
return ownerFiber
},
},
} as unknown as Agent
const run = startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent: parentWithFailingTeardown,
}, {})
run.cancel('cancel before readiness')
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
await expect(run.dispose()).rejects.toBe(disposeFailure)
disposeSpy.mockRestore()
await ownerFiber.dispose()
})
it('does not attach an abort listener when provider ownership is already inactive', async () => {
const { ctx, parent } = await setup([])
let providerCtx: Context | undefined

View File

@@ -6,7 +6,7 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../
## What it does
`start(request)` delegates to `startInProcessRun(ctx, request, {})` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. The driver creates one run-owner fiber under `parent.ctx`; parent teardown, this provider's teardown, and manual disposal all converge there before child publication. Its `run.started` boundary resolves only after the fresh child is published, so `subagent/start` observers see a live registry entry. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose).
`start(request)` delegates to `startInProcessRun(ctx, request, {})` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. The driver creates one run-owner fiber under `parent.ctx`; parent teardown, this provider's teardown, manual disposal, and cancellation before readiness all converge there before child publication. Its `run.started` boundary resolves only after the fresh child is published, so `subagent/start` observers see a live registry entry; a same-tick cancel deactivates the unpublished transaction instead, rejects readiness, resolves the result as `aborted`, and emits no agent/session or subagent lifecycle. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose).
## Capabilities

View File

@@ -163,20 +163,31 @@ describe('dsh-subagent-spawn', () => {
await run.dispose()
})
it('cancelling BEFORE the child turn starts settles aborted, not error', async () => {
// Regression: a cancel landing in the pre-turn window clears the queued
// prompt before any `turn/end` is logged. Deriving the stop reason from
// `turn/end` alone then mis-maps the no-turn case to `error`; the run must
// honor the cancel contract and settle `aborted`. The cancel is synchronous
// (same tick as start, before the loop's queued-wait continuation runs), so
// the turn is dropped and the empty script is never consumed.
it('same-tick cancellation rejects readiness and prevents child publication', async () => {
// Regression: cancellation before readiness used to set a flag but let the
// async factory publish a child anyway, so `started` fulfilled and lifecycle
// observers saw an agent for an attempt the caller had already cancelled.
// The empty script also proves no model turn can run.
const { ctx, parent } = await setup([])
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
ctx.on('subagent/start', () => void published.push('subagent/start'))
ctx.on('subagent/end', () => void published.push('subagent/end'))
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
run.cancel('early')
const result = await run.result
expect(result.stopReason).toBe('aborted')
expect(result.output).toEqual([])
await expect(run.started).rejects.toThrow()
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
await run.dispose()
await Promise.resolve()
expect(ctx.agents.get(run.id)).toBeUndefined()
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
expect(published).toEqual([])
})
it('a cancel from agent/queued maps a no-turn child log to aborted', async () => {

View File

@@ -18,23 +18,23 @@ Unlike the bash seam (one executor per context, second load throws), **multiple
| Member | Semantics |
|---|---|
| `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. |
| `registerProvider(provider)` | Read and validate the name, capability object and four boolean flags, `inheritsParentContext`, and `start` callback exactly once, then register a frozen acceptance snapshot under the accepted name. Malformed fixed fields fail loud before registration; 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), 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. |
| `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`. Acquire and memoize the provider run's disposer before reading the rest of its handle, then return a frozen service-owned wrapper whose 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. Malformed handle access/binding starts rollback before the synchronous fault escapes; malformed terminal data rejects only after rollback reaches quiescence. 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
- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter/persona`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored.
- **Runtime features** (steering, resume) are **optional methods** on `SubagentRun` (`sendMessage?`, `resume?`). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path.
Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). The model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it.
Beside `capabilities` sits one DESCRIPTIVE fact: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). The service validates that the descriptor is a boolean but does not interpret or enforce its meaning; the model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it.
## Run lifecycle
`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: 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.
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. A synchronous listener throw or returned-promise rejection is logged per listener; later listeners still run synchronously, and async listeners remain concurrent fire-and-forget. 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)

View File

@@ -169,9 +169,11 @@ export class SubagentService extends Service {
* Register a provider under its `provider.name`. Throws {@link SubagentError}
* (`DUPLICATE_PROVIDER`) if the name is already taken. The registry snapshots
* the name, static descriptors, and `start` callback identity at acceptance;
* later caller mutation cannot change lookup, capability validation, consumer
* wording, dispatch, or HMR cleanup. The callback remains bound to the
* original provider object, so provider-owned mutable state stays live.
* every fixed field and capability flag is read once and validated before
* registration, so malformed provider objects fail loud without entering the
* registry. Later caller mutation cannot change lookup, capability validation,
* consumer wording, dispatch, or HMR cleanup. The callback remains bound to
* the original provider object, so provider-owned mutable state stays live.
* Effect-scoped: disposed with the calling fiber (HMR-safe). Emits
* `subagent/provider-added` after the registration and
* `subagent/provider-removed` on unregistration, so consumers can mirror
@@ -187,18 +189,49 @@ 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 name: unknown = provider.name
const inputCapabilities: unknown = provider.capabilities
const inheritsParentContext: unknown = provider.inheritsParentContext
// eslint-disable-next-line @typescript-eslint/unbound-method
const inputStart: unknown = provider.start
if (typeof name !== 'string') {
throw new TypeError('subagent provider name must be a string')
}
if (inputCapabilities === null || typeof inputCapabilities !== 'object' || Array.isArray(inputCapabilities)) {
throw new TypeError(`subagent provider "${name}" capabilities must be an object`)
}
const inputCapabilityFields = inputCapabilities as Record<keyof SubagentCapabilities, unknown>
const outputSchema = inputCapabilityFields.outputSchema
const depthLimit = inputCapabilityFields.depthLimit
const toolFilter = inputCapabilityFields.toolFilter
const persona = inputCapabilityFields.persona
for (const [capability, value] of [
['outputSchema', outputSchema],
['depthLimit', depthLimit],
['toolFilter', toolFilter],
['persona', persona],
] as const) {
if (typeof value !== 'boolean') {
throw new TypeError(`subagent provider "${name}" capability "${capability}" must be a boolean`)
}
}
if (typeof inheritsParentContext !== 'boolean') {
throw new TypeError(`subagent provider "${name}" inheritsParentContext must be a boolean`)
}
if (typeof inputStart !== 'function') {
throw new TypeError(`subagent provider "${name}" start must be a function`)
}
const capabilities: SubagentCapabilities = Object.freeze({
outputSchema: inputCapabilities.outputSchema,
depthLimit: inputCapabilities.depthLimit,
toolFilter: inputCapabilities.toolFilter,
persona: inputCapabilities.persona,
outputSchema: outputSchema as boolean,
depthLimit: depthLimit as boolean,
toolFilter: toolFilter as boolean,
persona: persona as boolean,
})
const snapshot: SubagentProvider = Object.freeze({
name: provider.name,
name,
capabilities,
inheritsParentContext: provider.inheritsParentContext,
start: provider.start.bind(provider),
inheritsParentContext,
start: Function.prototype.bind.call(inputStart, provider) as SubagentProvider['start'],
})
const dispose = this.ctx.effect(function* (this: SubagentService) {
if (this.providers.has(snapshot.name)) {
@@ -255,7 +288,10 @@ export class SubagentService extends Service {
* {@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` /
* shared by the caller and lifecycle telemetry. Once a provider returns a
* callable disposer, malformed handle access/binding starts rollback before
* the synchronous fault escapes; malformed terminal data rejects only after
* that same memoized disposal reaches quiescence. 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.
@@ -324,82 +360,176 @@ export class SubagentService extends Service {
...toolFilter !== undefined ? { toolFilter } : {},
...input.persona !== undefined ? { persona: input.persona } : {},
}
const providerRun = provider.start(accepted)
const providerRun: unknown = provider.start(accepted)
if (providerRun === null || (typeof providerRun !== 'object' && typeof providerRun !== 'function')) {
throw new TypeError(`subagent provider "${name}" start must return a SubagentRun object`)
}
const acceptedRun = providerRun as SubagentRun
// Acquire the one rollback capability BEFORE touching any other provider-run
// field. Once start() returned a handle, the service owns an accepted live
// attempt; a hostile later accessor or bind must not make that attempt
// unreachable. The wrapper also memoizes provider disposal, so automatic
// rollback and a racing caller join one quiescence transaction even if a
// contract-violating provider forgot to make its own method idempotent.
// eslint-disable-next-line @typescript-eslint/unbound-method
const inputDispose = acceptedRun.dispose
if (typeof inputDispose !== 'function') {
throw new TypeError(`subagent provider "${name}" run dispose must be a function`)
}
let disposal: Promise<void> | undefined
const dispose = (): Promise<void> => {
if (disposal === undefined) {
try {
// Invoke through the captured callable without reading its public
// `bind`/`length`/`name` properties. Disposal is the recovery
// capability itself; hostile function metadata must not prevent the
// seam from exercising it when a later handle field is malformed.
disposal = Promise.resolve(Reflect.apply(inputDispose, acceptedRun, []))
} catch (error: unknown) {
disposal = Promise.reject(error instanceof Error
? error
: new Error('subagent provider run dispose threw a non-Error value', { cause: error }))
}
}
return disposal
}
// 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
// handler until `started` fulfilled would leave `result` transiently
// unhandled. The settled event is buffered until start has been announced,
// preserving start → end order even for an already-settled scripted run.
let readiness: 'pending' | 'started' | 'failed' = 'pending'
let pendingEnd: SubagentRunEndInfo | undefined
const deliverEnd = (info: SubagentRunEndInfo): void => {
if (readiness === 'started') this.emitLifecycle('subagent/end', info, parent)
else if (readiness === 'pending') pendingEnd = info
// A pre-publication readiness failure has no lifecycle pair; result
// remains observable by the run's consumer, but telemetry must not claim
// that a child started.
}
void result.then(
(value) => {
deliverEnd({
provider: name,
id,
stopReason: value.stopReason,
lastAssistantMessage: value.output,
})
},
() => { deliverEnd({ provider: name, id, stopReason: 'error' }) },
)
// Readiness is the publication boundary owned by the provider. For
// in-process runs, fulfillment means the agent registry already contains
// `run.id`; for ACP it means the remote session exists. Emit start with
// 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 started.then(
() => {
readiness = 'started'
this.emitLifecycle('subagent/start', { provider: name, id }, parent)
if (pendingEnd !== undefined) {
const info = pendingEnd
pendingEnd = undefined
this.emitLifecycle('subagent/end', info, parent)
try {
const id = acceptedRun.id
if (typeof id !== 'string') {
throw new TypeError(`subagent provider "${name}" run id must be a string`)
}
const started = acceptedRun.started
if (!(started instanceof Promise)) {
throw new TypeError(`subagent provider "${name}" run started must be a Promise`)
}
// Observe each accepted provider promise before reading the next hostile
// field. A later accessor/validation failure prevents a wrapper from being
// returned, but must not leave an already-rejected provider promise
// unhandled while rollback proceeds.
void started.catch(() => undefined)
const providerResult = acceptedRun.result
if (!(providerResult instanceof Promise)) {
throw new TypeError(`subagent provider "${name}" run result must be a Promise`)
}
void providerResult.catch(() => undefined)
// eslint-disable-next-line @typescript-eslint/unbound-method
const inputCancel = acceptedRun.cancel
if (typeof inputCancel !== 'function') {
throw new TypeError(`subagent provider "${name}" run cancel must be a function`)
}
// eslint-disable-next-line @typescript-eslint/unbound-method
const inputSendMessage = acceptedRun.sendMessage
if (inputSendMessage !== undefined && typeof inputSendMessage !== 'function') {
throw new TypeError(`subagent provider "${name}" run sendMessage must be a function when provided`)
}
// eslint-disable-next-line @typescript-eslint/unbound-method
const inputResume = acceptedRun.resume
if (inputResume !== undefined && typeof inputResume !== 'function') {
throw new TypeError(`subagent provider "${name}" run resume must be a function when provided`)
}
const cancel = Function.prototype.bind.call(inputCancel, acceptedRun) as SubagentRun['cancel']
const sendMessage = inputSendMessage === undefined
? undefined
: Function.prototype.bind.call(inputSendMessage, acceptedRun) as NonNullable<SubagentRun['sendMessage']>
const resume = inputResume === undefined
? undefined
: Function.prototype.bind.call(inputResume, acceptedRun) as NonNullable<SubagentRun['resume']>
const result = providerResult.then(async (value) => {
try {
return this.snapshotRunResult(value)
} catch (error: unknown) {
// A malformed terminal value is an infrastructure contract fault. The
// result rejects only after the accepted provider attempt has reached
// quiescence, so a caller cannot lose the only cleanup handle by merely
// observing the normalization failure.
await this.rollbackProviderRun(name, dispose)
throw error
}
},
() => {
readiness = 'failed'
pendingEnd = undefined
},
)
return run
})
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
// handler until `started` fulfilled would leave `result` transiently
// unhandled. The settled event is buffered until start has been announced,
// preserving start → end order even for an already-settled scripted run.
let readiness: 'pending' | 'started' | 'failed' = 'pending'
let pendingEnd: SubagentRunEndInfo | undefined
const deliverEnd = (info: SubagentRunEndInfo): void => {
if (readiness === 'started') this.emitLifecycle('subagent/end', info, parent)
else if (readiness === 'pending') pendingEnd = info
// A pre-publication readiness failure has no lifecycle pair; result
// remains observable by the run's consumer, but telemetry must not claim
// that a child started.
}
void result.then(
(value) => {
deliverEnd({
provider: name,
id,
stopReason: value.stopReason,
lastAssistantMessage: value.output,
})
},
() => { deliverEnd({ provider: name, id, stopReason: 'error' }) },
)
// Readiness is the publication boundary owned by the provider. For
// in-process runs, fulfillment means the agent registry already contains
// `run.id`; for ACP it means the remote session exists. Emit start with
// 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 started.then(
() => {
readiness = 'started'
this.emitLifecycle('subagent/start', { provider: name, id }, parent)
if (pendingEnd !== undefined) {
const info = pendingEnd
pendingEnd = undefined
this.emitLifecycle('subagent/end', info, parent)
}
},
() => {
readiness = 'failed'
pendingEnd = undefined
},
)
return run
} catch (error: unknown) {
// start() has already transferred a live attempt to the seam. Begin
// rollback synchronously before surfacing the malformed-handle failure;
// the contained cleanup promise prevents either a resource leak or an
// unhandled rejection even though this API cannot synchronously await it.
void this.rollbackProviderRun(name, dispose)
throw error
}
}
/** Dispose one malformed provider attempt without letting cleanup mask the contract fault. */
private async rollbackProviderRun(providerName: string, dispose: () => Promise<void>): Promise<void> {
try {
await dispose()
} catch (error: unknown) {
this.ctx.logger.warn(`subagent provider "${providerName}" malformed run rollback failed: ${renderThrown(error)}`)
}
}
/** Normalize one provider result into the immutable seam value. */
@@ -452,10 +582,12 @@ export class SubagentService extends Service {
/**
* Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch
* each subscriber individually and log (never propagate) a thrown one, so one
* bad subscriber can neither strand the already-live run, surface as an
* unhandled rejection on the detached settle hook, NOR starve the listeners
* registered after it. A single try/catch around `ctx.emit` would not do the
* each subscriber individually and log (never propagate) either a synchronous
* throw or a returned-promise rejection, so one bad subscriber can neither
* strand the already-live run, surface as an unhandled rejection on the
* detached settle hook, NOR starve the listeners registered after it. Async
* listeners remain concurrent fire-and-forget; dispatch does not await or
* serialize them. A single try/catch around `ctx.emit` would not do the
* last part — cordis `emit` runs listeners in a `.map(cb => cb())` that halts
* on the first throw — so this resolves the listener callbacks via
* `ctx.events.dispatch` and contains each call, the same guarantee
@@ -488,7 +620,14 @@ export class SubagentService extends Service {
: [scopeTarget(this, parent), name, acceptedInfo]
for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) {
try {
callback(acceptedInfo)
const returned: unknown = callback(acceptedInfo)
// Plain emits remain fire-and-forget and every callback is still invoked
// synchronously in this loop. Observe a returned promise independently so
// an async listener rejection is contained without serializing listeners
// or delaying provider/run lifecycle.
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`subagent: ${name} listener rejected: ${renderThrown(error)}`)
})
} catch (error: unknown) {
this.ctx.logger.warn(`subagent: ${name} listener threw: ${renderThrown(error)}`)
}

View File

@@ -150,6 +150,93 @@ describe('SubagentService', () => {
}
})
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 context 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)
@@ -608,6 +695,178 @@ describe('SubagentService', () => {
})
})
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)
@@ -811,6 +1070,8 @@ describe('SubagentService', () => {
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,
@@ -820,16 +1081,23 @@ describe('SubagentService', () => {
started: Promise.resolve(),
result: Promise.resolve({ output: nonJsonOutput, stopReason: 'completed' } as SubagentResult),
cancel() {},
dispose: async () => {},
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)
@@ -922,6 +1190,42 @@ describe('SubagentService', () => {
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)