refactor(subagent): unify async readiness and cancellation

This commit is contained in:
Tianyi Cui
2026-07-12 22:41:59 +08:00
parent 02ca71db57
commit bb3f6bd736
49 changed files with 1350 additions and 4147 deletions
+28 -18
View File
@@ -29,11 +29,10 @@ const STOP_REASONS = ['completed', 'aborted', 'error', 'max-tokens', 'refusal']
const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }
/**
* A scripted provider: every {@link start} returns a run whose `result`
* resolves on a microtask with the configured reply (and a structured value
* when the request asked for one and the capability is on). `dispose` is a
* no-op; a `cancel()` before the result settles flips the stop reason to
* `aborted`, so the cancellation path is observable in a test.
* A scripted provider: every {@link start} returns a ready run whose `result`
* resolves on the next task with the configured reply (and a structured value
* when the request asked for one and the capability is on). The required
* signal and `dispose()` both flip an unsettled result to `aborted`.
*/
class MockSubagentProvider implements SubagentProvider {
readonly capabilities: SubagentCapabilities
@@ -47,12 +46,22 @@ class MockSubagentProvider implements SubagentProvider {
this.inheritsParentContext = config.inheritsParentContext ?? false
}
start(request: SubagentStartRequest): SubagentRun {
async start(request: SubagentStartRequest): Promise<SubagentRun> {
if (request.signal.aborted) throw new Error('mock subagent start aborted before publication')
const reply = this.config.reply ?? 'mock subagent reply'
const output: ContentBlock[] = [{ type: 'text', text: reply }]
const wantsStructured = request.outputSchema !== undefined && this.capabilities.outputSchema
const baseStop: SubagentStopReason = this.config.stopReason ?? 'completed'
let cancelled = false
const flags = { cancelled: false }
const onAbort = (): void => { flags.cancelled = true }
request.signal.addEventListener('abort', onAbort, { once: true })
// Make publication genuinely asynchronous so a same-turn abort is still
// a provider-owned startup failure rather than a returned live run.
await Promise.resolve()
if (flags.cancelled) {
request.signal.removeEventListener('abort', onAbort)
throw new Error('mock subagent start aborted before publication')
}
// A deterministic child id derived from the parent — no clock/random (both
// banned in deterministic paths here, and unnecessary for a scripted run).
@@ -60,21 +69,22 @@ class MockSubagentProvider implements SubagentProvider {
const resultFor = (): SubagentResult => ({
output,
structured: wantsStructured ? (this.config.structured ?? { reply }) : undefined,
stopReason: cancelled ? 'aborted' : baseStop,
...wantsStructured ? { structured: this.config.structured ?? { reply } } : {},
stopReason: flags.cancelled ? 'aborted' : baseStop,
})
const result = new Promise<SubagentResult>((resolve) => {
setTimeout(() => { resolve(resultFor()) }, 0)
}).finally(() => {
request.signal.removeEventListener('abort', onAbort)
})
return {
id,
// A scripted run has no asynchronous publication phase; it is ready as
// soon as the provider returns the handle.
started: Promise.resolve(),
result: Promise.resolve().then(resultFor),
cancel() {
cancelled = true
},
async dispose() {
// Scripted run holds no resources — nothing to await.
result,
dispose(): Promise<void> {
flags.cancelled = true
request.signal.removeEventListener('abort', onAbort)
return Promise.resolve()
},
}
}