refactor(subagent): drop the agentType lifecycle field

Address review: `agentType` was a Claude-Code concept (`subagent_type`) that
does not fit our own subagent seam — nothing in the harness interprets it, and
its only consumer was the CC-dialect hook bridge. Rather than let a foreign
concept sit on the core seam, remove it:

- `SubagentStartRequest`, `SubagentRunInfo`, `SubagentRunEndInfo`: drop the
  `agentType` field; the `subagent/start`/`subagent/end` payloads now carry
  `provider`/`id` (+ end `stopReason`/`lastAssistantMessage`) only.
- `dsh-tool-subagent`: drop `Config.agentType` and its request plumbing.
- Tests: keep the lastAssistantMessage / clone-containment / reject-path
  coverage (rewritten to not assert agentType); delete the two tool-subagent
  tests that only exercised agentType forwarding (dead behavior).
- Docs: retitle + rewrite the subagent-observe-enrich RFC to the one shipped
  enrichment (lastAssistantMessage), with a note on why agentType was dropped;
  update rfc/README index title, both subagent READMEs, and the
  core-data-structures/subagent.md type-equiv block + prose; regenerate catalog.

The CC bridge (PR-F) will feed Claude Code's own default matcher value
"general-purpose" for its SubagentStart/Stop agent_type matcher instead.
This commit is contained in:
Tianyi Cui
2026-07-02 05:52:02 +08:00
parent 8514bddd82
commit 84f3019310
11 changed files with 29 additions and 138 deletions
+1 -1
View File
@@ -445,7 +445,7 @@ list(): string[]
start(name: string, request: SubagentStartRequest): SubagentRun
```
Source: [`packages/subagent/subagent/src/index.ts:130`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:121`](../../packages/subagent/subagent/src/index.ts)
### `ctx.systemPrompt` — `SystemPrompt`
+1 -2
View File
@@ -25,7 +25,6 @@ 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
@@ -86,7 +85,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 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.
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)). `subagent/end` 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** events: both 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
+1 -1
View File
@@ -87,7 +87,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 |
| [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
### Simplification
@@ -1,22 +1,25 @@
# RFC: Subagent lifecycle enrichment — agentType + lastAssistantMessage (observe-only)
# RFC: Subagent lifecycle enrichment — lastAssistantMessage (observe-only)
Status: implemented (accepted 2026-06-30)
<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. -->
<!-- An earlier draft also added an `agentType` subagent-kind label (the harness
analogue of CC's `subagent_type`) to the request + both lifecycle payloads.
It was dropped in review: it is a Claude-Code concept that does not fit our
own seam (nothing here interprets it, and the only consumer was a CC-dialect
bridge). The CC bridge instead feeds Claude Code's own default matcher value
`"general-purpose"` for its SubagentStart/Stop `agent_type` matcher. So this
RFC ships ONE enrichment: `lastAssistantMessage`. -->
## 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.
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 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 WHAT a subagent 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.
This RFC enriches the end payload. 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 a DEEP CLONE of `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. The clone is load-bearing for observe-only: the `subagent/end` emit fires from a detached `.then` registered *before* `start()` returns, i.e. before the caller's own `await run.result` continuation — handing listeners the same array reference would let a mutating listener corrupt the caller's `SubagentResult.output`. `structuredClone` makes the event a read-only view (a regression test mutates the event's array and asserts the caller's result is untouched).
**Add `lastAssistantMessage` — the child's final output — to `SubagentRunEndInfo`.** On the settle path it is a DEEP CLONE of `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. The clone is load-bearing for observe-only: the `subagent/end` emit fires from a detached `.then` registered *before* `start()` returns, i.e. before the caller's own `await run.result` continuation — handing listeners the same array reference would let a mutating listener corrupt the caller's `SubagentResult.output`. `structuredClone` makes the event a read-only view (a regression test mutates the event's array and asserts the caller's result is untouched); a clone failure is contained (logged, the event still fires without `lastAssistantMessage`) rather than becoming an unhandled rejection on the detached `.then`.
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.
@@ -26,4 +29,4 @@ A control-flow `subagent/end` (an awaited waterfall returning a stop/continue de
## 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.
A hooks bridge (or a native plugin) can now forward the child's `lastAssistantMessage` to a SubagentStop handler 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 events prose) and the two subagent READMEs; the catalog is regenerated. No production behavior changes — the events fire exactly as before, with one more (optional) field on the end payload — so no snapshot or e2e change is needed.
+1 -1
View File
@@ -32,7 +32,7 @@ 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` (a deep clone of the child's final `output`) on the settle path, absent when the run rejected at the infrastructure level. The clone keeps the surface observe-only: the end emit fires from a detached `.then` before the caller's `await run.result` resumes, so a shared reference would let a mutating listener corrupt the caller's result. 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.
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). `subagent/end` carries `lastAssistantMessage` (a deep clone of the child's final `output`) on the settle path, absent when the run rejected at the infrastructure level. The clone keeps the surface observe-only: the end emit fires from a detached `.then` before the caller's `await run.result` resumes, so a shared reference would let a mutating listener corrupt the caller's result. 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)
+6 -18
View File
@@ -20,9 +20,9 @@
* 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`.
* The `subagent/start` / `subagent/end` lifecycle events carry an OBSERVE-ONLY
* payload; `subagent/end` additionally carries the child's `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
@@ -82,13 +82,6 @@ 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). */
@@ -97,8 +90,6 @@ 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']
/**
@@ -187,10 +178,7 @@ 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.
// 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 })
this.emitLifecycle('subagent/start', { provider: name, id: run.id })
// 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
@@ -220,9 +208,9 @@ export class SubagentService extends Service {
} catch (error: unknown) {
this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`)
}
this.emitLifecycle('subagent/end', { provider: name, id: run.id, ...agentType, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} })
this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} })
},
() => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, ...agentType, stopReason: 'error' }) },
() => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) },
)
return run
}
-9
View File
@@ -40,15 +40,6 @@ 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
@@ -173,7 +173,7 @@ 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 () => {
it('carries lastAssistantMessage (the child output) onto the end event', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider(new StubProvider(
@@ -187,42 +187,19 @@ describe('SubagentService', () => {
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' }))
const run = ctx.subagents.start('enriched', baseRequest())
expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'enriched', id: run.id }))
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<string, unknown>
const endInfo = ended.mock.calls[0]![0] as Record<string, unknown>
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('observe-only: a subagent/end listener mutating lastAssistantMessage cannot corrupt the caller\'s result', async () => {
// The subagent/end emit fires from a detached `.then` registered before
// start() returns — i.e. BEFORE the caller's own `await run.result`
@@ -267,14 +244,13 @@ describe('SubagentService', () => {
const ended = vi.fn()
ctx.on('subagent/end', ended)
const run = ctx.subagents.start('rej', baseRequest({ agentType: 'researcher' }))
const run = ctx.subagents.start('rej', baseRequest())
await run.result.catch(() => {})
await Promise.resolve()
const endInfo = ended.mock.calls[0]![0] as Record<string, unknown>
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
expect('lastAssistantMessage' in endInfo).toBe(false) // no output exists on reject
})
it('contains a structuredClone failure: emits subagent/end without lastAssistantMessage (no unhandled rejection)', async () => {
@@ -282,7 +258,7 @@ describe('SubagentService', () => {
// containment. An uncloneable output (here a content block carrying a
// function) would otherwise throw and become an unhandled rejection on the
// detached `.then`. The handler must instead log and emit the event WITHOUT
// lastAssistantMessage, still carrying the real stopReason/agentType.
// lastAssistantMessage, still carrying the real stopReason.
const ctx = new Context()
await ctx.plugin(SubagentService)
const warn = vi.fn(); ctx.logger.warn = warn as never
@@ -301,13 +277,12 @@ describe('SubagentService', () => {
const ended = vi.fn()
ctx.on('subagent/end', ended)
const run = ctx.subagents.start('unclone', baseRequest({ agentType: 'researcher' }))
const run = ctx.subagents.start('unclone', baseRequest())
await run.result
await Promise.resolve()
const endInfo = ended.mock.calls[0]![0] as Record<string, unknown>
expect(endInfo.stopReason).toBe('completed') // the real outcome is preserved
expect(endInfo.agentType).toBe('researcher')
expect('lastAssistantMessage' in endInfo).toBe(false) // clone failed → omitted, not crashed
expect(warn).toHaveBeenCalledWith(expect.stringContaining('could not clone'))
})
@@ -11,7 +11,6 @@ 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)
@@ -48,14 +48,6 @@ 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<Config> = z.object({
@@ -65,7 +57,6 @@ export const Config: z<Config> = z.object({
model: z.string(),
systemPrompt: z.string(),
}),
agentType: z.string(),
})
/**
@@ -137,7 +128,6 @@ 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)
@@ -153,60 +153,6 @@ 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