feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers

One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
This commit is contained in:
Yichen Jiang
2026-07-09 21:22:54 +08:00
parent e7e382f9d1
commit 184e164091
83 changed files with 3909 additions and 1627 deletions

View File

@@ -38,6 +38,6 @@ The service also announces provider lifecycle: `subagent/provider-added` (the li
## 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 by default**: it starts a run and awaits `result`. Background delegation does not change this seam — the consumer registers the run with the generic `ctx.tasks` runtime and the run is collected through the shared task tools ([the background subagent tasks RFC](../../../docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md)). Steering (`sendMessage`) is part of the contract but intentionally unused. See the seam 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

@@ -14,11 +14,12 @@
* (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing
* consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages.
*
* Scope (first cut): the consumer collects synchronously — it starts a run and
* awaits {@link SubagentRun.result}. Steering ({@link SubagentRun.sendMessage})
* is part of the contract but intentionally unused; background / poll / spill
* semantics are deferred to a future redesign that unifies long-running-tool
* handling across subagents and bash.
* Scope: the seam stays collection-agnostic — a run is started and its
* `result` awaited, whether the consumer blocks on it (foreground) or
* registers it as a `ctx.tasks` background task (the generic runtime owns
* ids/polling/stop; this seam gains nothing task-shaped). Steering
* ({@link SubagentRun.sendMessage}) is part of the contract but intentionally
* unused.
*
* The `subagent/start` / `subagent/end` lifecycle events carry an OBSERVE-ONLY
* payload; `subagent/end` additionally carries the child's `lastAssistantMessage`
@@ -26,8 +27,8 @@
* FIXME(subagent-continuation): a control-flow `subagent/end` (an awaited
* waterfall returning a stop/continue decision, like the other interception
* seams) would require reshaping this emit into a waterfall, awaiting listeners
* before settling, and a `resume` capability on the in-process provider — part
* of the deferred background/steering redesign, NOT this observe-only cut.
* before settling, and a `resume` capability on the in-process provider — a
* deliberate deferral, NOT part of this observe-only cut.
*
* @module @deepseek-ai/dsh-subagent
*/

View File

@@ -4,7 +4,7 @@ The model-facing `subagent` tool: delegate a self-contained task to a child agen
## Provider selection is config, not model-facing
This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one.
This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt, run_in_background? }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one.
## The description states the provider's context contract
@@ -14,10 +14,13 @@ The tool description and the `prompt` parameter description are DERIVED from the
|---|---|
| `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). |
| `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. |
| `enableRunInBackground` | Expose `run_in_background` in this instance's schema (default `true`). Disabled, the parameter is absent entirely — delegation through this instance stays strictly synchronous. |
| `agentOptions` | Default per-child `{ model? }` applied to every spawned child. (No per-child persona: the deployment persona is a context-wide section every agent shares.) |
## Lifecycle (synchronous collect)
## Foreground lifecycle (synchronous collect)
`execute` starts a run on the configured provider and **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The tool's abort signal (`exec.signal`) is bridged to `run.cancel()`. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success.
Background / poll collection is deferred (see the [RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes.
## Background delegation (a generic task)
`run_in_background: true` refuses an already-aborted `exec.signal`, starts the run, registers `{ kind: 'subagent', label: description, owner: parent, cancel, done }` with `ctx.tasks` (`@deepseek-ai/dsh-tasks`), and returns `started background subagent task <id>` — the parent keeps working and collects/stops the child through the generic `task_output`/`task_list`/`task_kill` tools (`@deepseek-ai/dsh-tool-tasks`). The tool-call signal is deliberately NOT wired to the run after the id is returned; cancellation belongs to `task_kill` (its logged `reason` is forwarded to `run.cancel`) and the runtime's owner-disposal cleanup. The task is final-output-only (no incremental transcript — the child session remains the detailed trace), and its `done` settles only after `run.dispose()` (child quiescence), so owner disposal cannot resolve before the child is actually gone. Mapping (exported for tests): `runOutcome``completed` carries the final text as the task output; `aborted``killed`; `error`/`max-tokens`/`refusal`/unknown → `failed` with the reason as status-line detail — and `settleRun`, which disposes on both result paths and contains an infrastructure rejection as `failed`. A missing `ctx.tasks` fails the call loud (`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`). See the [background subagent tasks RFC](../../../docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md).

View File

@@ -25,6 +25,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
@@ -32,13 +33,15 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-mock": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -9,7 +9,7 @@
* EXACTLY ONE provider name (`Config.provider`). To expose more than one
* transport, load the plugin more than once, each bound to a different provider
* — there is no provider/type parameter in the model-facing schema. The model
* sees only `{ description, prompt }`.
* sees only `{ description, prompt }` (plus `run_in_background` when enabled).
*
* The tool DESCRIPTION is derived from the bound provider's context contract
* ({@link providerWording}): a fresh-context provider (spawn, ACP) gets the
@@ -20,13 +20,24 @@
* provider goes away — so no load-order requirement exists and an HMR reload
* of the backend re-derives the wording from the fresh provider.
*
* Collection is SYNCHRONOUS this cut: `execute` starts a run and awaits
* FOREGROUND collection is synchronous: `execute` starts a run and awaits
* `run.result` inside a `try/finally` that always disposes the run, so the
* owned child agent/session is torn down on every path (success, error, abort)
* and never leaks as a live idle child. A non-`completed` stop reason maps to an
* `isError` tool result (by throwing) rather than returning partial output as
* success.
*
* BACKGROUND delegation (`run_in_background: true`, exposed only when this
* instance's `enableRunInBackground` config allows) is a generic background
* TASK: the run is registered with `ctx.tasks` (kind `subagent`, final-output
* only — the child session remains the detailed trace) and collected/stopped
* through the generic `task_output`/`task_list`/`task_kill` tools. The
* tool-call abort signal is deliberately NOT wired to a background child:
* after the id is returned the parent step may end while the child works —
* cancellation belongs to `task_kill` and the owner-disposal cleanup. The
* task's `done` settles only after `run.dispose()` (child quiescence), which
* is what makes owner-disposal cleanup an actual no-leak guarantee.
*
* @module @deepseek-ai/dsh-tool-subagent
*/
@@ -36,6 +47,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
export const name = 'tool-subagent'
export const inject = ['tools', 'subagents']
@@ -52,6 +64,14 @@ export interface Config {
* `{ provider: 'acp', toolName: 'subagent_acp' }`.
*/
toolName?: string
/**
* Expose `run_in_background` in this instance's schema (default true).
* Disabled, the parameter is absent entirely — schema and capability never
* disagree; delegation through this instance stays strictly synchronous.
* Backgrounding also needs the `ctx.tasks` runtime at call time; a missing
* one fails the call loud with the load-these-packages message.
*/
enableRunInBackground?: boolean
/**
* Default per-child agent options (model) applied to every spawned child.
* Omitted fields fall back to the child loop's own defaults. There is no
@@ -64,6 +84,7 @@ export interface Config {
export const Config: z<Config> = z.object({
provider: z.string().required(),
toolName: z.string().default('subagent'),
enableRunInBackground: z.boolean().default(true),
agentOptions: z.object({
model: z.string(),
}),
@@ -102,6 +123,54 @@ function stopReasonError(result: SubagentResult): string | undefined {
}
}
/**
* Map a settled subagent result onto the generic task-outcome vocabulary:
* `completed` carries the final text as the task's idempotent output;
* `aborted` is the task-level `killed`; everything else — `error`,
* `max-tokens`, `refusal`, and unknown merge-extensible reasons — is `failed`
* with the reason as the status-line detail (partial output is NOT reported
* as output, mirroring the synchronous path's report-the-reason rule).
* Exported for tests.
* @param result - the child's terminal result.
* @returns the outcome for the `ctx.tasks` registration.
*/
export function runOutcome(result: SubagentResult): TaskOutcome { switch (result.stopReason) {
case 'completed':
return { status: 'completed', output: outputText(result.output) }
case 'aborted':
return { status: 'killed' }
case 'error':
case 'max-tokens':
case 'refusal':
return { status: 'failed', detail: result.stopReason }
// Merge-extensible union: an unknown terminal reason is a failure with
// the raw reason as detail, never partial output as success.
default:
return { status: 'failed', detail: String(result.stopReason) }
}
}
/**
* Settle a background run at QUIESCENCE: await the child's result, ALWAYS
* dispose the run (the owned child agent/session is released on every path),
* and only then report the mapped outcome — so the task registry's `done`,
* and therefore owner-disposal cleanup, cannot resolve before the child is
* actually gone. A rejected `run.result` (infrastructure fault — no
* SubagentResult exists) reports `failed` with the error as detail rather
* than rejecting the producer contract. Exported for tests.
* @param run - the live background run to settle and release.
* @returns the task outcome, after the run's resources are released.
*/
export async function settleRun(run: SubagentRun): Promise<TaskOutcome> {
try {
return runOutcome(await run.result)
} catch (error: unknown) {
return { status: 'failed', detail: String(error) }
} finally {
await run.dispose()
}
}
/**
* Model-facing wording per context contract ({@link SubagentProvider.inheritsParentContext}).
* A fresh child needs a standalone prompt; a forked child already sees the
@@ -150,9 +219,12 @@ export function apply(ctx: Context, config: Config): void {
let disposeTool: (() => void) | undefined
const mount = (provider: SubagentProvider): void => {
const wording = providerWording(provider.inheritsParentContext)
const backgroundEnabled = config.enableRunInBackground !== false
disposeTool = ctx.tools.register(defineTool({
name: config.toolName ?? 'subagent',
description: wording.description,
description: wording.description + (backgroundEnabled
? ' Set `run_in_background: true` to get a task id immediately and keep working; collect the final answer with `task_output` (wait: true when you are blocked on it) and stop it with `task_kill`.'
: ''),
parameters: {
description: {
type: 'string',
@@ -164,6 +236,12 @@ export function apply(ctx: Context, config: Config): void {
required: true,
description: wording.promptDescription,
},
...backgroundEnabled ? {
run_in_background: {
type: 'boolean' as const,
description: 'Run the subagent as a background task and return a task id immediately (collect with task_output, stop with task_kill).',
},
} : {},
},
async execute(args, exec): Promise<ContentBlock[]> {
const parent = exec.agent
@@ -174,6 +252,47 @@ export function apply(ctx: Context, config: Config): void {
throw new Error('subagent tool requires a calling agent (exec.agent was undefined)')
}
if (args.run_in_background === true) {
// The generic runtime owns everything task-shaped; without it a task
// id would be uncollectable — fail loud with the fix, not a dangle.
const tasks = ctx.get('tasks')
if (tasks === undefined) {
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
}
// A step already cancelled must not spawn a child. After the id is
// returned the tool-call signal is deliberately NOT wired to the run
// (the child outlives this step; cancellation belongs to task_kill
// and owner-disposal cleanup), so the request carries NO signal.
if (exec.signal?.aborted) throw new Error('subagent delegation aborted')
const run = ctx.subagents.start(config.provider, {
prompt: [{ type: 'text', text: args.prompt }],
parent,
...config.agentOptions ? { agentOptions: config.agentOptions } : {},
})
const done = settleRun(run)
let id: string
try {
id = tasks.register({
kind: 'subagent',
label: args.description,
owner: parent,
cancel: (reason) => { run.cancel(reason ?? 'background subagent task killed') },
done,
// No readOutput: a subagent task is final-output-only — the child
// session remains the detailed trace.
})
} catch (error: unknown) {
// A failed registration must not leak the just-started child: the
// model never received an id, so nothing could ever task_kill it.
// Cancel, await `done` (which settles only after run.dispose() —
// child quiescence), then fail the call with the real cause.
run.cancel('background task registration failed')
await done
throw error
}
return [{ type: 'text', text: `started background subagent task ${id}` }]
}
const request: SubagentStartRequest = {
prompt: [{ type: 'text', text: args.prompt }],
parent,

View File

@@ -5,9 +5,13 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as mock from '@deepseek-ai/dsh-subagent-mock'
import * as tool from '../src/index.ts'
import { runOutcome, settleRun } from '../src/index.ts'
/**
* Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real
@@ -60,12 +64,21 @@ describe('dsh-tool-subagent', () => {
expect(text(result)).toBe('child says hi')
})
it('exposes only description + prompt to the model (no provider/type parameter)', async () => {
it('exposes description + prompt + run_in_background to the model (no provider/type parameter)', async () => {
const ctx = await setup({ provider: 'mock' })
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')
expect(schema).toBeDefined()
const props = (schema!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
expect(Object.keys(props).sort()).toEqual(['description', 'prompt', 'run_in_background'])
expect(schema!.description).toContain('task_output')
})
it('omits run_in_background entirely when the instance disables it (schema and capability never disagree)', async () => {
const ctx = await setup({ provider: 'mock', enableRunInBackground: false })
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')
const props = (schema!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
expect(Object.keys(props).sort()).toEqual(['description', 'prompt'])
expect(schema!.description).not.toContain('task_output')
})
it.each([
@@ -452,3 +465,180 @@ describe('dsh-tool-subagent', () => {
expect(unwrapped.Config).toBeDefined()
})
})
describe('dsh-tool-subagent background mode', () => {
/** A parent agent carrying a real session token, registered in ctx.agents (owner-cleanup wiring requires a live registry entry). */
function ownerAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
const agent = { id: AgentId(`agent-${sessionId}`), inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
ctx.agents.register(agent)
return agent
}
async function backgroundSetup(toolConfig: tool.Config, mockConfig: Partial<mock.Config> = {}) {
const ctx = await setup(toolConfig, mockConfig)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks, {})
return ctx
}
it('returns a task id immediately and the answer is collected through task_output', async () => {
const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } }, { reply: 'background answer' })
const parent = ownerAgent(ctx, 'sess-parent')
const start = await callSubagent(ctx, { description: 'deep research', prompt: 'dig in', run_in_background: true }, { agent: parent })
expect(start.isError).toBe(false)
expect(text(start)).toBe('started background subagent task subagent-1')
const collected = await ctx.tools.execute({
callId: CallId('collect-1'),
name: 'task_output',
arguments: { task_id: 'subagent-1', wait: true },
agent: parent,
})
expect(text(collected)).toBe('background answer\n[status: completed]')
// Final-output reads are idempotent (not consumed).
const again = await ctx.tools.execute({
callId: CallId('collect-2'),
name: 'task_output',
arguments: { task_id: 'subagent-1' },
agent: parent,
})
expect(text(again)).toBe('background answer\n[status: completed]')
})
it('fails loud when the tasks runtime is not loaded', async () => {
const ctx = await setup({ provider: 'mock' })
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true })
expect(result.isError).toBe(true)
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks')
})
it('refuses to start when the tool signal is already aborted', async () => {
const ctx = await backgroundSetup({ provider: 'mock' })
const parent = ownerAgent(ctx, 'sess-parent')
const controller = new AbortController()
controller.abort()
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent, signal: controller.signal })
expect(result.isError).toBe(true)
expect(text(result)).toContain('subagent delegation aborted')
})
it('forwards task_kill reasons to run.cancel (and defaults one when absent)', async () => {
// A provider whose runs settle only on cancel — the mock settles on a
// microtask, too fast to observe a LIVE kill through the real tools.
const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } })
const parent = ownerAgent(ctx, 'sess-parent')
const cancels: (string | undefined)[] = []
ctx.subagents.registerProvider({
name: 'hanging',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
inheritsParentContext: false,
start: () => {
let settle!: (value: { output: { type: 'text'; text: string }[]; stopReason: 'aborted' }) => void
return {
id: AgentId(`hang-${cancels.length}`),
result: new Promise((res) => { settle = res }),
cancel(reason?: string) { cancels.push(reason); settle({ output: [], stopReason: 'aborted' }) },
dispose: () => Promise.resolve(),
}
},
})
// Direct apply (schema bypass): schemastery would default agentOptions to
// an (truthy) empty object — the raw config exercises the omitted branch
// on the background start request.
tool.apply(ctx, { provider: 'hanging', toolName: 'subagent_hang' })
const startOne = await ctx.tools.execute({ callId: CallId('h1'), name: 'subagent_hang', arguments: { description: 'one', prompt: 'p', run_in_background: true }, agent: parent })
const startTwo = await ctx.tools.execute({ callId: CallId('h2'), name: 'subagent_hang', arguments: { description: 'two', prompt: 'p', run_in_background: true }, agent: parent })
expect(text(startOne)).toBe('started background subagent task subagent-1')
expect(text(startTwo)).toBe('started background subagent task subagent-2')
const withReason = await ctx.tools.execute({ callId: CallId('k1'), name: 'task_kill', arguments: { task_id: 'subagent-1', reason: 'superseded' }, agent: parent })
const withoutReason = await ctx.tools.execute({ callId: CallId('k2'), name: 'task_kill', arguments: { task_id: 'subagent-2' }, agent: parent })
expect(text(withReason)).toBe('requested cancellation of task subagent-1')
expect(text(withoutReason)).toBe('requested cancellation of task subagent-2')
expect(cancels).toEqual(['superseded', 'background subagent task killed'])
// The aborted children settle as killed tasks.
const killed = await ctx.tools.execute({ callId: CallId('w1'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, agent: parent })
expect(text(killed)).toBe('(no new output)\n[status: killed]')
})
it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => {
const output = [{ type: 'text' as const, text: 'partial' }]
expect(runOutcome({ output, stopReason: 'completed' })).toEqual({ status: 'completed', output: 'partial' })
expect(runOutcome({ output, stopReason: 'aborted' })).toEqual({ status: 'killed' })
expect(runOutcome({ output, stopReason: 'error' })).toEqual({ status: 'failed', detail: 'error' })
expect(runOutcome({ output, stopReason: 'max-tokens' })).toEqual({ status: 'failed', detail: 'max-tokens' })
expect(runOutcome({ output, stopReason: 'refusal' })).toEqual({ status: 'failed', detail: 'refusal' })
// Merge-extensible: an unknown reason is failed-with-detail, never success.
expect(runOutcome({ output, stopReason: 'paused' as never })).toEqual({ status: 'failed', detail: 'paused' })
})
it('settleRun disposes the run before reporting, on both result paths', async () => {
const order: string[] = []
const completed = await settleRun({
id: AgentId('child-1'),
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose() { order.push('dispose'); return Promise.resolve() },
})
order.push('reported')
expect(completed).toEqual({ status: 'completed', output: 'ok' })
expect(order).toEqual(['dispose', 'reported'])
// An infrastructure rejection still disposes and reports failed.
let disposed = false
const failed = await settleRun({
id: AgentId('child-2'),
result: Promise.reject(new Error('transport gone')),
cancel() {},
dispose() { disposed = true; return Promise.resolve() },
})
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
expect(disposed).toBe(true)
})
})
describe('background registration failure (no orphaned child)', () => {
it('cancels and disposes the just-started run when register() throws', async () => {
// TaskService is loaded but NO control surface is attached, so
// ctx.tasks.register throws AFTER the provider run already started.
const ctx = await setup({ provider: 'mock' })
await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
const parent = { id: AgentId('agent-sess-p'), inject: () => {}, session: { header: { version: 0, id: 'sess-p', createdAt: 0 } } } as unknown as Agent
ctx.agents.register(parent)
const events: string[] = []
ctx.subagents.registerProvider({
name: 'probe',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
inheritsParentContext: false,
start: () => {
let settle!: (value: { output: never[]; stopReason: 'aborted' }) => void
return {
id: AgentId('probe-child'),
result: new Promise((res) => { settle = res }),
cancel(reason?: string) { events.push(`cancel:${reason}`); settle({ output: [], stopReason: 'aborted' }) },
dispose() { events.push('dispose'); return Promise.resolve() },
}
},
})
tool.apply(ctx, { provider: 'probe', toolName: 'subagent_probe' })
const result = await ctx.tools.execute({
callId: CallId('probe-1'),
name: 'subagent_probe',
arguments: { description: 'd', prompt: 'p', run_in_background: true },
agent: parent,
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('no control surface is attached')
// The child was cancelled AND disposed before the call settled — the
// model never got an id, so nothing else could ever collect or kill it.
expect(events).toEqual(['cancel:background task registration failed', 'dispose'])
})
})

View File

@@ -28,6 +28,9 @@
},
{
"path": "../subagent"
},
{
"path": "../../tasks/tasks"
}
]
}