From 7cc7b9cf7f5fb6aa46e228418b8d548e43e843b2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:29:08 +0800 Subject: [PATCH] feat(subagent): enrich subagent/start + subagent/end lifecycle events (observe-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hooks bridge translating SubagentStart/SubagentStop needs to know WHICH kind of subagent ran and WHAT it produced — Claude Code's hooks carry subagent_type and the child's final message. Enrich the existing lifecycle emits to match, observe-only: - agentType: an optional caller-supplied subagent-kind label (CC's subagent_type), added to SubagentStartRequest and carried VERBATIM onto both subagent/start (SubagentRunInfo) and subagent/end (SubagentRunEndInfo). The seam never interprets it. dsh-tool-subagent threads it from a new optional Config.agentType, so a deployment exposing multiple subagent kinds (one tool load per kind) labels each. - lastAssistantMessage: the child's final output (SubagentResult.output), added to SubagentRunEndInfo on the settle path so an observer sees what the subagent produced without holding the run. Absent on the reject path (no result produced). Strictly observe-only: both events stay plain emits (subagent/end fires from a detached .then and awaits no listener). A control-flow subagent/end (awaited waterfall returning a decision) would need the emit→waterfall reshape, awaiting listeners before settling, and a provider resume capability — deferred to the background/steering redesign (FIXME(subagent-continuation) anchors it). RFC: implemented/feature/2026-06-30-subagent-observe-enrich.md. --- docs/cordis-catalog/events-and-services.md | 6 +- docs/core-data-structures/subagent.md | 3 +- docs/rfc/README.md | 1 + .../2026-06-30-subagent-observe-enrich.md | 29 +++++++ packages/subagent/subagent/README.md | 2 + packages/subagent/subagent/src/index.ts | 44 +++++++++-- packages/subagent/subagent/src/types.ts | 9 +++ .../subagent/subagent/tests/service.spec.ts | 76 +++++++++++++++++++ packages/subagent/tool-subagent/README.md | 1 + packages/subagent/tool-subagent/src/index.ts | 10 +++ .../tool-subagent/tests/tool-subagent.spec.ts | 54 +++++++++++++ 11 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index a8afdbd5f1..3a477e171a 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -241,7 +241,7 @@ A subagent run settled — emitted when SubagentRun.result resolves (any stop re 'subagent/end'(info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:65`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:75`](../../packages/subagent/subagent/src/index.ts) #### `subagent/start` — emit @@ -251,7 +251,7 @@ A subagent run started — emitted after the provider is resolved and its capabi 'subagent/start'(info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:59`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:69`](../../packages/subagent/subagent/src/index.ts) ### `system-prompt/*` @@ -455,7 +455,7 @@ list(): string[] start(name: string, request: SubagentStartRequest): SubagentRun ``` -Source: [`packages/subagent/subagent/src/index.ts:103`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:130`](../../packages/subagent/subagent/src/index.ts) ### `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index c64d370ff2..ca85830808 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -25,6 +25,7 @@ What a caller asks for when starting a subagent. The tool layer builds this from ```ts type-equiv interface SubagentStartRequest { prompt: ContentBlock[] + agentType?: string parent: Agent signal?: AbortSignal agentOptions?: AgentOptions @@ -85,7 +86,7 @@ interface SubagentProvider { } ``` -The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events-and-services.md)). Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it. +The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events-and-services.md)). Both payloads carry the caller's optional `agentType` label (verbatim from the request — Claude Code's `subagent_type`); `subagent/end` additionally carries `lastAssistantMessage` (the child's final `output`) on the settle path, so an observer sees WHAT the subagent produced without holding the run (absent when the run rejected at the infrastructure level — no result was produced). These are **observe-only** enrichments: both events are plain `emit`s (the `subagent/end` fires from a detached `.then` after the result settles and awaits no listener), so a subscriber observes but cannot change the run. Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it. ## In-process backends: depth and seed diff --git a/docs/rfc/README.md b/docs/rfc/README.md index a05a7d5c5c..632b63ff56 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -88,6 +88,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 | | [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 | | [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 | +| [Subagent lifecycle enrichment — agentType + lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md new file mode 100644 index 0000000000..46cf403666 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md @@ -0,0 +1,29 @@ +# RFC: Subagent lifecycle enrichment — agentType + lastAssistantMessage (observe-only) + +Status: implemented (accepted 2026-06-30) + + + +## Context + +The hooks subsystem ([interception seams RFC](2026-06-30-interception-seams.md)) lets a plugin observe and gate the agent at lifecycle points. Claude Code and Codex both expose **SubagentStart / SubagentStop** hooks, and CC's carry a `subagent_type` (which named subagent kind ran) and the subagent's final message. The harness already emits `subagent/start` and `subagent/end` lifecycle events ([the subagent capability-seam](2026-06-21-subagent-capability-seam.md)), but their payloads were minimal (`provider`, `id`, and on end `stopReason`) — not enough for a hooks bridge to report which KIND of subagent ran, or WHAT it produced, without separately reaching for the live run. + +This RFC enriches those two payloads. It is deliberately **observe-only**: no control-flow change, no waterfall, no `start()` restructure. A run-affecting subagent-stop decision (continuation, injection that changes the run) is a separate, larger redesign and stays out of scope. + +## Decision + +Add two pieces of information to the subagent lifecycle surface: + +1. **`agentType` — a caller-supplied subagent-kind label**, the harness analogue of CC's `subagent_type`. It is optional on `SubagentStartRequest`, carried VERBATIM onto both `subagent/start` (`SubagentRunInfo`) and `subagent/end` (`SubagentRunEndInfo`). The seam never interprets it. The model-facing `dsh-tool-subagent` tool threads it from a new optional `Config.agentType`, so a deployment that exposes multiple subagent kinds (one tool load per kind) labels each. Absent when the caller does not distinguish kinds (the spread omits the key — `exactOptionalPropertyTypes`-correct). + +2. **`lastAssistantMessage` — the child's final output**, added to `SubagentRunEndInfo`. On the settle path it is `SubagentResult.output` (so an observer sees WHAT the subagent produced without holding the run). On the REJECT path (an infrastructure fault where no `SubagentResult` was produced — the seam only knows `stopReason: 'error'`) it is absent. + +Both events stay plain **`emit`s**. `subagent/end` fires from a detached `.then` on `run.result` and awaits no listener, so it is genuinely observe-only by construction — a `subagent/start` listener can still reach the live child via `ctx.agents.get(info.id)` and `inject()` into it; a `subagent/end` listener can only observe (the run has settled). Per-listener containment (already in place) keeps one bad subscriber from stranding a live run or surfacing as an unhandled rejection on the detached settle hook. + +## Why observe-only, and what is deferred + +A control-flow `subagent/end` (an awaited waterfall returning a stop/continue decision, like the other interception seams) would require: reshaping `subagent/end` from emit to waterfall, restructuring `SubagentService.start` to await listeners before settling, and implementing the `resume` capability in the in-process provider so a "continue" can actually re-run the child. That belongs to the background/steering subagent redesign the [capability-seam RFC](2026-06-21-subagent-capability-seam.md) already defers (the same redesign that unifies long-running-tool handling across subagents and bash). This RFC ships the observe-only enrichment a hooks bridge needs today; `FIXME(subagent-continuation)` / `TODO` anchors mark where the control-flow version would land if and when that redesign happens. + +## Consequences + +A hooks bridge (or a native plugin) can now translate SubagentStart/SubagentStop faithfully: it reports `agentType`, matches its hook config on it, and forwards the child's `lastAssistantMessage` to a SubagentStop handler — all by subscribing to the existing emits, no new control-flow surface. The vocabulary addition is documented in [docs/core-data-structures/subagent.md](../../../core-data-structures/subagent.md) (the `SubagentStartRequest` type-equiv block + the events prose) and the two subagent READMEs; the catalog is regenerated. No production behavior changes — the events fire exactly as before, with two more (optional) fields on their payloads — so no snapshot or e2e change is needed. diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 57862ca8ab..7d6c58af3c 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -32,6 +32,8 @@ Unlike the bash seam (one executor per context, second load throws), **multiple `provider.start(request)` returns a `SubagentRun`: a handle with a `result` promise, `cancel()`, `dispose()`, and the optional runtime methods. `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. +The service emits `subagent/start` (payload `SubagentRunInfo`) and `subagent/end` (payload `SubagentRunEndInfo`) around the run — both **observe-only** (plain `emit`s; `subagent/end` fires from a detached `.then` and awaits no listener). Both payloads carry the request's optional `agentType` label (Claude Code's `subagent_type`, verbatim — the seam never interprets it); `subagent/end` additionally carries `lastAssistantMessage` (the child's final `output`) on the settle path, absent when the run rejected at the infrastructure level. A `subagent/start` listener can still reach the live child via `ctx.agents.get(info.id)`; a `subagent/end` listener can only observe (the run has settled). Any run-affecting decision (continuation, injection that changes the run) 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). diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 356ad60a00..916c53e759 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -20,11 +20,21 @@ * semantics are deferred to a future redesign that unifies long-running-tool * handling across subagents and bash. * + * The `subagent/start` / `subagent/end` lifecycle events carry an enriched but + * OBSERVE-ONLY payload (`agentType`, and on end `lastAssistantMessage`) — see + * `docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md`. + * 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. + * * @module @deepseek-ai/dsh-subagent */ import { Context, Service } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { AgentId } from '@deepseek-ai/dsh-agent' import type { SubagentCapabilities, @@ -72,6 +82,13 @@ export interface SubagentRunInfo { provider: string /** The child agent's id. */ id: AgentId + /** + * The caller's subagent-kind label, carried verbatim from + * {@link SubagentStartRequest.agentType} (Claude Code's `subagent_type`). + * Absent when the caller did not supply one. An observer (a hooks bridge, + * a UI) reports or matches on it; the seam never interprets it. + */ + agentType?: string } /** Outcome detail for a settled subagent run (the `subagent/end` payload). */ @@ -80,8 +97,18 @@ export interface SubagentRunEndInfo { provider: string /** The child agent's id. */ id: AgentId + /** The caller's subagent-kind label (see {@link SubagentRunInfo.agentType}). */ + agentType?: string /** The terminal stop reason. */ stopReason: SubagentResult['stopReason'] + /** + * The child's final assistant output ({@link SubagentResult.output}), carried + * onto the end event so an observer sees WHAT the subagent produced without + * holding the run. Absent when the run rejected at the infrastructure level + * (no {@link SubagentResult} was produced — the seam only knows `stopReason: + * 'error'`). + */ + lastAssistantMessage?: ContentBlock[] } /** @@ -160,17 +187,22 @@ export class SubagentService extends Service { // acceptable. `ctx.emit` halts the dispatch on the first throw, so a single // surrounding try/catch is not enough — each listener is invoked and // contained individually. - this.emitLifecycle('subagent/start', { provider: name, id: run.id }) + // Carry the caller's subagent-kind label verbatim onto both lifecycle events + // (absent when not supplied — the spread omits the key for exactOptionalPropertyTypes). + const agentType = request.agentType !== undefined ? { agentType: request.agentType } : {} + this.emitLifecycle('subagent/start', { provider: name, id: run.id, ...agentType }) // Emit `subagent/end` when the run settles. The result promise does not // reject on a child-level failure (it resolves with stopReason 'error'), // so a rejection here is an infrastructure fault — surface its stop reason // as 'error' for the telemetry event without swallowing the rejection - // (the consumer still observes it via `run.result`). Per-listener - // containment also keeps a thrown `subagent/end` listener from becoming an - // unhandled rejection on this detached `.then`. + // (the consumer still observes it via `run.result`). On the resolve path the + // child's final output rides on the event (lastAssistantMessage); on the + // reject path there is no SubagentResult, so only the stop reason is known. + // Per-listener containment also keeps a thrown `subagent/end` listener from + // becoming an unhandled rejection on this detached `.then`. void run.result.then( - (result) => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason }) }, - () => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) }, + (result) => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, ...agentType, stopReason: result.stopReason, lastAssistantMessage: result.output }) }, + () => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, ...agentType, stopReason: 'error' }) }, ) return run } diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index fb60d5667c..55ef04a8a9 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -40,6 +40,15 @@ export interface SubagentCapabilities { export interface SubagentStartRequest { /** The task/prompt for the child agent (a user message in the child session). */ prompt: ContentBlock[] + /** + * Optional caller-supplied LABEL for the kind of subagent (e.g. `code-reviewer`, + * `researcher`) — the harness analogue of Claude Code's `subagent_type`. The + * seam does not interpret it; it is carried verbatim onto the `subagent/start` + * and `subagent/end` lifecycle events so an observer (a hooks bridge, a UI) can + * report or match on which kind of subagent ran. Absent when the caller does + * not distinguish subagent kinds. + */ + agentType?: string /** * The spawning ("parent") agent — the one whose tool call started this * subagent. REQUIRED: in-process backends read `parent.session.header` for diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 6876c6cd80..e23ae4ff3b 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -173,6 +173,82 @@ describe('SubagentService', () => { expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' })) }) + it('carries agentType (from the request) onto both lifecycle events, and lastAssistantMessage onto end', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider( + 'enriched', + ALL_CAPS, + { output: [{ type: 'text', text: 'the child answer' }], stopReason: 'completed' }, + )) + + const started = vi.fn() + const ended = vi.fn() + ctx.on('subagent/start', started) + ctx.on('subagent/end', ended) + + const run = ctx.subagents.start('enriched', baseRequest({ agentType: 'code-reviewer' })) + expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'enriched', id: run.id, agentType: 'code-reviewer' })) + + await run.result + await Promise.resolve() + expect(ended).toHaveBeenCalledWith(expect.objectContaining({ + provider: 'enriched', + id: run.id, + agentType: 'code-reviewer', + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'the child answer' }], + })) + }) + + it('omits agentType when the request supplied none', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider('plain')) + + const started = vi.fn() + const ended = vi.fn() + ctx.on('subagent/start', started) + ctx.on('subagent/end', ended) + + const run = ctx.subagents.start('plain', baseRequest()) + await run.result + await Promise.resolve() + + const startInfo = started.mock.calls[0]![0] as Record + const endInfo = ended.mock.calls[0]![0] as Record + expect('agentType' in startInfo).toBe(false) + expect('agentType' in endInfo).toBe(false) + // lastAssistantMessage IS present on a resolved end (the child's output). + expect(endInfo.lastAssistantMessage).toEqual([{ type: 'text', text: 'ok' }]) + }) + + it('omits lastAssistantMessage on the reject path (no SubagentResult was produced)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'rej', + capabilities: NO_CAPS, + start: () => ({ + id: AgentId('rej-child'), + result: Promise.reject(new Error('infra fault')), + cancel() {}, + dispose: async () => {}, + }), + }) + + const ended = vi.fn() + ctx.on('subagent/end', ended) + const run = ctx.subagents.start('rej', baseRequest({ agentType: 'researcher' })) + await run.result.catch(() => {}) + await Promise.resolve() + + const endInfo = ended.mock.calls[0]![0] as Record + expect(endInfo.stopReason).toBe('error') + expect(endInfo.agentType).toBe('researcher') // agentType still carried on reject + expect('lastAssistantMessage' in endInfo).toBe(false) // but no output exists + }) + it('emits subagent/end with stopReason "error" when the run result promise rejects', async () => { const ctx = new Context() await ctx.plugin(SubagentService) diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 1bb48f29ff..996e44d96e 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -11,6 +11,7 @@ This plugin binds to **exactly one** provider (`Config.provider`). The model see | `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`. | | `agentOptions` | Default per-child `{ model?, systemPrompt? }` applied to every spawned child. | +| `agentType` | Optional subagent-kind label (Claude Code's `subagent_type`) stamped on every run's `subagent/start`/`subagent/end` events, so an observer (a hooks bridge, a UI) can report or match on which kind ran. Set a distinct value per load when exposing multiple subagent kinds. | ## Lifecycle (synchronous collect) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 05490127ea..cb92035e88 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -48,6 +48,14 @@ export interface Config { * spawned child. Omitted fields fall back to the child loop's own defaults. */ agentOptions?: AgentOptions + /** + * Optional subagent-kind LABEL stamped on every run this tool starts (Claude + * Code's `subagent_type`). Carried onto the `subagent/start`/`subagent/end` + * lifecycle events so an observer can report or match on which kind of + * subagent ran. A deployment that exposes multiple subagent kinds (one tool + * load per kind) sets a distinct `agentType` per load; omit when undifferentiated. + */ + agentType?: string } export const Config: z = z.object({ @@ -57,6 +65,7 @@ export const Config: z = z.object({ model: z.string(), systemPrompt: z.string(), }), + agentType: z.string(), }) /** @@ -128,6 +137,7 @@ export function apply(ctx: Context, config: Config): void { parent, ...exec.signal ? { signal: exec.signal } : {}, ...config.agentOptions ? { agentOptions: config.agentOptions } : {}, + ...config.agentType !== undefined ? { agentType: config.agentType } : {}, } const run: SubagentRun = ctx.subagents.start(config.provider, request) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 2f40cd6f8c..0b088b65db 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -153,6 +153,60 @@ describe('dsh-tool-subagent', () => { expect(seen?.agentOptions).toEqual({ model: 'child-model', systemPrompt: 'be terse' }) }) + it('forwards a configured agentType into the start request (observed on the lifecycle events)', async () => { + let seen: { agentType?: string } | undefined + const starts: { agentType?: string }[] = [] + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.on('subagent/start', info => void starts.push(info)) + ctx.subagents.registerProvider({ + name: 'typed', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: (request) => { + seen = request + return { + id: AgentId('typed-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { provider: 'typed', agentType: 'code-reviewer' }) + + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + // The config agentType reaches the request, and the service stamps it on the event. + expect(seen?.agentType).toBe('code-reviewer') + expect(starts[0]?.agentType).toBe('code-reviewer') + }) + + it('omits agentType from the request when none is configured', async () => { + let seen: { agentType?: string } | undefined + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'untyped', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: (request) => { + seen = request + return { + id: AgentId('untyped-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { provider: 'untyped' }) + + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(seen !== undefined && 'agentType' in seen).toBe(false) + }) + it('defaults toolName and omits agentOptions when apply() is called directly (schema bypass)', async () => { // `ctx.plugin` validates+defaults config first (toolName→'subagent', the // agentOptions object→{}), so the runtime `?? 'subagent'` fallback and the