detached 22e42bf

fix(subagents): steer running children and stop echoing undelivered follow-ups (#227)

* fix(subagents): steer running children and stop echoing undelivered follow-ups

resume now injects a follow-up into a running child's turn by default (mode steer), with mode queue keeping the separate-turn behavior. A new interrupt action cancels only the running turn and keeps the child resumable. Stop and interrupt report unread steers as undelivered and never-started queued turns as dropped instead of echoing them as interrupted tasks, and status exposes pending_followups.

The child runtime becomes a ResidentRuntime interface so the manager can reach the child's core.Agent queue.

Fixes #226

* fix(subagents): expose interrupt in prompts and report undelivered wait outcomes

Prompt addenda now take the full set of granted actions and describe interrupt. A waited resume reports steers its turn never delivered, dropped follow-ups keep non-cancellation errors, and /subagents resume says whether it steered or queued. Removes the unused wait wrapper, unexports resumeWithTurn, and extends gating tests to the interrupt action.

* test(subagents): cover interrupt racing natural turn completion
commit
22e42bf5b305cea847ac6df9aeee89459c3381ee
author
bnema · · 2026-09-25 05:25 UTC
committer
GitHub · · 2026-09-25 05:25 UTC
parent
efd47b3
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.44.1
41 files · +1570 -431
M README.md +2 -2
@@ -425,7 +425,7 @@ Resident subagents run alongside the main session as independent in-process agen
425425
426426 > **V0.x breaking change:** previous subprocess subagent state is unsupported and ignored. Outstanding jobs are never migrated or replayed; remove the old state directory when you no longer need it.
427427
428-> **V0.x breaking change:** the separate `subagent_spawn`, `subagent_status`, `subagent_stop`, and `subagent_resume` tools are replaced by one `subagent` tool with `spawn`, `status`, `stop`, and `resume` actions. `--tools subagent_spawn` no longer selects anything; use `--tools subagent:spawn`, or bare `--tools subagent` for every action. Under `--no-yolo` the "always allow" grant is remembered per action (`subagent:status` and `subagent:spawn` are separate grants), so approving a read-only `status` call never pre-approves `spawn`, `stop`, or `resume`.
428+> **V0.x breaking change:** the separate `subagent_spawn`, `subagent_status`, `subagent_stop`, and `subagent_resume` tools are replaced by one `subagent` tool whose actions are selected by an `action` argument. `--tools subagent_spawn` no longer selects anything; use `--tools subagent:spawn`, or bare `--tools subagent` for every action. Under `--no-yolo` the "always allow" grant is remembered per action (`subagent:status` and `subagent:spawn` are separate grants), so approving a read-only `status` call never pre-approves any other action.
429429
430430 > **Choose the workspace deliberately.** Use `isolation:"worktree"` for parallel coding so children cannot edit the host checkout. Use shared mode for read-only review or explicitly coordinated work. Worktree isolation is an accident-prevention guardrail, not a security sandbox.
431431
@@ -449,7 +449,7 @@ Resident subagents run alongside the main session as independent in-process agen
449449
450450 The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up.
451451
452-Each child runs without a cumulative token budget or step cap: it inherits the parent's resolved `--max-steps`, unlimited by default, and each turn is bounded by the model context window with one compaction recovery. A child whose last-turn prompt reaches 85%, 90%, or 95% of its model context window receives one non-blocking reminder per band to finish up, injected into its next provider request. Retrieve a terminal result without a model call using the `subagent` tool's `status` action with `agent_id` and `include_result: true`. After checking the saved progress and side effects, an explicit resume continues the retained session, and its `wait` value blocks on that follow-up turn. Required work stays unresolved until success. See [lifecycle and persistence](docs/subagents.md#lifecycle-and-persistence) for details.
452+Each child runs without a cumulative token budget or step cap: it inherits the parent's resolved `--max-steps`, unlimited by default, and each turn is bounded by the model context window with one compaction recovery. A child whose last-turn prompt reaches 85%, 90%, or 95% of its model context window receives one non-blocking reminder per band to finish up, injected into its next provider request. Retrieve a terminal result without a model call using the `subagent` tool's `status` action with `agent_id` and `include_result: true`. After checking the saved progress and side effects, an explicit resume continues the retained session, and its `wait` value blocks on the turn that answers it. A resume to a running child steers its current turn by default (`mode: "queue"` runs it as the next turn instead), and the `interrupt` action cancels only the running turn so a later resume can ask the child to wrap up with its context intact. Required work stays unresolved until success. See [lifecycle and persistence](docs/subagents.md#lifecycle-and-persistence) for details.
453453
454454 Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`.
455455
@@ -425,7 +425,7 @@ Resident subagents run alongside the main session as independent in-process agen
425 425
426 > **V0.x breaking change:** previous subprocess subagent state is unsupported and ignored. Outstanding jobs are never migrated or replayed; remove the old state directory when you no longer need it.426 > **V0.x breaking change:** previous subprocess subagent state is unsupported and ignored. Outstanding jobs are never migrated or replayed; remove the old state directory when you no longer need it.
427 427
428-> **V0.x breaking change:** the separate `subagent_spawn`, `subagent_status`, `subagent_stop`, and `subagent_resume` tools are replaced by one `subagent` tool with `spawn`, `status`, `stop`, and `resume` actions. `--tools subagent_spawn` no longer selects anything; use `--tools subagent:spawn`, or bare `--tools subagent` for every action. Under `--no-yolo` the "always allow" grant is remembered per action (`subagent:status` and `subagent:spawn` are separate grants), so approving a read-only `status` call never pre-approves `spawn`, `stop`, or `resume`.428+> **V0.x breaking change:** the separate `subagent_spawn`, `subagent_status`, `subagent_stop`, and `subagent_resume` tools are replaced by one `subagent` tool whose actions are selected by an `action` argument. `--tools subagent_spawn` no longer selects anything; use `--tools subagent:spawn`, or bare `--tools subagent` for every action. Under `--no-yolo` the "always allow" grant is remembered per action (`subagent:status` and `subagent:spawn` are separate grants), so approving a read-only `status` call never pre-approves any other action.
429 429
430 > **Choose the workspace deliberately.** Use `isolation:"worktree"` for parallel coding so children cannot edit the host checkout. Use shared mode for read-only review or explicitly coordinated work. Worktree isolation is an accident-prevention guardrail, not a security sandbox.430 > **Choose the workspace deliberately.** Use `isolation:"worktree"` for parallel coding so children cannot edit the host checkout. Use shared mode for read-only review or explicitly coordinated work. Worktree isolation is an accident-prevention guardrail, not a security sandbox.
431 431
@@ -449,7 +449,7 @@ Resident subagents run alongside the main session as independent in-process agen
449 449
450 The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up.450 The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up.
451 451
452-Each child runs without a cumulative token budget or step cap: it inherits the parent's resolved `--max-steps`, unlimited by default, and each turn is bounded by the model context window with one compaction recovery. A child whose last-turn prompt reaches 85%, 90%, or 95% of its model context window receives one non-blocking reminder per band to finish up, injected into its next provider request. Retrieve a terminal result without a model call using the `subagent` tool's `status` action with `agent_id` and `include_result: true`. After checking the saved progress and side effects, an explicit resume continues the retained session, and its `wait` value blocks on that follow-up turn. Required work stays unresolved until success. See [lifecycle and persistence](docs/subagents.md#lifecycle-and-persistence) for details.452+Each child runs without a cumulative token budget or step cap: it inherits the parent's resolved `--max-steps`, unlimited by default, and each turn is bounded by the model context window with one compaction recovery. A child whose last-turn prompt reaches 85%, 90%, or 95% of its model context window receives one non-blocking reminder per band to finish up, injected into its next provider request. Retrieve a terminal result without a model call using the `subagent` tool's `status` action with `agent_id` and `include_result: true`. After checking the saved progress and side effects, an explicit resume continues the retained session, and its `wait` value blocks on the turn that answers it. A resume to a running child steers its current turn by default (`mode: "queue"` runs it as the next turn instead), and the `interrupt` action cancels only the running turn so a later resume can ask the child to wrap up with its context intact. Required work stays unresolved until success. See [lifecycle and persistence](docs/subagents.md#lifecycle-and-persistence) for details.
453 453
454 Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`.454 Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`.
455 455
M docs/subagents.md +35 -12
@@ -6,12 +6,12 @@
66
77 > **V0.x breaking change:** the separate `subagent_spawn`, `subagent_status`,
88 > `subagent_stop`, and `subagent_resume` tools are replaced by one `subagent`
9-> tool with `spawn`, `status`, `stop`, and `resume` actions.
9+> tool whose actions are selected by an `action` argument.
1010 > `--tools subagent_spawn` no longer selects anything; use
1111 > `--tools subagent:spawn`, or bare `--tools subagent` for every action.
1212 > Under `--no-yolo`, "always allow" is remembered per action
1313 > (`subagent:status` and `subagent:spawn` are separate grants), so approving a
14-> read-only `status` call never pre-approves `spawn`, `stop`, or `resume`.
14+> read-only `status` call never pre-approves any other action.
1515
1616 Subagents are independent `core.Agent` conversations resident in the same zut
1717 process as their parent. A child has a stable, private session identity, its own
@@ -217,12 +217,13 @@ required capability.
217217
218218 ## Tools and slash commands
219219
220-The model-facing tool keeps one logical name and four actions, selected by its
220+The model-facing tool keeps one logical name and five actions, selected by its
221221 `action` argument. Launch-time gating is per action: `--tools subagent` grants
222222 every action, `--tools subagent:<action>` grants one, and `--no-tools` disables
223223 all of them.
224224
225-- `subagent` accepts an `action` of `spawn`, `status`, `stop`, or `resume`.
225+- `subagent` accepts an `action` of `spawn`, `status`, `stop`, `resume`, or
226+ `interrupt`.
226227 - `spawn` accepts `task`, optional `agent`, `model` and `provider`,
227228 `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or
228229 `worktree`).
@@ -244,14 +245,35 @@ all of them.
244245 reasoning is never exposed. `watch` (1–60 seconds, requires `agent_id`)
245246 observes that activity and returns a `timeline` of phase and tool changes.
246247 It returns early when the child stops running. Use it to inspect progress,
247- not to wait for completion.
248+ not to wait for completion. `pending_followups` counts follow-ups the
249+ child has accepted but not read yet.
248250 - `stop` accepts `agent_id` and stops one live child.
249- - `resume` accepts `agent_id`, `prompt`, and optional `wait`. `prompt` is an
250- explicit follow-up prompt for an existing child. After a terminal failure,
251- inspect the saved result before resuming; resume continues the retained
252- session. `wait` uses the same 1–300 second bound as `spawn` and waits for
253- the accepted follow-up turn, returning its outcome or reporting the timeout
254- while the child stays active.
251+ - `resume` accepts `agent_id`, `prompt`, optional `mode`, and optional
252+ `wait`. `prompt` is an explicit follow-up prompt for an existing child.
253+ After a terminal failure, inspect the saved result before resuming; resume
254+ continues the retained session. `mode` controls how a follow-up reaches a
255+ running child:
256+ - `steer` (default) injects it into the current turn at the next tool or
257+ model boundary, like typing while the main agent works. It adds no new
258+ turn and no extra completion.
259+ - `queue` runs it as a separate turn after the current one.
260+
261+ An idle or stopped child starts a new turn in either mode, and a steer
262+ never jumps ahead of already queued turns. The response's `delivery` is
263+ `steered` or `queued`. `wait` uses the same 1–300 second bound as `spawn`
264+ and waits for the turn that answers the follow-up, returning its outcome or
265+ reporting the timeout while the child stays active. A returned outcome can
266+ be `dropped` when a queued follow-up never started, and it lists
267+ `undelivered` steers the child never read.
268+ - `interrupt` accepts `agent_id` and cancels only the child's running turn.
269+ Unlike `stop`, the child stays live with its transcript, so a later
270+ `resume` such as "report what you have" continues with full context.
271+ Queued follow-ups are dropped. It fails when no turn is running.
272+
273+When a turn ends before the child reads a steered follow-up, for example after
274+`stop`, `interrupt`, or a failure, the completion update lists it as
275+`undelivered`. Queued follow-ups that never started are reported as `dropped`.
276+Neither appears as an interrupted task.
255277
256278 Child execution started by the `subagent` tool's `spawn` or `resume` action is
257279 asynchronous unless it receives an explicit `wait` value. For an unwaited call,
@@ -282,7 +304,8 @@ as `completed`, a relative update time, and a short ID. Press Down from the
282304 main composer to open this list without clearing a draft when a child exists.
283305 Use arrows and Enter to open a child session; `/subagents new <task>` creates a child; `/subagents logs <id>`
284306 opens its history; `/subagents result <id>` shows the bounded final summary;
285-`kill <id>` stops it; and `resume <id> <prompt>` continues it.
307+`kill <id>` stops it; and `resume <id> <prompt>` continues it. A prompt sent
308+from the child composer or `resume` to a running child steers its current turn.
286309
287310 The child session uses the same transcript renderer as the main session.
288311 Recent history is bounded and loaded asynchronously; PgUp asks for an older
@@ -6,12 +6,12 @@
6 6
7 > **V0.x breaking change:** the separate `subagent_spawn`, `subagent_status`,7 > **V0.x breaking change:** the separate `subagent_spawn`, `subagent_status`,
8 > `subagent_stop`, and `subagent_resume` tools are replaced by one `subagent`8 > `subagent_stop`, and `subagent_resume` tools are replaced by one `subagent`
9-> tool with `spawn`, `status`, `stop`, and `resume` actions.9+> tool whose actions are selected by an `action` argument.
10 > `--tools subagent_spawn` no longer selects anything; use10 > `--tools subagent_spawn` no longer selects anything; use
11 > `--tools subagent:spawn`, or bare `--tools subagent` for every action.11 > `--tools subagent:spawn`, or bare `--tools subagent` for every action.
12 > Under `--no-yolo`, "always allow" is remembered per action12 > Under `--no-yolo`, "always allow" is remembered per action
13 > (`subagent:status` and `subagent:spawn` are separate grants), so approving a13 > (`subagent:status` and `subagent:spawn` are separate grants), so approving a
14-> read-only `status` call never pre-approves `spawn`, `stop`, or `resume`.14+> read-only `status` call never pre-approves any other action.
15 15
16 Subagents are independent `core.Agent` conversations resident in the same zut16 Subagents are independent `core.Agent` conversations resident in the same zut
17 process as their parent. A child has a stable, private session identity, its own17 process as their parent. A child has a stable, private session identity, its own
@@ -217,12 +217,13 @@ required capability.
217 217
218 ## Tools and slash commands218 ## Tools and slash commands
219 219
220-The model-facing tool keeps one logical name and four actions, selected by its220+The model-facing tool keeps one logical name and five actions, selected by its
221 `action` argument. Launch-time gating is per action: `--tools subagent` grants221 `action` argument. Launch-time gating is per action: `--tools subagent` grants
222 every action, `--tools subagent:<action>` grants one, and `--no-tools` disables222 every action, `--tools subagent:<action>` grants one, and `--no-tools` disables
223 all of them.223 all of them.
224 224
225-- `subagent` accepts an `action` of `spawn`, `status`, `stop`, or `resume`.225+- `subagent` accepts an `action` of `spawn`, `status`, `stop`, `resume`, or
226+ `interrupt`.
226 - `spawn` accepts `task`, optional `agent`, `model` and `provider`,227 - `spawn` accepts `task`, optional `agent`, `model` and `provider`,
227 `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or228 `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or
228 `worktree`).229 `worktree`).
@@ -244,14 +245,35 @@ all of them.
244 reasoning is never exposed. `watch` (1–60 seconds, requires `agent_id`)245 reasoning is never exposed. `watch` (1–60 seconds, requires `agent_id`)
245 observes that activity and returns a `timeline` of phase and tool changes.246 observes that activity and returns a `timeline` of phase and tool changes.
246 It returns early when the child stops running. Use it to inspect progress,247 It returns early when the child stops running. Use it to inspect progress,
247- not to wait for completion.248+ not to wait for completion. `pending_followups` counts follow-ups the
249+ child has accepted but not read yet.
248 - `stop` accepts `agent_id` and stops one live child.250 - `stop` accepts `agent_id` and stops one live child.
249- - `resume` accepts `agent_id`, `prompt`, and optional `wait`. `prompt` is an251+ - `resume` accepts `agent_id`, `prompt`, optional `mode`, and optional
250- explicit follow-up prompt for an existing child. After a terminal failure,252+ `wait`. `prompt` is an explicit follow-up prompt for an existing child.
251- inspect the saved result before resuming; resume continues the retained253+ After a terminal failure, inspect the saved result before resuming; resume
252- session. `wait` uses the same 1–300 second bound as `spawn` and waits for254+ continues the retained session. `mode` controls how a follow-up reaches a
253- the accepted follow-up turn, returning its outcome or reporting the timeout255+ running child:
254- while the child stays active.256+ - `steer` (default) injects it into the current turn at the next tool or
257+ model boundary, like typing while the main agent works. It adds no new
258+ turn and no extra completion.
259+ - `queue` runs it as a separate turn after the current one.
260+
261+ An idle or stopped child starts a new turn in either mode, and a steer
262+ never jumps ahead of already queued turns. The response's `delivery` is
263+ `steered` or `queued`. `wait` uses the same 1–300 second bound as `spawn`
264+ and waits for the turn that answers the follow-up, returning its outcome or
265+ reporting the timeout while the child stays active. A returned outcome can
266+ be `dropped` when a queued follow-up never started, and it lists
267+ `undelivered` steers the child never read.
268+ - `interrupt` accepts `agent_id` and cancels only the child's running turn.
269+ Unlike `stop`, the child stays live with its transcript, so a later
270+ `resume` such as "report what you have" continues with full context.
271+ Queued follow-ups are dropped. It fails when no turn is running.
272+
273+When a turn ends before the child reads a steered follow-up, for example after
274+`stop`, `interrupt`, or a failure, the completion update lists it as
275+`undelivered`. Queued follow-ups that never started are reported as `dropped`.
276+Neither appears as an interrupted task.
255 277
256 Child execution started by the `subagent` tool's `spawn` or `resume` action is278 Child execution started by the `subagent` tool's `spawn` or `resume` action is
257 asynchronous unless it receives an explicit `wait` value. For an unwaited call,279 asynchronous unless it receives an explicit `wait` value. For an unwaited call,
@@ -282,7 +304,8 @@ as `completed`, a relative update time, and a short ID. Press Down from the
282 main composer to open this list without clearing a draft when a child exists.304 main composer to open this list without clearing a draft when a child exists.
283 Use arrows and Enter to open a child session; `/subagents new <task>` creates a child; `/subagents logs <id>`305 Use arrows and Enter to open a child session; `/subagents new <task>` creates a child; `/subagents logs <id>`
284 opens its history; `/subagents result <id>` shows the bounded final summary;306 opens its history; `/subagents result <id>` shows the bounded final summary;
285-`kill <id>` stops it; and `resume <id> <prompt>` continues it.307+`kill <id>` stops it; and `resume <id> <prompt>` continues it. A prompt sent
308+from the child composer or `resume` to a running child steers its current turn.
286 309
287 The child session uses the same transcript renderer as the main session.310 The child session uses the same transcript renderer as the main session.
288 Recent history is bounded and loaded asynchronously; PgUp asks for an older311 Recent history is bounded and loaded asynchronously; PgUp asks for an older
M packages/agent/build.go +18 -11
@@ -940,17 +940,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
940940 if primaryOrchestrator {
941941 // Headless orchestration owns its strict prompt contract rather than
942942 // inheriting the interactive collaboration setting.
943- append_ = append(append_, StrictOrchestratorSystemAddendumFor(
944- autoSubagentsToolAllowed(args),
945- autoSubagentsStopToolAllowed(args),
946- autoSubagentsResumeToolAllowed(args),
947- ))
943+ append_ = append(append_, StrictOrchestratorSystemAddendumFor(subagentActionsAllowed(args)))
948944 } else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
949- append_ = append(append_, ProactiveSubagentsSystemAddendumFor(
950- autoSubagentsToolAllowed(args),
951- autoSubagentsStopToolAllowed(args),
952- autoSubagentsResumeToolAllowed(args),
953- ))
945+ append_ = append(append_, ProactiveSubagentsSystemAddendumFor(subagentActionsAllowed(args)))
954946 } else if primaryInteractive && autoSubagentsAnyToolAllowed(args) {
955947 append_ = append(append_, OnDemandSubagentsSystemAddendum)
956948 }
@@ -1519,11 +1511,26 @@ func autoSubagentsResumeToolAllowed(args Args) bool {
15191511 return subagentActionAllowed(args, tools.SubagentActionResume)
15201512 }
15211513
1514+func autoSubagentsInterruptToolAllowed(args Args) bool {
1515+ return subagentActionAllowed(args, tools.SubagentActionInterrupt)
1516+}
1517+
1518+// subagentActionsAllowed collects the launch-time subagent action grants.
1519+func subagentActionsAllowed(args Args) SubagentActions {
1520+ return SubagentActions{
1521+ Spawn: autoSubagentsToolAllowed(args),
1522+ Stop: autoSubagentsStopToolAllowed(args),
1523+ Resume: autoSubagentsResumeToolAllowed(args),
1524+ Interrupt: autoSubagentsInterruptToolAllowed(args),
1525+ }
1526+}
1527+
15221528 func autoSubagentsAnyToolAllowed(args Args) bool {
15231529 return autoSubagentsToolAllowed(args) ||
15241530 autoSubagentsStatusToolAllowed(args) ||
15251531 autoSubagentsStopToolAllowed(args) ||
1526- autoSubagentsResumeToolAllowed(args)
1532+ autoSubagentsResumeToolAllowed(args) ||
1533+ autoSubagentsInterruptToolAllowed(args)
15271534 }
15281535
15291536 // subagentActionAllowed reports whether the launch-time tool policy grants one
@@ -940,17 +940,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
940 if primaryOrchestrator {940 if primaryOrchestrator {
941 // Headless orchestration owns its strict prompt contract rather than941 // Headless orchestration owns its strict prompt contract rather than
942 // inheriting the interactive collaboration setting.942 // inheriting the interactive collaboration setting.
943- append_ = append(append_, StrictOrchestratorSystemAddendumFor(943+ append_ = append(append_, StrictOrchestratorSystemAddendumFor(subagentActionsAllowed(args)))
944- autoSubagentsToolAllowed(args),
945- autoSubagentsStopToolAllowed(args),
946- autoSubagentsResumeToolAllowed(args),
947- ))
948 } else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {944 } else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
949- append_ = append(append_, ProactiveSubagentsSystemAddendumFor(945+ append_ = append(append_, ProactiveSubagentsSystemAddendumFor(subagentActionsAllowed(args)))
950- autoSubagentsToolAllowed(args),
951- autoSubagentsStopToolAllowed(args),
952- autoSubagentsResumeToolAllowed(args),
953- ))
954 } else if primaryInteractive && autoSubagentsAnyToolAllowed(args) {946 } else if primaryInteractive && autoSubagentsAnyToolAllowed(args) {
955 append_ = append(append_, OnDemandSubagentsSystemAddendum)947 append_ = append(append_, OnDemandSubagentsSystemAddendum)
956 }948 }
@@ -1519,11 +1511,26 @@ func autoSubagentsResumeToolAllowed(args Args) bool {
1519 return subagentActionAllowed(args, tools.SubagentActionResume)1511 return subagentActionAllowed(args, tools.SubagentActionResume)
1520 }1512 }
1521 1513
1514+func autoSubagentsInterruptToolAllowed(args Args) bool {
1515+ return subagentActionAllowed(args, tools.SubagentActionInterrupt)
1516+}
1517+
1518+// subagentActionsAllowed collects the launch-time subagent action grants.
1519+func subagentActionsAllowed(args Args) SubagentActions {
1520+ return SubagentActions{
1521+ Spawn: autoSubagentsToolAllowed(args),
1522+ Stop: autoSubagentsStopToolAllowed(args),
1523+ Resume: autoSubagentsResumeToolAllowed(args),
1524+ Interrupt: autoSubagentsInterruptToolAllowed(args),
1525+ }
1526+}
1527+
1522 func autoSubagentsAnyToolAllowed(args Args) bool {1528 func autoSubagentsAnyToolAllowed(args Args) bool {
1523 return autoSubagentsToolAllowed(args) ||1529 return autoSubagentsToolAllowed(args) ||
1524 autoSubagentsStatusToolAllowed(args) ||1530 autoSubagentsStatusToolAllowed(args) ||
1525 autoSubagentsStopToolAllowed(args) ||1531 autoSubagentsStopToolAllowed(args) ||
1526- autoSubagentsResumeToolAllowed(args)1532+ autoSubagentsResumeToolAllowed(args) ||
1533+ autoSubagentsInterruptToolAllowed(args)
1527 }1534 }
1528 1535
1529 // subagentActionAllowed reports whether the launch-time tool policy grants one1536 // subagentActionAllowed reports whether the launch-time tool policy grants one
M packages/agent/cli.go +67 -69
@@ -2689,6 +2689,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
26892689 autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args)
26902690 autoSubagentsStopToolAllowedForSession := autoSubagentsStopToolAllowed(args)
26912691 autoSubagentsResumeToolAllowedForSession := autoSubagentsResumeToolAllowed(args)
2692+ autoSubagentsInterruptToolAllowedForSession := autoSubagentsInterruptToolAllowed(args)
26922693 webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args)
26932694 webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args)
26942695 setWebSearchAvailable := func(available bool) {
@@ -2701,75 +2702,72 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
27012702 }
27022703
27032704 iv = modes.NewInteractive(modes.InteractiveConfig{
2704- Terminal: term,
2705- Theme: theme,
2706- InlineImagesEnabled: initialCfg.InlineImagesEnabled,
2707- TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
2708- TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
2709- AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,
2710- PonytailEnabled: initialCfg.PonytailEnabled,
2711- WebSearchEnabled: initialCfg.WebSearchEnabled,
2712- WebSearchToolAllowed: &webSearchToolAllowedForInvocation,
2713- WebSearchInvocationOverride: webSearchInvocationOverride,
2714- AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,
2715- AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession,
2716- AutoSubagentsStopToolAllowed: &autoSubagentsStopToolAllowedForSession,
2717- AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession,
2718- FastMode: &fastMode,
2719- LSPEnabled: initialCfg.LSPEnabled,
2720- SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
2721- AutoCompactThreshold: initialCfg.AutoCompactThreshold,
2722- GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget,
2723- JailByDefault: initialCfg.JailByDefault,
2724- QuickModelShortcuts: quickModelShortcuts,
2725- ActiveModelProfile: initialCfg.ActiveModelProfile,
2726- RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
2727- RespectGitignore: initialCfg.RespectGitignore,
2728- CompactMode: initialCfg.CompactMode,
2729- TUIInputStyle: initialCfg.TUIInputStyle,
2730- TUIStatusPosition: initialCfg.TUIStatusPosition,
2731- TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2732- ThemeName: initialCfg.Theme,
2733- EffectiveThemeName: themePreference.Effective,
2734- ThemeForced: themePreference.Forced,
2735- TerminalProfile: theme.Terminal,
2736- FlatTools: initialCfg.FlatToolRender(),
2737- CompactUser: initialCfg.CompactUserInput(),
2738- ExtensionThemes: extMgr.ThemeOptions,
2739- AutoSubagentsSystemAddendum: ProactiveSubagentsSystemAddendumFor(
2740- autoSubagentsToolAllowedForSession,
2741- autoSubagentsStopToolAllowedForSession,
2742- autoSubagentsResumeToolAllowedForSession,
2743- ),
2744- OnDemandSubagentsSystemAddendum: OnDemandSubagentsSystemAddendum,
2745- SubagentsSystemAddendum: subagentsAddendum,
2746- SettingsStore: configSettingsStore{},
2747- Model: r.Model,
2748- Provider: r.Provider,
2749- AuthMethod: r.AuthMethod,
2750- BaseURL: r.BaseURL,
2751- Reasoning: r.Reasoning,
2752- OnReasoningChanged: runtime.SetReasoning,
2753- OnFastModeChanged: runtime.SetFastMode,
2754- SystemPrompt: r.SystemPrompt,
2755- WritingGuidance: WritingGuidance(),
2756- Tools: r.ToolRegistry,
2757- MaxSteps: r.MaxSteps,
2758- CWD: r.CWD,
2759- StartupAgentName: args.AgentName,
2760- StartupContextPaths: instructionContextPaths(r.ContextFiles),
2761- StartupExtensionNames: startupExtensionNames(extMgr.All()),
2762- StartupExtensionErrors: startupExtensionErrors,
2763- StartupSkillNames: startupSkillNames(startupSkills),
2764- ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2765- ZutHome: ZutHome(),
2766- SessionsRoot: agentSessionsRoot(ZutHome(), args),
2767- SessionsDisabled: args.NoSess,
2768- Version: version,
2769- UpdateInfoChan: updateCh,
2770- Sandbox: sharedSandbox,
2771- Agent: ag,
2772- InitialCompactHandoff: currentCompactHandoff(),
2705+ Terminal: term,
2706+ Theme: theme,
2707+ InlineImagesEnabled: initialCfg.InlineImagesEnabled,
2708+ TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
2709+ TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
2710+ AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,
2711+ PonytailEnabled: initialCfg.PonytailEnabled,
2712+ WebSearchEnabled: initialCfg.WebSearchEnabled,
2713+ WebSearchToolAllowed: &webSearchToolAllowedForInvocation,
2714+ WebSearchInvocationOverride: webSearchInvocationOverride,
2715+ AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,
2716+ AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession,
2717+ AutoSubagentsStopToolAllowed: &autoSubagentsStopToolAllowedForSession,
2718+ AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession,
2719+ AutoSubagentsInterruptToolAllowed: &autoSubagentsInterruptToolAllowedForSession,
2720+ FastMode: &fastMode,
2721+ LSPEnabled: initialCfg.LSPEnabled,
2722+ SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
2723+ AutoCompactThreshold: initialCfg.AutoCompactThreshold,
2724+ GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget,
2725+ JailByDefault: initialCfg.JailByDefault,
2726+ QuickModelShortcuts: quickModelShortcuts,
2727+ ActiveModelProfile: initialCfg.ActiveModelProfile,
2728+ RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
2729+ RespectGitignore: initialCfg.RespectGitignore,
2730+ CompactMode: initialCfg.CompactMode,
2731+ TUIInputStyle: initialCfg.TUIInputStyle,
2732+ TUIStatusPosition: initialCfg.TUIStatusPosition,
2733+ TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2734+ ThemeName: initialCfg.Theme,
2735+ EffectiveThemeName: themePreference.Effective,
2736+ ThemeForced: themePreference.Forced,
2737+ TerminalProfile: theme.Terminal,
2738+ FlatTools: initialCfg.FlatToolRender(),
2739+ CompactUser: initialCfg.CompactUserInput(),
2740+ ExtensionThemes: extMgr.ThemeOptions,
2741+ AutoSubagentsSystemAddendum: ProactiveSubagentsSystemAddendumFor(subagentActionsAllowed(args)),
2742+ OnDemandSubagentsSystemAddendum: OnDemandSubagentsSystemAddendum,
2743+ SubagentsSystemAddendum: subagentsAddendum,
2744+ SettingsStore: configSettingsStore{},
2745+ Model: r.Model,
2746+ Provider: r.Provider,
2747+ AuthMethod: r.AuthMethod,
2748+ BaseURL: r.BaseURL,
2749+ Reasoning: r.Reasoning,
2750+ OnReasoningChanged: runtime.SetReasoning,
2751+ OnFastModeChanged: runtime.SetFastMode,
2752+ SystemPrompt: r.SystemPrompt,
2753+ WritingGuidance: WritingGuidance(),
2754+ Tools: r.ToolRegistry,
2755+ MaxSteps: r.MaxSteps,
2756+ CWD: r.CWD,
2757+ StartupAgentName: args.AgentName,
2758+ StartupContextPaths: instructionContextPaths(r.ContextFiles),
2759+ StartupExtensionNames: startupExtensionNames(extMgr.All()),
2760+ StartupExtensionErrors: startupExtensionErrors,
2761+ StartupSkillNames: startupSkillNames(startupSkills),
2762+ ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2763+ ZutHome: ZutHome(),
2764+ SessionsRoot: agentSessionsRoot(ZutHome(), args),
2765+ SessionsDisabled: args.NoSess,
2766+ Version: version,
2767+ UpdateInfoChan: updateCh,
2768+ Sandbox: sharedSandbox,
2769+ Agent: ag,
2770+ InitialCompactHandoff: currentCompactHandoff(),
27732771 InitialSessionTitle: func() string {
27742772 if sess == nil || sessionTitlePending {
27752773 return ""
@@ -2689,6 +2689,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2689 autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args)2689 autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args)
2690 autoSubagentsStopToolAllowedForSession := autoSubagentsStopToolAllowed(args)2690 autoSubagentsStopToolAllowedForSession := autoSubagentsStopToolAllowed(args)
2691 autoSubagentsResumeToolAllowedForSession := autoSubagentsResumeToolAllowed(args)2691 autoSubagentsResumeToolAllowedForSession := autoSubagentsResumeToolAllowed(args)
2692+ autoSubagentsInterruptToolAllowedForSession := autoSubagentsInterruptToolAllowed(args)
2692 webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args)2693 webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args)
2693 webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args)2694 webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args)
2694 setWebSearchAvailable := func(available bool) {2695 setWebSearchAvailable := func(available bool) {
@@ -2701,75 +2702,72 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2701 }2702 }
2702 2703
2703 iv = modes.NewInteractive(modes.InteractiveConfig{2704 iv = modes.NewInteractive(modes.InteractiveConfig{
2704- Terminal: term,2705+ Terminal: term,
2705- Theme: theme,2706+ Theme: theme,
2706- InlineImagesEnabled: initialCfg.InlineImagesEnabled,2707+ InlineImagesEnabled: initialCfg.InlineImagesEnabled,
2707- TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,2708+ TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
2708- TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,2709+ TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
2709- AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,2710+ AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,
2710- PonytailEnabled: initialCfg.PonytailEnabled,2711+ PonytailEnabled: initialCfg.PonytailEnabled,
2711- WebSearchEnabled: initialCfg.WebSearchEnabled,2712+ WebSearchEnabled: initialCfg.WebSearchEnabled,
2712- WebSearchToolAllowed: &webSearchToolAllowedForInvocation,2713+ WebSearchToolAllowed: &webSearchToolAllowedForInvocation,
2713- WebSearchInvocationOverride: webSearchInvocationOverride,2714+ WebSearchInvocationOverride: webSearchInvocationOverride,
2714- AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,2715+ AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,
2715- AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession,2716+ AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession,
2716- AutoSubagentsStopToolAllowed: &autoSubagentsStopToolAllowedForSession,2717+ AutoSubagentsStopToolAllowed: &autoSubagentsStopToolAllowedForSession,
2717- AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession,2718+ AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession,
2718- FastMode: &fastMode,2719+ AutoSubagentsInterruptToolAllowed: &autoSubagentsInterruptToolAllowedForSession,
2719- LSPEnabled: initialCfg.LSPEnabled,2720+ FastMode: &fastMode,
2720- SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,2721+ LSPEnabled: initialCfg.LSPEnabled,
2721- AutoCompactThreshold: initialCfg.AutoCompactThreshold,2722+ SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
2722- GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget,2723+ AutoCompactThreshold: initialCfg.AutoCompactThreshold,
2723- JailByDefault: initialCfg.JailByDefault,2724+ GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget,
2724- QuickModelShortcuts: quickModelShortcuts,2725+ JailByDefault: initialCfg.JailByDefault,
2725- ActiveModelProfile: initialCfg.ActiveModelProfile,2726+ QuickModelShortcuts: quickModelShortcuts,
2726- RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,2727+ ActiveModelProfile: initialCfg.ActiveModelProfile,
2727- RespectGitignore: initialCfg.RespectGitignore,2728+ RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
2728- CompactMode: initialCfg.CompactMode,2729+ RespectGitignore: initialCfg.RespectGitignore,
2729- TUIInputStyle: initialCfg.TUIInputStyle,2730+ CompactMode: initialCfg.CompactMode,
2730- TUIStatusPosition: initialCfg.TUIStatusPosition,2731+ TUIInputStyle: initialCfg.TUIInputStyle,
2731- TUIWorkingPosition: initialCfg.TUIWorkingPosition,2732+ TUIStatusPosition: initialCfg.TUIStatusPosition,
2732- ThemeName: initialCfg.Theme,2733+ TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2733- EffectiveThemeName: themePreference.Effective,2734+ ThemeName: initialCfg.Theme,
2734- ThemeForced: themePreference.Forced,2735+ EffectiveThemeName: themePreference.Effective,
2735- TerminalProfile: theme.Terminal,2736+ ThemeForced: themePreference.Forced,
2736- FlatTools: initialCfg.FlatToolRender(),2737+ TerminalProfile: theme.Terminal,
2737- CompactUser: initialCfg.CompactUserInput(),2738+ FlatTools: initialCfg.FlatToolRender(),
2738- ExtensionThemes: extMgr.ThemeOptions,2739+ CompactUser: initialCfg.CompactUserInput(),
2739- AutoSubagentsSystemAddendum: ProactiveSubagentsSystemAddendumFor(2740+ ExtensionThemes: extMgr.ThemeOptions,
2740- autoSubagentsToolAllowedForSession,2741+ AutoSubagentsSystemAddendum: ProactiveSubagentsSystemAddendumFor(subagentActionsAllowed(args)),
2741- autoSubagentsStopToolAllowedForSession,2742+ OnDemandSubagentsSystemAddendum: OnDemandSubagentsSystemAddendum,
2742- autoSubagentsResumeToolAllowedForSession,2743+ SubagentsSystemAddendum: subagentsAddendum,
2743- ),2744+ SettingsStore: configSettingsStore{},
2744- OnDemandSubagentsSystemAddendum: OnDemandSubagentsSystemAddendum,2745+ Model: r.Model,
2745- SubagentsSystemAddendum: subagentsAddendum,2746+ Provider: r.Provider,
2746- SettingsStore: configSettingsStore{},2747+ AuthMethod: r.AuthMethod,
2747- Model: r.Model,2748+ BaseURL: r.BaseURL,
2748- Provider: r.Provider,2749+ Reasoning: r.Reasoning,
2749- AuthMethod: r.AuthMethod,2750+ OnReasoningChanged: runtime.SetReasoning,
2750- BaseURL: r.BaseURL,2751+ OnFastModeChanged: runtime.SetFastMode,
2751- Reasoning: r.Reasoning,2752+ SystemPrompt: r.SystemPrompt,
2752- OnReasoningChanged: runtime.SetReasoning,2753+ WritingGuidance: WritingGuidance(),
2753- OnFastModeChanged: runtime.SetFastMode,2754+ Tools: r.ToolRegistry,
2754- SystemPrompt: r.SystemPrompt,2755+ MaxSteps: r.MaxSteps,
2755- WritingGuidance: WritingGuidance(),2756+ CWD: r.CWD,
2756- Tools: r.ToolRegistry,2757+ StartupAgentName: args.AgentName,
2757- MaxSteps: r.MaxSteps,2758+ StartupContextPaths: instructionContextPaths(r.ContextFiles),
2758- CWD: r.CWD,2759+ StartupExtensionNames: startupExtensionNames(extMgr.All()),
2759- StartupAgentName: args.AgentName,2760+ StartupExtensionErrors: startupExtensionErrors,
2760- StartupContextPaths: instructionContextPaths(r.ContextFiles),2761+ StartupSkillNames: startupSkillNames(startupSkills),
2761- StartupExtensionNames: startupExtensionNames(extMgr.All()),2762+ ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2762- StartupExtensionErrors: startupExtensionErrors,2763+ ZutHome: ZutHome(),
2763- StartupSkillNames: startupSkillNames(startupSkills),2764+ SessionsRoot: agentSessionsRoot(ZutHome(), args),
2764- ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,2765+ SessionsDisabled: args.NoSess,
2765- ZutHome: ZutHome(),2766+ Version: version,
2766- SessionsRoot: agentSessionsRoot(ZutHome(), args),2767+ UpdateInfoChan: updateCh,
2767- SessionsDisabled: args.NoSess,2768+ Sandbox: sharedSandbox,
2768- Version: version,2769+ Agent: ag,
2769- UpdateInfoChan: updateCh,2770+ InitialCompactHandoff: currentCompactHandoff(),
2770- Sandbox: sharedSandbox,
2771- Agent: ag,
2772- InitialCompactHandoff: currentCompactHandoff(),
2773 InitialSessionTitle: func() string {2771 InitialSessionTitle: func() string {
2774 if sess == nil || sessionTitlePending {2772 if sess == nil || sessionTitlePending {
2775 return ""2773 return ""
M packages/agent/modes/interactive.go +6 -5
@@ -94,11 +94,12 @@ type InteractiveConfig struct {
9494 // the launch-time --no-tools/--tools policy excludes status queries.
9595 AutoSubagentsStatusToolAllowed *bool
9696
97- // AutoSubagentsStopToolAllowed and AutoSubagentsResumeToolAllowed are nil
98- // for normal sessions and false when the launch-time tool policy excludes
99- // manager lifecycle actions.
100- AutoSubagentsStopToolAllowed *bool
101- AutoSubagentsResumeToolAllowed *bool
97+ // AutoSubagentsStopToolAllowed, AutoSubagentsResumeToolAllowed, and
98+ // AutoSubagentsInterruptToolAllowed are nil for normal sessions and false
99+ // when the launch-time tool policy excludes manager lifecycle actions.
100+ AutoSubagentsStopToolAllowed *bool
101+ AutoSubagentsResumeToolAllowed *bool
102+ AutoSubagentsInterruptToolAllowed *bool
102103
103104 // FastMode mirrors the active profile's or global persisted OpenAI
104105 // fast-mode flag at startup. nil/missing means disabled. Unsupported
@@ -94,11 +94,12 @@ type InteractiveConfig struct {
94 // the launch-time --no-tools/--tools policy excludes status queries.94 // the launch-time --no-tools/--tools policy excludes status queries.
95 AutoSubagentsStatusToolAllowed *bool95 AutoSubagentsStatusToolAllowed *bool
96 96
97- // AutoSubagentsStopToolAllowed and AutoSubagentsResumeToolAllowed are nil97+ // AutoSubagentsStopToolAllowed, AutoSubagentsResumeToolAllowed, and
98- // for normal sessions and false when the launch-time tool policy excludes98+ // AutoSubagentsInterruptToolAllowed are nil for normal sessions and false
99- // manager lifecycle actions.99+ // when the launch-time tool policy excludes manager lifecycle actions.
100- AutoSubagentsStopToolAllowed *bool100+ AutoSubagentsStopToolAllowed *bool
101- AutoSubagentsResumeToolAllowed *bool101+ AutoSubagentsResumeToolAllowed *bool
102+ AutoSubagentsInterruptToolAllowed *bool
102 103
103 // FastMode mirrors the active profile's or global persisted OpenAI104 // FastMode mirrors the active profile's or global persisted OpenAI
104 // fast-mode flag at startup. nil/missing means disabled. Unsupported105 // fast-mode flag at startup. nil/missing means disabled. Unsupported
M packages/agent/modes/interactive_resident_subagents.go +17 -1
@@ -478,11 +478,18 @@ func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool {
478478 }
479479 return autoSubagentsToolAllowedConfig(cfg)
480480 }
481+func autoSubagentsInterruptToolAllowedConfig(cfg InteractiveConfig) bool {
482+ if cfg.AutoSubagentsInterruptToolAllowed != nil {
483+ return *cfg.AutoSubagentsInterruptToolAllowed
484+ }
485+ return autoSubagentsToolAllowedConfig(cfg)
486+}
481487 func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
482488 return autoSubagentsToolAllowedConfig(cfg) ||
483489 autoSubagentsStatusToolAllowedConfig(cfg) ||
484490 autoSubagentsStopToolAllowedConfig(cfg) ||
485- autoSubagentsResumeToolAllowedConfig(cfg)
491+ autoSubagentsResumeToolAllowedConfig(cfg) ||
492+ autoSubagentsInterruptToolAllowedConfig(cfg)
486493 }
487494 func (i *Interactive) autoSubagentsToolAllowed() bool {
488495 return autoSubagentsToolAllowedConfig(i.cfg)
@@ -496,6 +503,9 @@ func (i *Interactive) autoSubagentsStopToolAllowed() bool {
496503 func (i *Interactive) autoSubagentsResumeToolAllowed() bool {
497504 return autoSubagentsResumeToolAllowedConfig(i.cfg)
498505 }
506+func (i *Interactive) autoSubagentsInterruptToolAllowed() bool {
507+ return autoSubagentsInterruptToolAllowedConfig(i.cfg)
508+}
499509 func (i *Interactive) autoSubagentsEnabledLocked() bool {
500510 return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
501511 }
@@ -546,6 +556,12 @@ func (i *Interactive) applyAutoSubagentsTool() {
546556 Enabled: func() bool { return true },
547557 }
548558 }
559+ if i.autoSubagentsInterruptToolAllowed() {
560+ facade.Interrupt = &tools.SubagentInterruptTool{
561+ ResidentManager: i.cfg.ResidentManager,
562+ Enabled: func() bool { return true },
563+ }
564+ }
549565 next[facade.Name()] = facade
550566 }
551567 i.agent.SetTools(next)
@@ -478,11 +478,18 @@ func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool {
478 }478 }
479 return autoSubagentsToolAllowedConfig(cfg)479 return autoSubagentsToolAllowedConfig(cfg)
480 }480 }
481+func autoSubagentsInterruptToolAllowedConfig(cfg InteractiveConfig) bool {
482+ if cfg.AutoSubagentsInterruptToolAllowed != nil {
483+ return *cfg.AutoSubagentsInterruptToolAllowed
484+ }
485+ return autoSubagentsToolAllowedConfig(cfg)
486+}
481 func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {487 func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
482 return autoSubagentsToolAllowedConfig(cfg) ||488 return autoSubagentsToolAllowedConfig(cfg) ||
483 autoSubagentsStatusToolAllowedConfig(cfg) ||489 autoSubagentsStatusToolAllowedConfig(cfg) ||
484 autoSubagentsStopToolAllowedConfig(cfg) ||490 autoSubagentsStopToolAllowedConfig(cfg) ||
485- autoSubagentsResumeToolAllowedConfig(cfg)491+ autoSubagentsResumeToolAllowedConfig(cfg) ||
492+ autoSubagentsInterruptToolAllowedConfig(cfg)
486 }493 }
487 func (i *Interactive) autoSubagentsToolAllowed() bool {494 func (i *Interactive) autoSubagentsToolAllowed() bool {
488 return autoSubagentsToolAllowedConfig(i.cfg)495 return autoSubagentsToolAllowedConfig(i.cfg)
@@ -496,6 +503,9 @@ func (i *Interactive) autoSubagentsStopToolAllowed() bool {
496 func (i *Interactive) autoSubagentsResumeToolAllowed() bool {503 func (i *Interactive) autoSubagentsResumeToolAllowed() bool {
497 return autoSubagentsResumeToolAllowedConfig(i.cfg)504 return autoSubagentsResumeToolAllowedConfig(i.cfg)
498 }505 }
506+func (i *Interactive) autoSubagentsInterruptToolAllowed() bool {
507+ return autoSubagentsInterruptToolAllowedConfig(i.cfg)
508+}
499 func (i *Interactive) autoSubagentsEnabledLocked() bool {509 func (i *Interactive) autoSubagentsEnabledLocked() bool {
500 return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()510 return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
501 }511 }
@@ -546,6 +556,12 @@ func (i *Interactive) applyAutoSubagentsTool() {
546 Enabled: func() bool { return true },556 Enabled: func() bool { return true },
547 }557 }
548 }558 }
559+ if i.autoSubagentsInterruptToolAllowed() {
560+ facade.Interrupt = &tools.SubagentInterruptTool{
561+ ResidentManager: i.cfg.ResidentManager,
562+ Enabled: func() bool { return true },
563+ }
564+ }
549 next[facade.Name()] = facade565 next[facade.Name()] = facade
550 }566 }
551 i.agent.SetTools(next)567 i.agent.SetTools(next)
M packages/agent/modes/resident_subagent_indicator_test.go +12 -12
@@ -44,11 +44,11 @@ func (t *residentIndicatorTerminal) Reset() {
4444 }
4545
4646 func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAllows(t *testing.T) {
47- manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
48- return func(ctx context.Context, _ string) error {
47+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
48+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
4949 <-ctx.Done()
5050 return ctx.Err()
51- }, nil
51+ }), nil
5252 })
5353 t.Cleanup(func() { _ = manager.Close(context.Background()) })
5454 spawnRunningResidentIndicatorChildren(t, manager, 10)
@@ -79,11 +79,11 @@ func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAl
7979 }
8080
8181 func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeight(t *testing.T) {
82- manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
83- return func(ctx context.Context, _ string) error {
82+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
83+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
8484 <-ctx.Done()
8585 return ctx.Err()
86- }, nil
86+ }), nil
8787 })
8888 t.Cleanup(func() { _ = manager.Close(context.Background()) })
8989 spawnRunningResidentIndicatorChildren(t, manager, 6)
@@ -105,11 +105,11 @@ func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeigh
105105 }
106106
107107 func TestInteractiveResidentSubagentActivityShowsAllActiveChildrenWhenShortHeightFits(t *testing.T) {
108- manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
109- return func(ctx context.Context, _ string) error {
108+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
109+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
110110 <-ctx.Done()
111111 return ctx.Err()
112- }, nil
112+ }), nil
113113 })
114114 t.Cleanup(func() { _ = manager.Close(context.Background()) })
115115 spawnRunningResidentIndicatorChildren(t, manager, 2)
@@ -175,11 +175,11 @@ func spawnResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentMan
175175 }
176176
177177 func TestRenderResidentSubagentActivityPageReportsHiddenActiveChildren(t *testing.T) {
178- manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
179- return func(ctx context.Context, _ string) error {
178+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
179+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
180180 <-ctx.Done()
181181 return ctx.Err()
182- }, nil
182+ }), nil
183183 })
184184 t.Cleanup(func() { _ = manager.Close(context.Background()) })
185185 spawnResidentIndicatorChildren(t, manager, 5)
@@ -44,11 +44,11 @@ func (t *residentIndicatorTerminal) Reset() {
44 }44 }
45 45
46 func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAllows(t *testing.T) {46 func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAllows(t *testing.T) {
47- manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {47+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
48- return func(ctx context.Context, _ string) error {48+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
49 <-ctx.Done()49 <-ctx.Done()
50 return ctx.Err()50 return ctx.Err()
51- }, nil51+ }), nil
52 })52 })
53 t.Cleanup(func() { _ = manager.Close(context.Background()) })53 t.Cleanup(func() { _ = manager.Close(context.Background()) })
54 spawnRunningResidentIndicatorChildren(t, manager, 10)54 spawnRunningResidentIndicatorChildren(t, manager, 10)
@@ -79,11 +79,11 @@ func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAl
79 }79 }
80 80
81 func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeight(t *testing.T) {81 func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeight(t *testing.T) {
82- manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {82+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
83- return func(ctx context.Context, _ string) error {83+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
84 <-ctx.Done()84 <-ctx.Done()
85 return ctx.Err()85 return ctx.Err()
86- }, nil86+ }), nil
87 })87 })
88 t.Cleanup(func() { _ = manager.Close(context.Background()) })88 t.Cleanup(func() { _ = manager.Close(context.Background()) })
89 spawnRunningResidentIndicatorChildren(t, manager, 6)89 spawnRunningResidentIndicatorChildren(t, manager, 6)
@@ -105,11 +105,11 @@ func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeigh
105 }105 }
106 106
107 func TestInteractiveResidentSubagentActivityShowsAllActiveChildrenWhenShortHeightFits(t *testing.T) {107 func TestInteractiveResidentSubagentActivityShowsAllActiveChildrenWhenShortHeightFits(t *testing.T) {
108- manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {108+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
109- return func(ctx context.Context, _ string) error {109+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
110 <-ctx.Done()110 <-ctx.Done()
111 return ctx.Err()111 return ctx.Err()
112- }, nil112+ }), nil
113 })113 })
114 t.Cleanup(func() { _ = manager.Close(context.Background()) })114 t.Cleanup(func() { _ = manager.Close(context.Background()) })
115 spawnRunningResidentIndicatorChildren(t, manager, 2)115 spawnRunningResidentIndicatorChildren(t, manager, 2)
@@ -175,11 +175,11 @@ func spawnResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentMan
175 }175 }
176 176
177 func TestRenderResidentSubagentActivityPageReportsHiddenActiveChildren(t *testing.T) {177 func TestRenderResidentSubagentActivityPageReportsHiddenActiveChildren(t *testing.T) {
178- manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {178+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
179- return func(ctx context.Context, _ string) error {179+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
180 <-ctx.Done()180 <-ctx.Done()
181 return ctx.Err()181 return ctx.Err()
182- }, nil182+ }), nil
183 })183 })
184 t.Cleanup(func() { _ = manager.Close(context.Background()) })184 t.Cleanup(func() { _ = manager.Close(context.Background()) })
185 spawnResidentIndicatorChildren(t, manager, 5)185 spawnResidentIndicatorChildren(t, manager, 5)
M packages/agent/modes/resident_subagent_tools_test.go +3 -2
@@ -30,6 +30,7 @@ func TestApplyAutoSubagentsToolReplacesOneFacadeEntry(t *testing.T) {
3030 AutoSubagentsStatusToolAllowed: &allowed,
3131 AutoSubagentsStopToolAllowed: &allowed,
3232 AutoSubagentsResumeToolAllowed: &resumeAllowed,
33+ // Interrupt is left unset, so it follows the spawn grant.
3334 })
3435
3536 interactive.applyAutoSubagentsTool()
@@ -59,8 +60,8 @@ func TestApplyAutoSubagentsToolReplacesOneFacadeEntry(t *testing.T) {
5960 if facade == preSeeded {
6061 t.Fatal("interactive refresh reused the pre-seeded facade instead of replacing it")
6162 }
62- if facade.Spawn == nil || facade.Status == nil || facade.Stop == nil {
63- t.Fatalf("facade actions = spawn:%v status:%v stop:%v", facade.Spawn != nil, facade.Status != nil, facade.Stop != nil)
63+ if facade.Spawn == nil || facade.Status == nil || facade.Stop == nil || facade.Interrupt == nil {
64+ t.Fatalf("facade actions = spawn:%v status:%v stop:%v interrupt:%v", facade.Spawn != nil, facade.Status != nil, facade.Stop != nil, facade.Interrupt != nil)
6465 }
6566 if (facade.Resume != nil) != wantResume {
6667 t.Fatalf("facade resume = %v, want %v", facade.Resume != nil, wantResume)
@@ -30,6 +30,7 @@ func TestApplyAutoSubagentsToolReplacesOneFacadeEntry(t *testing.T) {
30 AutoSubagentsStatusToolAllowed: &allowed,30 AutoSubagentsStatusToolAllowed: &allowed,
31 AutoSubagentsStopToolAllowed: &allowed,31 AutoSubagentsStopToolAllowed: &allowed,
32 AutoSubagentsResumeToolAllowed: &resumeAllowed,32 AutoSubagentsResumeToolAllowed: &resumeAllowed,
33+ // Interrupt is left unset, so it follows the spawn grant.
33 })34 })
34 35
35 interactive.applyAutoSubagentsTool()36 interactive.applyAutoSubagentsTool()
@@ -59,8 +60,8 @@ func TestApplyAutoSubagentsToolReplacesOneFacadeEntry(t *testing.T) {
59 if facade == preSeeded {60 if facade == preSeeded {
60 t.Fatal("interactive refresh reused the pre-seeded facade instead of replacing it")61 t.Fatal("interactive refresh reused the pre-seeded facade instead of replacing it")
61 }62 }
62- if facade.Spawn == nil || facade.Status == nil || facade.Stop == nil {63+ if facade.Spawn == nil || facade.Status == nil || facade.Stop == nil || facade.Interrupt == nil {
63- t.Fatalf("facade actions = spawn:%v status:%v stop:%v", facade.Spawn != nil, facade.Status != nil, facade.Stop != nil)64+ t.Fatalf("facade actions = spawn:%v status:%v stop:%v interrupt:%v", facade.Spawn != nil, facade.Status != nil, facade.Stop != nil, facade.Interrupt != nil)
64 }65 }
65 if (facade.Resume != nil) != wantResume {66 if (facade.Resume != nil) != wantResume {
66 t.Fatalf("facade resume = %v, want %v", facade.Resume != nil, wantResume)67 t.Fatalf("facade resume = %v, want %v", facade.Resume != nil, wantResume)
M packages/agent/modes/resident_subagents_dialog_test.go +3 -3
@@ -32,12 +32,12 @@ func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testi
3232
3333 func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) {
3434 completed := make(chan struct{}, 2)
35- manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
35+ manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
3636 journal.ConfigureUsage(272_000, true)
37- return func(context.Context, string) error {
37+ return subagents.ResidentTurnRunner(func(context.Context, string) error {
3838 usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525}
3939 return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage})
40- }, nil
40+ }), nil
4141 })
4242 manager.SetCompletionObserver(func(subagents.ResidentCompletion) { completed <- struct{}{} })
4343 t.Cleanup(func() { _ = manager.Close(context.Background()) })
@@ -32,12 +32,12 @@ func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testi
32 32
33 func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) {33 func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) {
34 completed := make(chan struct{}, 2)34 completed := make(chan struct{}, 2)
35- manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {35+ manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
36 journal.ConfigureUsage(272_000, true)36 journal.ConfigureUsage(272_000, true)
37- return func(context.Context, string) error {37+ return subagents.ResidentTurnRunner(func(context.Context, string) error {
38 usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525}38 usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525}
39 return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage})39 return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage})
40- }, nil40+ }), nil
41 })41 })
42 manager.SetCompletionObserver(func(subagents.ResidentCompletion) { completed <- struct{}{} })42 manager.SetCompletionObserver(func(subagents.ResidentCompletion) { completed <- struct{}{} })
43 t.Cleanup(func() { _ = manager.Close(context.Background()) })43 t.Cleanup(func() { _ = manager.Close(context.Background()) })
M packages/agent/modes/subagent_session_test.go +19 -19
@@ -32,8 +32,8 @@ func TestResidentChildSessionBuildsSharedTranscriptView(t *testing.T) {
3232 if err := journal.Close(); err != nil {
3333 t.Fatal(err)
3434 }
35- manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
36- return func(context.Context, string) error { return nil }, nil
35+ manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
36+ return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
3737 })
3838 session := newResidentChildSession(manager, "child", tui.Dark)
3939 if err := session.LoadRecent(20); err != nil {
@@ -52,11 +52,11 @@ func TestResidentChildSessionBuildsSharedTranscriptView(t *testing.T) {
5252 func TestResidentChildSessionKeepsComposerUntilFollowUpIsAccepted(t *testing.T) {
5353 root := t.TempDir()
5454 runs := make(chan string, 2)
55- manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
56- return func(_ context.Context, prompt string) error {
55+ manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
56+ return subagents.ResidentTurnRunner(func(_ context.Context, prompt string) error {
5757 runs <- prompt
5858 return nil
59- }, nil
59+ }), nil
6060 })
6161 t.Cleanup(func() {
6262 if err := manager.Close(context.Background()); err != nil {
@@ -227,8 +227,8 @@ func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) {
227227 started := make(chan struct{})
228228 stream := make(chan struct{})
229229 release := make(chan struct{})
230- manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
231- return func(context.Context, string) error {
230+ manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
231+ return subagents.ResidentTurnRunner(func(context.Context, string) error {
232232 close(started)
233233 <-stream
234234 if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streamed without input"}); err != nil {
@@ -236,7 +236,7 @@ func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) {
236236 }
237237 <-release
238238 return nil
239- }, nil
239+ }), nil
240240 })
241241 t.Cleanup(func() {
242242 close(release)
@@ -277,8 +277,8 @@ func TestInteractiveResidentHistoryUpdatesRefreshOpenChildSession(t *testing.T)
277277 started := make(chan struct{})
278278 persist := make(chan struct{})
279279 release := make(chan struct{})
280- manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
281- return func(context.Context, string) error {
280+ manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
281+ return subagents.ResidentTurnRunner(func(context.Context, string) error {
282282 close(started)
283283 <-persist
284284 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"printf fresh"}`)}}}}); err != nil {
@@ -289,7 +289,7 @@ func TestInteractiveResidentHistoryUpdatesRefreshOpenChildSession(t *testing.T)
289289 }
290290 <-release
291291 return nil
292- }, nil
292+ }), nil
293293 })
294294 t.Cleanup(func() {
295295 close(release)
@@ -328,8 +328,8 @@ func TestInteractiveResidentHistoryUpdateDoesNotBlockChildControlGoroutine(t *te
328328 persist := make(chan struct{})
329329 persisted := make(chan struct{})
330330 release := make(chan struct{})
331- manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
332- return func(context.Context, string) error {
331+ manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
332+ return subagents.ResidentTurnRunner(func(context.Context, string) error {
333333 close(started)
334334 <-persist
335335 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "durable update"}}}}); err != nil {
@@ -338,7 +338,7 @@ func TestInteractiveResidentHistoryUpdateDoesNotBlockChildControlGoroutine(t *te
338338 close(persisted)
339339 <-release
340340 return nil
341- }, nil
341+ }), nil
342342 })
343343 t.Cleanup(func() {
344344 close(release)
@@ -365,8 +365,8 @@ func TestInteractiveResidentHistoryUpdateDoesNotBlockChildControlGoroutine(t *te
365365 }
366366
367367 func TestInteractiveInputDownOpensResidentPickerAndEnterOpensLiveChild(t *testing.T) {
368- manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
369- return func(context.Context, string) error { return nil }, nil
368+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
369+ return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
370370 })
371371 t.Cleanup(func() {
372372 if err := manager.Close(context.Background()); err != nil {
@@ -399,12 +399,12 @@ func TestInteractiveInputDownOpensResidentPickerAndEnterOpensLiveChild(t *testin
399399 func TestInteractiveResidentActivityDrivesIndependentAnimation(t *testing.T) {
400400 started := make(chan struct{})
401401 release := make(chan struct{})
402- manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
403- return func(context.Context, string) error {
402+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
403+ return subagents.ResidentTurnRunner(func(context.Context, string) error {
404404 close(started)
405405 <-release
406406 return nil
407- }, nil
407+ }), nil
408408 })
409409 t.Cleanup(func() {
410410 if err := manager.Close(context.Background()); err != nil {
@@ -32,8 +32,8 @@ func TestResidentChildSessionBuildsSharedTranscriptView(t *testing.T) {
32 if err := journal.Close(); err != nil {32 if err := journal.Close(); err != nil {
33 t.Fatal(err)33 t.Fatal(err)
34 }34 }
35- manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {35+ manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
36- return func(context.Context, string) error { return nil }, nil36+ return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
37 })37 })
38 session := newResidentChildSession(manager, "child", tui.Dark)38 session := newResidentChildSession(manager, "child", tui.Dark)
39 if err := session.LoadRecent(20); err != nil {39 if err := session.LoadRecent(20); err != nil {
@@ -52,11 +52,11 @@ func TestResidentChildSessionBuildsSharedTranscriptView(t *testing.T) {
52 func TestResidentChildSessionKeepsComposerUntilFollowUpIsAccepted(t *testing.T) {52 func TestResidentChildSessionKeepsComposerUntilFollowUpIsAccepted(t *testing.T) {
53 root := t.TempDir()53 root := t.TempDir()
54 runs := make(chan string, 2)54 runs := make(chan string, 2)
55- manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {55+ manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
56- return func(_ context.Context, prompt string) error {56+ return subagents.ResidentTurnRunner(func(_ context.Context, prompt string) error {
57 runs <- prompt57 runs <- prompt
58 return nil58 return nil
59- }, nil59+ }), nil
60 })60 })
61 t.Cleanup(func() {61 t.Cleanup(func() {
62 if err := manager.Close(context.Background()); err != nil {62 if err := manager.Close(context.Background()); err != nil {
@@ -227,8 +227,8 @@ func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) {
227 started := make(chan struct{})227 started := make(chan struct{})
228 stream := make(chan struct{})228 stream := make(chan struct{})
229 release := make(chan struct{})229 release := make(chan struct{})
230- manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {230+ manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
231- return func(context.Context, string) error {231+ return subagents.ResidentTurnRunner(func(context.Context, string) error {
232 close(started)232 close(started)
233 <-stream233 <-stream
234 if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streamed without input"}); err != nil {234 if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streamed without input"}); err != nil {
@@ -236,7 +236,7 @@ func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) {
236 }236 }
237 <-release237 <-release
238 return nil238 return nil
239- }, nil239+ }), nil
240 })240 })
241 t.Cleanup(func() {241 t.Cleanup(func() {
242 close(release)242 close(release)
@@ -277,8 +277,8 @@ func TestInteractiveResidentHistoryUpdatesRefreshOpenChildSession(t *testing.T)
277 started := make(chan struct{})277 started := make(chan struct{})
278 persist := make(chan struct{})278 persist := make(chan struct{})
279 release := make(chan struct{})279 release := make(chan struct{})
280- manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {280+ manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
281- return func(context.Context, string) error {281+ return subagents.ResidentTurnRunner(func(context.Context, string) error {
282 close(started)282 close(started)
283 <-persist283 <-persist
284 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"printf fresh"}`)}}}}); err != nil {284 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"printf fresh"}`)}}}}); err != nil {
@@ -289,7 +289,7 @@ func TestInteractiveResidentHistoryUpdatesRefreshOpenChildSession(t *testing.T)
289 }289 }
290 <-release290 <-release
291 return nil291 return nil
292- }, nil292+ }), nil
293 })293 })
294 t.Cleanup(func() {294 t.Cleanup(func() {
295 close(release)295 close(release)
@@ -328,8 +328,8 @@ func TestInteractiveResidentHistoryUpdateDoesNotBlockChildControlGoroutine(t *te
328 persist := make(chan struct{})328 persist := make(chan struct{})
329 persisted := make(chan struct{})329 persisted := make(chan struct{})
330 release := make(chan struct{})330 release := make(chan struct{})
331- manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {331+ manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
332- return func(context.Context, string) error {332+ return subagents.ResidentTurnRunner(func(context.Context, string) error {
333 close(started)333 close(started)
334 <-persist334 <-persist
335 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "durable update"}}}}); err != nil {335 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "durable update"}}}}); err != nil {
@@ -338,7 +338,7 @@ func TestInteractiveResidentHistoryUpdateDoesNotBlockChildControlGoroutine(t *te
338 close(persisted)338 close(persisted)
339 <-release339 <-release
340 return nil340 return nil
341- }, nil341+ }), nil
342 })342 })
343 t.Cleanup(func() {343 t.Cleanup(func() {
344 close(release)344 close(release)
@@ -365,8 +365,8 @@ func TestInteractiveResidentHistoryUpdateDoesNotBlockChildControlGoroutine(t *te
365 }365 }
366 366
367 func TestInteractiveInputDownOpensResidentPickerAndEnterOpensLiveChild(t *testing.T) {367 func TestInteractiveInputDownOpensResidentPickerAndEnterOpensLiveChild(t *testing.T) {
368- manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {368+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
369- return func(context.Context, string) error { return nil }, nil369+ return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
370 })370 })
371 t.Cleanup(func() {371 t.Cleanup(func() {
372 if err := manager.Close(context.Background()); err != nil {372 if err := manager.Close(context.Background()); err != nil {
@@ -399,12 +399,12 @@ func TestInteractiveInputDownOpensResidentPickerAndEnterOpensLiveChild(t *testin
399 func TestInteractiveResidentActivityDrivesIndependentAnimation(t *testing.T) {399 func TestInteractiveResidentActivityDrivesIndependentAnimation(t *testing.T) {
400 started := make(chan struct{})400 started := make(chan struct{})
401 release := make(chan struct{})401 release := make(chan struct{})
402- manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {402+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
403- return func(context.Context, string) error {403+ return subagents.ResidentTurnRunner(func(context.Context, string) error {
404 close(started)404 close(started)
405 <-release405 <-release
406 return nil406 return nil
407- }, nil407+ }), nil
408 })408 })
409 t.Cleanup(func() {409 t.Cleanup(func() {
410 if err := manager.Close(context.Background()); err != nil {410 if err := manager.Close(context.Background()); err != nil {
M packages/agent/modes/subagents_slash.go +7 -2
@@ -77,11 +77,16 @@ func (i *Interactive) runResidentSubagents(ctx context.Context, args []string) {
7777 i.subagentsStatus("", "/subagents "+sub+" <id> <prompt>: missing id or prompt")
7878 return
7979 }
80- if err := i.cfg.ResidentManager.Resume(ctx, id, prompt); err != nil {
80+ outcome, err := i.cfg.ResidentManager.ResumeFollowUp(ctx, id, prompt, subagents.ResumeSteer, "", nil)
81+ if err != nil {
8182 i.subagentsStatus("", sub+": "+err.Error())
8283 return
8384 }
84- i.subagentsStatus("accepted follow-up for "+id, "")
85+ if outcome.Steered {
86+ i.subagentsStatus("steered follow-up into the running turn of "+id, "")
87+ } else {
88+ i.subagentsStatus("queued follow-up for "+id, "")
89+ }
8590 default:
8691 i.subagentsStatus("", "/subagents: resident child sessions support list / new / logs / result / kill / send / resume")
8792 }
@@ -77,11 +77,16 @@ func (i *Interactive) runResidentSubagents(ctx context.Context, args []string) {
77 i.subagentsStatus("", "/subagents "+sub+" <id> <prompt>: missing id or prompt")77 i.subagentsStatus("", "/subagents "+sub+" <id> <prompt>: missing id or prompt")
78 return78 return
79 }79 }
80- if err := i.cfg.ResidentManager.Resume(ctx, id, prompt); err != nil {80+ outcome, err := i.cfg.ResidentManager.ResumeFollowUp(ctx, id, prompt, subagents.ResumeSteer, "", nil)
81+ if err != nil {
81 i.subagentsStatus("", sub+": "+err.Error())82 i.subagentsStatus("", sub+": "+err.Error())
82 return83 return
83 }84 }
84- i.subagentsStatus("accepted follow-up for "+id, "")85+ if outcome.Steered {
86+ i.subagentsStatus("steered follow-up into the running turn of "+id, "")
87+ } else {
88+ i.subagentsStatus("queued follow-up for "+id, "")
89+ }
85 default:90 default:
86 i.subagentsStatus("", "/subagents: resident child sessions support list / new / logs / result / kill / send / resume")91 i.subagentsStatus("", "/subagents: resident child sessions support list / new / logs / result / kill / send / resume")
87 }92 }
M packages/agent/settings_store.go +30 -16
@@ -2,6 +2,7 @@ package agent
22
33 import (
44 "fmt"
5+ "strings"
56
67 "github.com/bnema/zut/packages/agent/modes"
78 "github.com/bnema/zut/packages/provider"
@@ -339,34 +340,47 @@ const StrictOrchestratorDelegationUnavailableAddendum = `Delegation is unavailab
339340
340341 const ProactiveSubagentsDelegationUnavailableAddendum = `Proactive delegation is unavailable in this session because the launch-time tool policy does not expose the subagent spawn action. Continue the user's task locally; do not report delegation as a blocker.`
341342
343+// SubagentActions records which subagent actions the launch-time tool policy
344+// exposes, so prompt guidance names only actions the model can call.
345+type SubagentActions struct {
346+ Spawn, Stop, Resume, Interrupt bool
347+}
348+
349+func (a SubagentActions) lifecycle() bool { return a.Stop || a.Resume || a.Interrupt }
350+
342351 // ProactiveSubagentsSystemAddendumFor returns the interactive collaboration
343352 // contract with guidance only for manager actions exposed at launch time.
344-func ProactiveSubagentsSystemAddendumFor(spawnToolAllowed, stopToolAllowed, resumeToolAllowed bool) string {
353+func ProactiveSubagentsSystemAddendumFor(actions SubagentActions) string {
345354 base := ProactiveSubagentsSystemAddendum
346- if spawnToolAllowed {
355+ if actions.Spawn {
347356 base = ProactiveSubagentsRoutingAddendum + "\n\n" + base
348357 }
349- return subagentsSystemAddendumFor(base, ProactiveSubagentsDelegationUnavailableAddendum, false, spawnToolAllowed, stopToolAllowed, resumeToolAllowed)
358+ return subagentsSystemAddendumFor(base, ProactiveSubagentsDelegationUnavailableAddendum, false, actions)
350359 }
351360
352361 // StrictOrchestratorSystemAddendumFor returns the headless manager-only
353362 // contract with guidance only for manager actions exposed at launch time.
354-func StrictOrchestratorSystemAddendumFor(spawnToolAllowed, stopToolAllowed, resumeToolAllowed bool) string {
355- return subagentsSystemAddendumFor(StrictOrchestratorSystemAddendum, StrictOrchestratorDelegationUnavailableAddendum, true, spawnToolAllowed, stopToolAllowed, resumeToolAllowed)
363+func StrictOrchestratorSystemAddendumFor(actions SubagentActions) string {
364+ return subagentsSystemAddendumFor(StrictOrchestratorSystemAddendum, StrictOrchestratorDelegationUnavailableAddendum, true, actions)
356365 }
357366
358-func subagentsSystemAddendumFor(base, unavailable string, strict, spawnToolAllowed, stopToolAllowed, resumeToolAllowed bool) string {
367+func subagentsSystemAddendumFor(base, unavailable string, strict bool, actions SubagentActions) string {
359368 addendum := base + "\n\n" + subagentLifecycleAddendum
360- switch {
361- case stopToolAllowed && resumeToolAllowed:
362- addendum += "\n\nManager lifecycle actions are available: use the subagent tool's stop action to request termination of a stuck worker, and its resume action with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context."
363- case stopToolAllowed:
364- addendum += "\n\nA manager lifecycle action is available: use the subagent tool's stop action to request termination of a stuck worker."
365- case resumeToolAllowed:
366- addendum += "\n\nA manager lifecycle action is available: use the subagent tool's resume action with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context."
367- }
368- if !spawnToolAllowed {
369- if stopToolAllowed || resumeToolAllowed {
369+ var lifecycle []string
370+ if actions.Resume {
371+ lifecycle = append(lifecycle, "use resume with an agent id and follow-up prompt to continue an idle worker, steer a running worker mid-turn, or restart a stopped worker with its existing session context")
372+ }
373+ if actions.Interrupt {
374+ lifecycle = append(lifecycle, "use interrupt to cancel only a worker's running turn while keeping its context, for example before asking it to wrap up")
375+ }
376+ if actions.Stop {
377+ lifecycle = append(lifecycle, "use stop to request termination of a stuck worker")
378+ }
379+ if len(lifecycle) != 0 {
380+ addendum += "\n\nSubagent manager lifecycle actions available: " + strings.Join(lifecycle, "; ") + "."
381+ }
382+ if !actions.Spawn {
383+ if actions.lifecycle() {
370384 addendum += "\n\nSpawning new workers is unavailable in this session. Use only the enabled manager lifecycle actions for existing workers."
371385 if strict {
372386 addendum += " Do not implement, debug, test, or review directly."
@@ -2,6 +2,7 @@ package agent
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "strings"
5 6
6 "github.com/bnema/zut/packages/agent/modes"7 "github.com/bnema/zut/packages/agent/modes"
7 "github.com/bnema/zut/packages/provider"8 "github.com/bnema/zut/packages/provider"
@@ -339,34 +340,47 @@ const StrictOrchestratorDelegationUnavailableAddendum = `Delegation is unavailab
339 340
340 const ProactiveSubagentsDelegationUnavailableAddendum = `Proactive delegation is unavailable in this session because the launch-time tool policy does not expose the subagent spawn action. Continue the user's task locally; do not report delegation as a blocker.`341 const ProactiveSubagentsDelegationUnavailableAddendum = `Proactive delegation is unavailable in this session because the launch-time tool policy does not expose the subagent spawn action. Continue the user's task locally; do not report delegation as a blocker.`
341 342
343+// SubagentActions records which subagent actions the launch-time tool policy
344+// exposes, so prompt guidance names only actions the model can call.
345+type SubagentActions struct {
346+ Spawn, Stop, Resume, Interrupt bool
347+}
348+
349+func (a SubagentActions) lifecycle() bool { return a.Stop || a.Resume || a.Interrupt }
350+
342 // ProactiveSubagentsSystemAddendumFor returns the interactive collaboration351 // ProactiveSubagentsSystemAddendumFor returns the interactive collaboration
343 // contract with guidance only for manager actions exposed at launch time.352 // contract with guidance only for manager actions exposed at launch time.
344-func ProactiveSubagentsSystemAddendumFor(spawnToolAllowed, stopToolAllowed, resumeToolAllowed bool) string {353+func ProactiveSubagentsSystemAddendumFor(actions SubagentActions) string {
345 base := ProactiveSubagentsSystemAddendum354 base := ProactiveSubagentsSystemAddendum
346- if spawnToolAllowed {355+ if actions.Spawn {
347 base = ProactiveSubagentsRoutingAddendum + "\n\n" + base356 base = ProactiveSubagentsRoutingAddendum + "\n\n" + base
348 }357 }
349- return subagentsSystemAddendumFor(base, ProactiveSubagentsDelegationUnavailableAddendum, false, spawnToolAllowed, stopToolAllowed, resumeToolAllowed)358+ return subagentsSystemAddendumFor(base, ProactiveSubagentsDelegationUnavailableAddendum, false, actions)
350 }359 }
351 360
352 // StrictOrchestratorSystemAddendumFor returns the headless manager-only361 // StrictOrchestratorSystemAddendumFor returns the headless manager-only
353 // contract with guidance only for manager actions exposed at launch time.362 // contract with guidance only for manager actions exposed at launch time.
354-func StrictOrchestratorSystemAddendumFor(spawnToolAllowed, stopToolAllowed, resumeToolAllowed bool) string {363+func StrictOrchestratorSystemAddendumFor(actions SubagentActions) string {
355- return subagentsSystemAddendumFor(StrictOrchestratorSystemAddendum, StrictOrchestratorDelegationUnavailableAddendum, true, spawnToolAllowed, stopToolAllowed, resumeToolAllowed)364+ return subagentsSystemAddendumFor(StrictOrchestratorSystemAddendum, StrictOrchestratorDelegationUnavailableAddendum, true, actions)
356 }365 }
357 366
358-func subagentsSystemAddendumFor(base, unavailable string, strict, spawnToolAllowed, stopToolAllowed, resumeToolAllowed bool) string {367+func subagentsSystemAddendumFor(base, unavailable string, strict bool, actions SubagentActions) string {
359 addendum := base + "\n\n" + subagentLifecycleAddendum368 addendum := base + "\n\n" + subagentLifecycleAddendum
360- switch {369+ var lifecycle []string
361- case stopToolAllowed && resumeToolAllowed:370+ if actions.Resume {
362- addendum += "\n\nManager lifecycle actions are available: use the subagent tool's stop action to request termination of a stuck worker, and its resume action with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context."371+ lifecycle = append(lifecycle, "use resume with an agent id and follow-up prompt to continue an idle worker, steer a running worker mid-turn, or restart a stopped worker with its existing session context")
363- case stopToolAllowed:372+ }
364- addendum += "\n\nA manager lifecycle action is available: use the subagent tool's stop action to request termination of a stuck worker."373+ if actions.Interrupt {
365- case resumeToolAllowed:374+ lifecycle = append(lifecycle, "use interrupt to cancel only a worker's running turn while keeping its context, for example before asking it to wrap up")
366- addendum += "\n\nA manager lifecycle action is available: use the subagent tool's resume action with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context."375+ }
367- }376+ if actions.Stop {
368- if !spawnToolAllowed {377+ lifecycle = append(lifecycle, "use stop to request termination of a stuck worker")
369- if stopToolAllowed || resumeToolAllowed {378+ }
379+ if len(lifecycle) != 0 {
380+ addendum += "\n\nSubagent manager lifecycle actions available: " + strings.Join(lifecycle, "; ") + "."
381+ }
382+ if !actions.Spawn {
383+ if actions.lifecycle() {
370 addendum += "\n\nSpawning new workers is unavailable in this session. Use only the enabled manager lifecycle actions for existing workers."384 addendum += "\n\nSpawning new workers is unavailable in this session. Use only the enabled manager lifecycle actions for existing workers."
371 if strict {385 if strict {
372 addendum += " Do not implement, debug, test, or review directly."386 addendum += " Do not implement, debug, test, or review directly."
M packages/agent/settings_store_test.go +26 -22
@@ -6,15 +6,16 @@ import (
66 )
77
88 func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) {
9- // Substrings that identify the stop and resume action guidance.
9+ // Substrings that identify the lifecycle action guidance.
1010 const (
11- stopAction = "stop action to request termination of a stuck worker"
12- resumeAction = "resume action with an agent id and follow-up prompt"
13- noSpawnNotice = "Spawning new workers is unavailable"
11+ stopAction = "use stop to request termination of a stuck worker"
12+ resumeAction = "use resume with an agent id and follow-up prompt"
13+ interruptAction = "use interrupt to cancel only a worker's running turn"
14+ noSpawnNotice = "Spawning new workers is unavailable"
1415 )
1516 builders := []struct {
1617 name string
17- build func(bool, bool, bool) string
18+ build func(SubagentActions) string
1819 noSpawnFallback string
1920 }{
2021 {name: "proactive", build: ProactiveSubagentsSystemAddendumFor, noSpawnFallback: "Continue non-delegated work locally."},
@@ -27,22 +28,25 @@ func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) {
2728 spawn bool
2829 stop bool
2930 resume bool
31+ interrupt bool
3032 want []string
3133 unwanted []string
3234 available bool
3335 }{
34- {name: "none", spawn: true, unwanted: []string{stopAction, resumeAction}, available: true},
35- {name: "fully unavailable", unwanted: []string{stopAction, resumeAction}},
36- {name: "stop", spawn: true, stop: true, want: []string{stopAction}, unwanted: []string{resumeAction}, available: true},
37- {name: "resume", spawn: true, resume: true, want: []string{resumeAction}, unwanted: []string{stopAction}, available: true},
38- {name: "both", spawn: true, stop: true, resume: true, want: []string{stopAction, resumeAction}, available: true},
39- {name: "stop without spawn", stop: true, want: []string{stopAction, noSpawnNotice}, unwanted: []string{resumeAction}},
40- {name: "resume without spawn", resume: true, want: []string{resumeAction, noSpawnNotice}, unwanted: []string{stopAction}},
41- {name: "both without spawn", stop: true, resume: true, want: []string{stopAction, resumeAction, noSpawnNotice}},
36+ {name: "none", spawn: true, unwanted: []string{stopAction, resumeAction, interruptAction}, available: true},
37+ {name: "fully unavailable", unwanted: []string{stopAction, resumeAction, interruptAction}},
38+ {name: "stop", spawn: true, stop: true, want: []string{stopAction}, unwanted: []string{resumeAction, interruptAction}, available: true},
39+ {name: "resume", spawn: true, resume: true, want: []string{resumeAction}, unwanted: []string{stopAction, interruptAction}, available: true},
40+ {name: "interrupt", spawn: true, interrupt: true, want: []string{interruptAction}, unwanted: []string{stopAction, resumeAction}, available: true},
41+ {name: "all", spawn: true, stop: true, resume: true, interrupt: true, want: []string{stopAction, resumeAction, interruptAction}, available: true},
42+ {name: "stop without spawn", stop: true, want: []string{stopAction, noSpawnNotice}, unwanted: []string{resumeAction, interruptAction}},
43+ {name: "resume without spawn", resume: true, want: []string{resumeAction, noSpawnNotice}, unwanted: []string{stopAction, interruptAction}},
44+ {name: "interrupt without spawn", interrupt: true, want: []string{interruptAction, noSpawnNotice}, unwanted: []string{stopAction, resumeAction}},
45+ {name: "all without spawn", stop: true, resume: true, interrupt: true, want: []string{stopAction, resumeAction, interruptAction, noSpawnNotice}},
4246 } {
4347 t.Run(tc.name, func(t *testing.T) {
44- got := builder.build(tc.spawn, tc.stop, tc.resume)
45- if !tc.spawn && (tc.stop || tc.resume) && !strings.Contains(got, builder.noSpawnFallback) {
48+ got := builder.build(SubagentActions{Spawn: tc.spawn, Stop: tc.stop, Resume: tc.resume, Interrupt: tc.interrupt})
49+ if !tc.spawn && (tc.stop || tc.resume || tc.interrupt) && !strings.Contains(got, builder.noSpawnFallback) {
4650 t.Fatalf("addendum missing mode-specific no-spawn fallback %q:\n%s", builder.noSpawnFallback, got)
4751 }
4852 for _, want := range tc.want {
@@ -65,7 +69,7 @@ func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) {
6569 }
6670
6771 func TestSubagentPoliciesSeparateProactiveAndStrictOwnership(t *testing.T) {
68- proactive := ProactiveSubagentsSystemAddendumFor(true, false, false)
72+ proactive := ProactiveSubagentsSystemAddendumFor(SubagentActions{Spawn: true, Stop: false, Resume: false})
6973 for _, want := range []string{"primary owner and implementer", "immediate next task you will perform locally", "If you cannot name useful non-overlapping local work, do not delegate", "Do not search, read, test, review, edit"} {
7074 if !strings.Contains(proactive, want) {
7175 t.Fatalf("proactive addendum missing %q:\n%s", want, proactive)
@@ -75,7 +79,7 @@ func TestSubagentPoliciesSeparateProactiveAndStrictOwnership(t *testing.T) {
7579 t.Fatalf("proactive addendum retained strict orchestrator contract:\n%s", proactive)
7680 }
7781
78- strict := StrictOrchestratorSystemAddendumFor(true, false, false)
82+ strict := StrictOrchestratorSystemAddendumFor(SubagentActions{Spawn: true, Stop: false, Resume: false})
7983 for _, want := range []string{"not an implementer", "non-overlapping worker scopes", "Once a worker is active", "Only coordinate workers"} {
8084 if !strings.Contains(strict, want) {
8185 t.Fatalf("strict addendum missing %q:\n%s", want, strict)
@@ -84,7 +88,7 @@ func TestSubagentPoliciesSeparateProactiveAndStrictOwnership(t *testing.T) {
8488 }
8589
8690 func TestProactiveAddendumRoutesBeforeWorkOnlyWhenSpawningIsAvailable(t *testing.T) {
87- proactive := ProactiveSubagentsSystemAddendumFor(true, false, false)
91+ proactive := ProactiveSubagentsSystemAddendumFor(SubagentActions{Spawn: true, Stop: false, Resume: false})
8892 for _, want := range []string{
8993 "Route before you work:",
9094 "routing happens when the user states the request you are about to work on",
@@ -110,7 +114,7 @@ func TestProactiveAddendumRoutesBeforeWorkOnlyWhenSpawningIsAvailable(t *testing
110114 {name: "stop and resume", stop: true, resume: true},
111115 } {
112116 t.Run(tc.name, func(t *testing.T) {
113- got := ProactiveSubagentsSystemAddendumFor(false, tc.stop, tc.resume)
117+ got := ProactiveSubagentsSystemAddendumFor(SubagentActions{Spawn: false, Stop: tc.stop, Resume: tc.resume})
114118 for _, unwanted := range []string{"Route before you work:", "[subagents_list]", "spawn a clearly described general worker"} {
115119 if strings.Contains(got, unwanted) {
116120 t.Fatalf("spawn-withheld addendum kept %q:\n%s", unwanted, got)
@@ -133,18 +137,18 @@ func TestProactiveAddendumRoutesBeforeWorkOnlyWhenSpawningIsAvailable(t *testing
133137
134138 // Routing is the interactive collaboration step; the headless strict
135139 // contract divides scopes itself and must not inherit it.
136- strict := StrictOrchestratorSystemAddendumFor(true, false, false)
140+ strict := StrictOrchestratorSystemAddendumFor(SubagentActions{Spawn: true, Stop: false, Resume: false})
137141 if strings.Contains(strict, "Route before you work:") || strings.Contains(strict, "[subagents_list]") {
138142 t.Fatalf("strict orchestrator inherited interactive routing guidance:\n%s", strict)
139143 }
140144 }
141145
142146 func TestSubagentPoliciesHandleUnavailableDelegationByMode(t *testing.T) {
143- proactive := ProactiveSubagentsSystemAddendumFor(false, false, false)
147+ proactive := ProactiveSubagentsSystemAddendumFor(SubagentActions{Spawn: false, Stop: false, Resume: false})
144148 if !strings.Contains(proactive, "Continue the user's task locally") || strings.Contains(proactive, "report this limitation") {
145149 t.Fatalf("proactive unavailable guidance blocks local work:\n%s", proactive)
146150 }
147- strict := StrictOrchestratorSystemAddendumFor(false, false, false)
151+ strict := StrictOrchestratorSystemAddendumFor(SubagentActions{Spawn: false, Stop: false, Resume: false})
148152 if !strings.Contains(strict, "report this limitation") || !strings.Contains(strict, "rather than implementing") {
149153 t.Fatalf("strict unavailable guidance permits local implementation:\n%s", strict)
150154 }
@@ -6,15 +6,16 @@ import (
6 )6 )
7 7
8 func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) {8 func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) {
9- // Substrings that identify the stop and resume action guidance.9+ // Substrings that identify the lifecycle action guidance.
10 const (10 const (
11- stopAction = "stop action to request termination of a stuck worker"11+ stopAction = "use stop to request termination of a stuck worker"
12- resumeAction = "resume action with an agent id and follow-up prompt"12+ resumeAction = "use resume with an agent id and follow-up prompt"
13- noSpawnNotice = "Spawning new workers is unavailable"13+ interruptAction = "use interrupt to cancel only a worker's running turn"
14+ noSpawnNotice = "Spawning new workers is unavailable"
14 )15 )
15 builders := []struct {16 builders := []struct {
16 name string17 name string
17- build func(bool, bool, bool) string18+ build func(SubagentActions) string
18 noSpawnFallback string19 noSpawnFallback string
19 }{20 }{
20 {name: "proactive", build: ProactiveSubagentsSystemAddendumFor, noSpawnFallback: "Continue non-delegated work locally."},21 {name: "proactive", build: ProactiveSubagentsSystemAddendumFor, noSpawnFallback: "Continue non-delegated work locally."},
@@ -27,22 +28,25 @@ func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) {
27 spawn bool28 spawn bool
28 stop bool29 stop bool
29 resume bool30 resume bool
31+ interrupt bool
30 want []string32 want []string
31 unwanted []string33 unwanted []string
32 available bool34 available bool
33 }{35 }{
34- {name: "none", spawn: true, unwanted: []string{stopAction, resumeAction}, available: true},36+ {name: "none", spawn: true, unwanted: []string{stopAction, resumeAction, interruptAction}, available: true},
35- {name: "fully unavailable", unwanted: []string{stopAction, resumeAction}},37+ {name: "fully unavailable", unwanted: []string{stopAction, resumeAction, interruptAction}},
36- {name: "stop", spawn: true, stop: true, want: []string{stopAction}, unwanted: []string{resumeAction}, available: true},38+ {name: "stop", spawn: true, stop: true, want: []string{stopAction}, unwanted: []string{resumeAction, interruptAction}, available: true},
37- {name: "resume", spawn: true, resume: true, want: []string{resumeAction}, unwanted: []string{stopAction}, available: true},39+ {name: "resume", spawn: true, resume: true, want: []string{resumeAction}, unwanted: []string{stopAction, interruptAction}, available: true},
38- {name: "both", spawn: true, stop: true, resume: true, want: []string{stopAction, resumeAction}, available: true},40+ {name: "interrupt", spawn: true, interrupt: true, want: []string{interruptAction}, unwanted: []string{stopAction, resumeAction}, available: true},
39- {name: "stop without spawn", stop: true, want: []string{stopAction, noSpawnNotice}, unwanted: []string{resumeAction}},41+ {name: "all", spawn: true, stop: true, resume: true, interrupt: true, want: []string{stopAction, resumeAction, interruptAction}, available: true},
40- {name: "resume without spawn", resume: true, want: []string{resumeAction, noSpawnNotice}, unwanted: []string{stopAction}},42+ {name: "stop without spawn", stop: true, want: []string{stopAction, noSpawnNotice}, unwanted: []string{resumeAction, interruptAction}},
41- {name: "both without spawn", stop: true, resume: true, want: []string{stopAction, resumeAction, noSpawnNotice}},43+ {name: "resume without spawn", resume: true, want: []string{resumeAction, noSpawnNotice}, unwanted: []string{stopAction, interruptAction}},
44+ {name: "interrupt without spawn", interrupt: true, want: []string{interruptAction, noSpawnNotice}, unwanted: []string{stopAction, resumeAction}},
45+ {name: "all without spawn", stop: true, resume: true, interrupt: true, want: []string{stopAction, resumeAction, interruptAction, noSpawnNotice}},
42 } {46 } {
43 t.Run(tc.name, func(t *testing.T) {47 t.Run(tc.name, func(t *testing.T) {
44- got := builder.build(tc.spawn, tc.stop, tc.resume)48+ got := builder.build(SubagentActions{Spawn: tc.spawn, Stop: tc.stop, Resume: tc.resume, Interrupt: tc.interrupt})
45- if !tc.spawn && (tc.stop || tc.resume) && !strings.Contains(got, builder.noSpawnFallback) {49+ if !tc.spawn && (tc.stop || tc.resume || tc.interrupt) && !strings.Contains(got, builder.noSpawnFallback) {
46 t.Fatalf("addendum missing mode-specific no-spawn fallback %q:\n%s", builder.noSpawnFallback, got)50 t.Fatalf("addendum missing mode-specific no-spawn fallback %q:\n%s", builder.noSpawnFallback, got)
47 }51 }
48 for _, want := range tc.want {52 for _, want := range tc.want {
@@ -65,7 +69,7 @@ func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) {
65 }69 }
66 70
67 func TestSubagentPoliciesSeparateProactiveAndStrictOwnership(t *testing.T) {71 func TestSubagentPoliciesSeparateProactiveAndStrictOwnership(t *testing.T) {
68- proactive := ProactiveSubagentsSystemAddendumFor(true, false, false)72+ proactive := ProactiveSubagentsSystemAddendumFor(SubagentActions{Spawn: true, Stop: false, Resume: false})
69 for _, want := range []string{"primary owner and implementer", "immediate next task you will perform locally", "If you cannot name useful non-overlapping local work, do not delegate", "Do not search, read, test, review, edit"} {73 for _, want := range []string{"primary owner and implementer", "immediate next task you will perform locally", "If you cannot name useful non-overlapping local work, do not delegate", "Do not search, read, test, review, edit"} {
70 if !strings.Contains(proactive, want) {74 if !strings.Contains(proactive, want) {
71 t.Fatalf("proactive addendum missing %q:\n%s", want, proactive)75 t.Fatalf("proactive addendum missing %q:\n%s", want, proactive)
@@ -75,7 +79,7 @@ func TestSubagentPoliciesSeparateProactiveAndStrictOwnership(t *testing.T) {
75 t.Fatalf("proactive addendum retained strict orchestrator contract:\n%s", proactive)79 t.Fatalf("proactive addendum retained strict orchestrator contract:\n%s", proactive)
76 }80 }
77 81
78- strict := StrictOrchestratorSystemAddendumFor(true, false, false)82+ strict := StrictOrchestratorSystemAddendumFor(SubagentActions{Spawn: true, Stop: false, Resume: false})
79 for _, want := range []string{"not an implementer", "non-overlapping worker scopes", "Once a worker is active", "Only coordinate workers"} {83 for _, want := range []string{"not an implementer", "non-overlapping worker scopes", "Once a worker is active", "Only coordinate workers"} {
80 if !strings.Contains(strict, want) {84 if !strings.Contains(strict, want) {
81 t.Fatalf("strict addendum missing %q:\n%s", want, strict)85 t.Fatalf("strict addendum missing %q:\n%s", want, strict)
@@ -84,7 +88,7 @@ func TestSubagentPoliciesSeparateProactiveAndStrictOwnership(t *testing.T) {
84 }88 }
85 89
86 func TestProactiveAddendumRoutesBeforeWorkOnlyWhenSpawningIsAvailable(t *testing.T) {90 func TestProactiveAddendumRoutesBeforeWorkOnlyWhenSpawningIsAvailable(t *testing.T) {
87- proactive := ProactiveSubagentsSystemAddendumFor(true, false, false)91+ proactive := ProactiveSubagentsSystemAddendumFor(SubagentActions{Spawn: true, Stop: false, Resume: false})
88 for _, want := range []string{92 for _, want := range []string{
89 "Route before you work:",93 "Route before you work:",
90 "routing happens when the user states the request you are about to work on",94 "routing happens when the user states the request you are about to work on",
@@ -110,7 +114,7 @@ func TestProactiveAddendumRoutesBeforeWorkOnlyWhenSpawningIsAvailable(t *testing
110 {name: "stop and resume", stop: true, resume: true},114 {name: "stop and resume", stop: true, resume: true},
111 } {115 } {
112 t.Run(tc.name, func(t *testing.T) {116 t.Run(tc.name, func(t *testing.T) {
113- got := ProactiveSubagentsSystemAddendumFor(false, tc.stop, tc.resume)117+ got := ProactiveSubagentsSystemAddendumFor(SubagentActions{Spawn: false, Stop: tc.stop, Resume: tc.resume})
114 for _, unwanted := range []string{"Route before you work:", "[subagents_list]", "spawn a clearly described general worker"} {118 for _, unwanted := range []string{"Route before you work:", "[subagents_list]", "spawn a clearly described general worker"} {
115 if strings.Contains(got, unwanted) {119 if strings.Contains(got, unwanted) {
116 t.Fatalf("spawn-withheld addendum kept %q:\n%s", unwanted, got)120 t.Fatalf("spawn-withheld addendum kept %q:\n%s", unwanted, got)
@@ -133,18 +137,18 @@ func TestProactiveAddendumRoutesBeforeWorkOnlyWhenSpawningIsAvailable(t *testing
133 137
134 // Routing is the interactive collaboration step; the headless strict138 // Routing is the interactive collaboration step; the headless strict
135 // contract divides scopes itself and must not inherit it.139 // contract divides scopes itself and must not inherit it.
136- strict := StrictOrchestratorSystemAddendumFor(true, false, false)140+ strict := StrictOrchestratorSystemAddendumFor(SubagentActions{Spawn: true, Stop: false, Resume: false})
137 if strings.Contains(strict, "Route before you work:") || strings.Contains(strict, "[subagents_list]") {141 if strings.Contains(strict, "Route before you work:") || strings.Contains(strict, "[subagents_list]") {
138 t.Fatalf("strict orchestrator inherited interactive routing guidance:\n%s", strict)142 t.Fatalf("strict orchestrator inherited interactive routing guidance:\n%s", strict)
139 }143 }
140 }144 }
141 145
142 func TestSubagentPoliciesHandleUnavailableDelegationByMode(t *testing.T) {146 func TestSubagentPoliciesHandleUnavailableDelegationByMode(t *testing.T) {
143- proactive := ProactiveSubagentsSystemAddendumFor(false, false, false)147+ proactive := ProactiveSubagentsSystemAddendumFor(SubagentActions{Spawn: false, Stop: false, Resume: false})
144 if !strings.Contains(proactive, "Continue the user's task locally") || strings.Contains(proactive, "report this limitation") {148 if !strings.Contains(proactive, "Continue the user's task locally") || strings.Contains(proactive, "report this limitation") {
145 t.Fatalf("proactive unavailable guidance blocks local work:\n%s", proactive)149 t.Fatalf("proactive unavailable guidance blocks local work:\n%s", proactive)
146 }150 }
147- strict := StrictOrchestratorSystemAddendumFor(false, false, false)151+ strict := StrictOrchestratorSystemAddendumFor(SubagentActions{Spawn: false, Stop: false, Resume: false})
148 if !strings.Contains(strict, "report this limitation") || !strings.Contains(strict, "rather than implementing") {152 if !strings.Contains(strict, "report this limitation") || !strings.Contains(strict, "rather than implementing") {
149 t.Fatalf("strict unavailable guidance permits local implementation:\n%s", strict)153 t.Fatalf("strict unavailable guidance permits local implementation:\n%s", strict)
150 }154 }
M packages/agent/subagent_factory.go +66 -7
@@ -13,11 +13,65 @@ import (
1313 "github.com/bnema/zut/packages/provider"
1414 )
1515
16+// residentAgentRuntime runs a child's turns on one core.Agent and lets the
17+// manager steer the running turn through the agent's queued-message boundary.
18+type residentAgentRuntime struct {
19+ agent *core.Agent
20+ run func(context.Context, string) error
21+
22+ mu sync.Mutex
23+ running bool
24+}
25+
26+func (r *residentAgentRuntime) Run(ctx context.Context, prompt string) error {
27+ r.mu.Lock()
28+ r.running = true
29+ r.mu.Unlock()
30+ err := r.run(ctx, prompt)
31+ for {
32+ // A follow-up steered after the agent's last queue check would
33+ // otherwise be silently left behind by a successful turn. Closing
34+ // the steer window under the lock makes delivery all-or-nothing.
35+ // On error or cancellation the queue is left for the child to
36+ // drain and report as undelivered. The loop ends because each
37+ // continuation drains the queue at its first step, and Steer only
38+ // refills it on an explicit manager call.
39+ r.mu.Lock()
40+ if err != nil || ctx.Err() != nil || r.agent.QueuedMessageCount() == 0 {
41+ r.running = false
42+ r.mu.Unlock()
43+ return err
44+ }
45+ r.mu.Unlock()
46+ err = r.run(ctx, "")
47+ }
48+}
49+
50+func (r *residentAgentRuntime) Steer(prompt string) bool {
51+ r.mu.Lock()
52+ defer r.mu.Unlock()
53+ if !r.running {
54+ return false
55+ }
56+ return r.agent.QueueMessage(prompt, nil)
57+}
58+
59+func (r *residentAgentRuntime) PendingSteers() int { return r.agent.QueuedMessageCount() }
60+
61+func (r *residentAgentRuntime) DrainSteers() []string {
62+ drained := r.agent.DrainQueuedMessages()
63+ out := make([]string, 0, len(drained))
64+ for _, message := range drained {
65+ out = append(out, message.Text)
66+ }
67+ return out
68+}
69+
1670 // newResidentChildRunner is the host-owned construction boundary for one
1771 // resident child. It resolves a fresh provider client and a fresh core.Agent
1872 // exactly once for the child's durable session; no subprocess configuration or
1973 // credential transfer is involved.
20-func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
74+func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
2175 if strings.TrimSpace(spec.SessionID) == "" {
2276 return nil, fmt.Errorf("resident child %q has no session identity", spec.ID)
2377 }
@@ -81,10 +135,9 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
81135 }
82136 }
83137 limit := agent.MaxSteps
84- return func(ctx context.Context, prompt string) error {
85- if ctx == nil {
86- ctx = context.Background()
87- }
138+ // run executes one accepted prompt, or continues the transcript to deliver
139+ // late steered follow-ups when prompt is empty.
140+ run := func(ctx context.Context, prompt string) error {
88141 turnCtx, cancel := context.WithCancel(ctx)
89142 defer cancel()
90143 var journalErr error
@@ -115,7 +168,12 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
115168 journalMu.Unlock()
116169 }
117170 }
118- err := agent.Prompt(turnCtx, prompt, nil, sink)
171+ var err error
172+ if prompt == "" {
173+ err = agent.Continue(turnCtx, sink)
174+ } else {
175+ err = agent.Prompt(turnCtx, prompt, nil, sink)
176+ }
119177 if journalFailure := checkJournal(); journalFailure != nil {
120178 return journalFailure
121179 }
@@ -126,7 +184,8 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
126184 }
127185 }
128186 return err
129- }, nil
187+ }
188+ return &residentAgentRuntime{agent: agent, run: run}, nil
130189 }
131190
132191 // A compaction recovery never rewrites a transcript with nothing worth
@@ -13,11 +13,65 @@ import (
13 "github.com/bnema/zut/packages/provider"13 "github.com/bnema/zut/packages/provider"
14 )14 )
15 15
16+// residentAgentRuntime runs a child's turns on one core.Agent and lets the
17+// manager steer the running turn through the agent's queued-message boundary.
18+type residentAgentRuntime struct {
19+ agent *core.Agent
20+ run func(context.Context, string) error
21+
22+ mu sync.Mutex
23+ running bool
24+}
25+
26+func (r *residentAgentRuntime) Run(ctx context.Context, prompt string) error {
27+ r.mu.Lock()
28+ r.running = true
29+ r.mu.Unlock()
30+ err := r.run(ctx, prompt)
31+ for {
32+ // A follow-up steered after the agent's last queue check would
33+ // otherwise be silently left behind by a successful turn. Closing
34+ // the steer window under the lock makes delivery all-or-nothing.
35+ // On error or cancellation the queue is left for the child to
36+ // drain and report as undelivered. The loop ends because each
37+ // continuation drains the queue at its first step, and Steer only
38+ // refills it on an explicit manager call.
39+ r.mu.Lock()
40+ if err != nil || ctx.Err() != nil || r.agent.QueuedMessageCount() == 0 {
41+ r.running = false
42+ r.mu.Unlock()
43+ return err
44+ }
45+ r.mu.Unlock()
46+ err = r.run(ctx, "")
47+ }
48+}
49+
50+func (r *residentAgentRuntime) Steer(prompt string) bool {
51+ r.mu.Lock()
52+ defer r.mu.Unlock()
53+ if !r.running {
54+ return false
55+ }
56+ return r.agent.QueueMessage(prompt, nil)
57+}
58+
59+func (r *residentAgentRuntime) PendingSteers() int { return r.agent.QueuedMessageCount() }
60+
61+func (r *residentAgentRuntime) DrainSteers() []string {
62+ drained := r.agent.DrainQueuedMessages()
63+ out := make([]string, 0, len(drained))
64+ for _, message := range drained {
65+ out = append(out, message.Text)
66+ }
67+ return out
68+}
69+
16 // newResidentChildRunner is the host-owned construction boundary for one70 // newResidentChildRunner is the host-owned construction boundary for one
17 // resident child. It resolves a fresh provider client and a fresh core.Agent71 // resident child. It resolves a fresh provider client and a fresh core.Agent
18 // exactly once for the child's durable session; no subprocess configuration or72 // exactly once for the child's durable session; no subprocess configuration or
19 // credential transfer is involved.73 // credential transfer is involved.
20-func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {74+func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
21 if strings.TrimSpace(spec.SessionID) == "" {75 if strings.TrimSpace(spec.SessionID) == "" {
22 return nil, fmt.Errorf("resident child %q has no session identity", spec.ID)76 return nil, fmt.Errorf("resident child %q has no session identity", spec.ID)
23 }77 }
@@ -81,10 +135,9 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
81 }135 }
82 }136 }
83 limit := agent.MaxSteps137 limit := agent.MaxSteps
84- return func(ctx context.Context, prompt string) error {138+ // run executes one accepted prompt, or continues the transcript to deliver
85- if ctx == nil {139+ // late steered follow-ups when prompt is empty.
86- ctx = context.Background()140+ run := func(ctx context.Context, prompt string) error {
87- }
88 turnCtx, cancel := context.WithCancel(ctx)141 turnCtx, cancel := context.WithCancel(ctx)
89 defer cancel()142 defer cancel()
90 var journalErr error143 var journalErr error
@@ -115,7 +168,12 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
115 journalMu.Unlock()168 journalMu.Unlock()
116 }169 }
117 }170 }
118- err := agent.Prompt(turnCtx, prompt, nil, sink)171+ var err error
172+ if prompt == "" {
173+ err = agent.Continue(turnCtx, sink)
174+ } else {
175+ err = agent.Prompt(turnCtx, prompt, nil, sink)
176+ }
119 if journalFailure := checkJournal(); journalFailure != nil {177 if journalFailure := checkJournal(); journalFailure != nil {
120 return journalFailure178 return journalFailure
121 }179 }
@@ -126,7 +184,8 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
126 }184 }
127 }185 }
128 return err186 return err
129- }, nil187+ }
188+ return &residentAgentRuntime{agent: agent, run: run}, nil
130 }189 }
131 190
132 // A compaction recovery never rewrites a transcript with nothing worth191 // A compaction recovery never rewrites a transcript with nothing worth
M packages/agent/subagent_factory_test.go +21 -21
@@ -27,7 +27,7 @@ func TestResidentChildRunnerWithoutStepLimitRunsPastFormerCap(t *testing.T) {
2727 Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL,
2828 CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, DisableRepetitionGuard: true,
2929 })
30- if err := runner(t.Context(), "review"); err != nil {
30+ if err := runner.Run(t.Context(), "review"); err != nil {
3131 t.Fatalf("runner error = %v, want success past the former 50-step cap", err)
3232 }
3333 if got := requests.Load(); got != 52 {
@@ -41,7 +41,7 @@ func TestResidentChildRunnerEnforcesExplicitParentStepLimit(t *testing.T) {
4141 Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL,
4242 MaxSteps: 3, CWD: t.TempDir(), NoContextFiles: true, NoSkill: true,
4343 })
44- err := runner(t.Context(), "review")
44+ err := runner.Run(t.Context(), "review")
4545 if !errors.Is(err, core.ErrMaxSteps) || !strings.Contains(err.Error(), "3") {
4646 t.Fatalf("runner error = %v, want step limit 3", err)
4747 }
@@ -83,7 +83,7 @@ func residentToolLoopServer(t *testing.T, toolCalls int) (*httptest.Server, *ato
8383
8484 // newResidentChildTestRunner builds one journaled resident child runner with
8585 // its own accepted spec.
86-func newResidentChildTestRunner(t *testing.T, childID string, args Args) (subagents.ResidentTurnRunner, *subagents.ResidentJournal, subagents.ResidentChildSpec) {
86+func newResidentChildTestRunner(t *testing.T, childID string, args Args) (subagents.ResidentRuntime, *subagents.ResidentJournal, subagents.ResidentChildSpec) {
8787 t.Helper()
8888 spec := subagents.ResidentChildSpec{
8989 ID: childID, SessionID: childID + "-session", Provider: "openai", Model: "gpt-4o",
@@ -97,11 +97,11 @@ func newResidentChildTestRunner(t *testing.T, childID string, args Args) (subage
9797 if err := journal.Accept(spec, "review"); err != nil {
9898 t.Fatal(err)
9999 }
100- runner, err := newResidentChildRunner(args, spec, journal)
100+ runtime, err := newResidentChildRunner(args, spec, journal)
101101 if err != nil {
102102 t.Fatal(err)
103103 }
104- return runner, journal, spec
104+ return runtime, journal, spec
105105 }
106106
107107 func TestResidentChildRunnerInjectsContextReminderIntoNextRequest(t *testing.T) {
@@ -159,10 +159,10 @@ func TestResidentChildRunnerInjectsContextReminderIntoNextRequest(t *testing.T)
159159 if err != nil {
160160 t.Fatal(err)
161161 }
162- if err := runner(t.Context(), "review"); err != nil {
162+ if err := runner.Run(t.Context(), "review"); err != nil {
163163 t.Fatal(err)
164164 }
165- if err := runner(t.Context(), "follow up"); err != nil {
165+ if err := runner.Run(t.Context(), "follow up"); err != nil {
166166 t.Fatal(err)
167167 }
168168 if got := requests.Load(); got != 2 {
@@ -353,7 +353,7 @@ func TestResidentChildRunnerKeepsToolsAcrossTurns(t *testing.T) {
353353 defer server.Close()
354354
355355 runner, journal := newResidentTestRunner(t, server.URL)
356- if err := runner(t.Context(), "review"); err != nil {
356+ if err := runner.Run(t.Context(), "review"); err != nil {
357357 t.Fatal(err)
358358 }
359359 if got := requests.Load(); got != 2 {
@@ -384,7 +384,7 @@ func TestResidentChildRunnerRunsHighUsageFollowUpWithoutBudget(t *testing.T) {
384384 defer server.Close()
385385
386386 runner, journal := newResidentTestRunner(t, server.URL)
387- if err := runner(t.Context(), "review"); err != nil {
387+ if err := runner.Run(t.Context(), "review"); err != nil {
388388 t.Fatalf("runner error = %v, want terminal result", err)
389389 }
390390 if got := requests.Load(); got != 1 {
@@ -394,7 +394,7 @@ func TestResidentChildRunnerRunsHighUsageFollowUpWithoutBudget(t *testing.T) {
394394 t.Fatalf("latest summary = %q", summary)
395395 }
396396 // Without a cumulative budget, a high-usage follow-up runs normally.
397- if err := runner(t.Context(), "continue"); err != nil {
397+ if err := runner.Run(t.Context(), "continue"); err != nil {
398398 t.Fatalf("follow-up error = %v, want success", err)
399399 }
400400 if got := requests.Load(); got != 2 {
@@ -429,7 +429,7 @@ func TestResidentChildRunnerResumesWithRetainedHistory(t *testing.T) {
429429 }))
430430 defer server.Close()
431431 cwd := t.TempDir()
432- manager := subagents.NewResidentManager(t.TempDir(), func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
432+ manager := subagents.NewResidentManager(t.TempDir(), func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
433433 return newResidentChildRunner(Args{Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, CWD: cwd, NoContextFiles: true, NoSkill: true}, spec, journal)
434434 })
435435 t.Cleanup(func() { _ = manager.Close(context.Background()) })
@@ -460,7 +460,7 @@ func TestResidentChildRunnerResumesWithRetainedHistory(t *testing.T) {
460460 }
461461 }
462462
463-func newResidentTestRunner(t *testing.T, baseURL string) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) {
463+func newResidentTestRunner(t *testing.T, baseURL string) (subagents.ResidentRuntime, *subagents.ResidentJournal) {
464464 t.Helper()
465465 runner, journal, _ := newResidentChildTestRunner(t, "resident-child", Args{
466466 Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: baseURL,
@@ -672,7 +672,7 @@ func TestResidentChildRunnerRecoversFromContextOverflowOnce(t *testing.T) {
672672 if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
673673 t.Fatal(err)
674674 }
675- runErr := runner(t.Context(), "review")
675+ runErr := runner.Run(t.Context(), "review")
676676 if err := journal.RecordTurnFinished(spec, "turn-1", runErr); err != nil {
677677 t.Fatal(err)
678678 }
@@ -728,7 +728,7 @@ func TestResidentChildRunnerDoesNotRecoverTwicePerTurn(t *testing.T) {
728728 script := &residentOverflowScript{toolCallRequests: 2, secondOverflow: true, finalText: "unreached"}
729729 server := script.start(t)
730730 runner, journal, _ := newResidentChildTestRunner(t, "double-overflow", residentArguments(t, server.URL))
731- err := runner(t.Context(), "review")
731+ err := runner.Run(t.Context(), "review")
732732 if err == nil || !provider.IsContextOverflowError(err) {
733733 t.Fatalf("runner error = %v, want the second overflow to stay terminal", err)
734734 }
@@ -746,7 +746,7 @@ func TestResidentChildRunnerDoesNotReplenishExplicitStepLimit(t *testing.T) {
746746 args := residentArguments(t, server.URL)
747747 args.MaxSteps = 4
748748 runner, journal, _ := newResidentChildTestRunner(t, "limited-overflow", args)
749- err := runner(t.Context(), "review")
749+ err := runner.Run(t.Context(), "review")
750750 if err == nil || !provider.IsContextOverflowError(err) {
751751 t.Fatalf("runner error = %v, want the overflow", err)
752752 }
@@ -791,7 +791,7 @@ func TestResidentChildRunnerFailsTurnWhenCheckpointCannotPersist(t *testing.T) {
791791 if err != nil {
792792 t.Fatal(err)
793793 }
794- if err := runner(t.Context(), "review"); err == nil {
794+ if err := runner.Run(t.Context(), "review"); err == nil {
795795 t.Fatal("runner recovered without persisting its checkpoint")
796796 }
797797 if got := script.requests.Load(); got != 4 {
@@ -815,7 +815,7 @@ func TestResidentChildRunnerFailsTurnWhenCheckpointCannotPersist(t *testing.T) {
815815 if err != nil {
816816 t.Fatal(err)
817817 }
818- if err := runner(t.Context(), "verify"); err != nil {
818+ if err := runner.Run(t.Context(), "verify"); err != nil {
819819 t.Fatalf("follow-up error = %v", err)
820820 }
821821 bodies := script.capturedBodies()
@@ -891,7 +891,7 @@ func TestResidentChildRunnerIgnoresCompactionUsageInReminder(t *testing.T) {
891891 }
892892 server := script.start(t)
893893 runner, _, _ := newResidentChildTestRunner(t, "usage-child", residentArguments(t, server.URL))
894- if err := runner(t.Context(), "review"); err != nil {
894+ if err := runner.Run(t.Context(), "review"); err != nil {
895895 t.Fatalf("runner error = %v, want one recovered turn", err)
896896 }
897897 bodies := script.capturedBodies()
@@ -936,7 +936,7 @@ func TestResidentChildRunnerKeepsHostContextOutOfCheckpoint(t *testing.T) {
936936 }
937937 server := script.start(t)
938938 runner, journal, _ := newResidentChildTestRunner(t, "checkpoint-context", residentArguments(t, server.URL))
939- if err := runner(t.Context(), "review"); err != nil {
939+ if err := runner.Run(t.Context(), "review"); err != nil {
940940 t.Fatalf("runner error = %v, want one recovered turn", err)
941941 }
942942 messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
@@ -964,7 +964,7 @@ func TestResidentChildRunnerKeepsRemainingStepAllowanceAcrossRecovery(t *testing
964964 args := residentArguments(t, server.URL)
965965 args.MaxSteps = 5
966966 runner, journal, _ := newResidentChildTestRunner(t, "partial-allowance", args)
967- err := runner(t.Context(), "review")
967+ err := runner.Run(t.Context(), "review")
968968 if !errors.Is(err, core.ErrMaxSteps) {
969969 t.Fatalf("runner error = %v, want the continuation to exhaust the remaining allowance", err)
970970 }
@@ -982,7 +982,7 @@ func TestResidentChildRunnerDoesNotCompactWithoutHistory(t *testing.T) {
982982 script := &residentOverflowScript{toolCallRequests: 0, finalText: "unreached"}
983983 server := script.start(t)
984984 runner, journal, _ := newResidentChildTestRunner(t, "short-overflow", residentArguments(t, server.URL))
985- err := runner(t.Context(), "review")
985+ err := runner.Run(t.Context(), "review")
986986 if err == nil || !provider.IsContextOverflowError(err) || !strings.Contains(err.Error(), "context_length_exceeded") {
987987 t.Fatalf("runner error = %v, want the original overflow", err)
988988 }
@@ -27,7 +27,7 @@ func TestResidentChildRunnerWithoutStepLimitRunsPastFormerCap(t *testing.T) {
27 Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL,27 Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL,
28 CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, DisableRepetitionGuard: true,28 CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, DisableRepetitionGuard: true,
29 })29 })
30- if err := runner(t.Context(), "review"); err != nil {30+ if err := runner.Run(t.Context(), "review"); err != nil {
31 t.Fatalf("runner error = %v, want success past the former 50-step cap", err)31 t.Fatalf("runner error = %v, want success past the former 50-step cap", err)
32 }32 }
33 if got := requests.Load(); got != 52 {33 if got := requests.Load(); got != 52 {
@@ -41,7 +41,7 @@ func TestResidentChildRunnerEnforcesExplicitParentStepLimit(t *testing.T) {
41 Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL,41 Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL,
42 MaxSteps: 3, CWD: t.TempDir(), NoContextFiles: true, NoSkill: true,42 MaxSteps: 3, CWD: t.TempDir(), NoContextFiles: true, NoSkill: true,
43 })43 })
44- err := runner(t.Context(), "review")44+ err := runner.Run(t.Context(), "review")
45 if !errors.Is(err, core.ErrMaxSteps) || !strings.Contains(err.Error(), "3") {45 if !errors.Is(err, core.ErrMaxSteps) || !strings.Contains(err.Error(), "3") {
46 t.Fatalf("runner error = %v, want step limit 3", err)46 t.Fatalf("runner error = %v, want step limit 3", err)
47 }47 }
@@ -83,7 +83,7 @@ func residentToolLoopServer(t *testing.T, toolCalls int) (*httptest.Server, *ato
83 83
84 // newResidentChildTestRunner builds one journaled resident child runner with84 // newResidentChildTestRunner builds one journaled resident child runner with
85 // its own accepted spec.85 // its own accepted spec.
86-func newResidentChildTestRunner(t *testing.T, childID string, args Args) (subagents.ResidentTurnRunner, *subagents.ResidentJournal, subagents.ResidentChildSpec) {86+func newResidentChildTestRunner(t *testing.T, childID string, args Args) (subagents.ResidentRuntime, *subagents.ResidentJournal, subagents.ResidentChildSpec) {
87 t.Helper()87 t.Helper()
88 spec := subagents.ResidentChildSpec{88 spec := subagents.ResidentChildSpec{
89 ID: childID, SessionID: childID + "-session", Provider: "openai", Model: "gpt-4o",89 ID: childID, SessionID: childID + "-session", Provider: "openai", Model: "gpt-4o",
@@ -97,11 +97,11 @@ func newResidentChildTestRunner(t *testing.T, childID string, args Args) (subage
97 if err := journal.Accept(spec, "review"); err != nil {97 if err := journal.Accept(spec, "review"); err != nil {
98 t.Fatal(err)98 t.Fatal(err)
99 }99 }
100- runner, err := newResidentChildRunner(args, spec, journal)100+ runtime, err := newResidentChildRunner(args, spec, journal)
101 if err != nil {101 if err != nil {
102 t.Fatal(err)102 t.Fatal(err)
103 }103 }
104- return runner, journal, spec104+ return runtime, journal, spec
105 }105 }
106 106
107 func TestResidentChildRunnerInjectsContextReminderIntoNextRequest(t *testing.T) {107 func TestResidentChildRunnerInjectsContextReminderIntoNextRequest(t *testing.T) {
@@ -159,10 +159,10 @@ func TestResidentChildRunnerInjectsContextReminderIntoNextRequest(t *testing.T)
159 if err != nil {159 if err != nil {
160 t.Fatal(err)160 t.Fatal(err)
161 }161 }
162- if err := runner(t.Context(), "review"); err != nil {162+ if err := runner.Run(t.Context(), "review"); err != nil {
163 t.Fatal(err)163 t.Fatal(err)
164 }164 }
165- if err := runner(t.Context(), "follow up"); err != nil {165+ if err := runner.Run(t.Context(), "follow up"); err != nil {
166 t.Fatal(err)166 t.Fatal(err)
167 }167 }
168 if got := requests.Load(); got != 2 {168 if got := requests.Load(); got != 2 {
@@ -353,7 +353,7 @@ func TestResidentChildRunnerKeepsToolsAcrossTurns(t *testing.T) {
353 defer server.Close()353 defer server.Close()
354 354
355 runner, journal := newResidentTestRunner(t, server.URL)355 runner, journal := newResidentTestRunner(t, server.URL)
356- if err := runner(t.Context(), "review"); err != nil {356+ if err := runner.Run(t.Context(), "review"); err != nil {
357 t.Fatal(err)357 t.Fatal(err)
358 }358 }
359 if got := requests.Load(); got != 2 {359 if got := requests.Load(); got != 2 {
@@ -384,7 +384,7 @@ func TestResidentChildRunnerRunsHighUsageFollowUpWithoutBudget(t *testing.T) {
384 defer server.Close()384 defer server.Close()
385 385
386 runner, journal := newResidentTestRunner(t, server.URL)386 runner, journal := newResidentTestRunner(t, server.URL)
387- if err := runner(t.Context(), "review"); err != nil {387+ if err := runner.Run(t.Context(), "review"); err != nil {
388 t.Fatalf("runner error = %v, want terminal result", err)388 t.Fatalf("runner error = %v, want terminal result", err)
389 }389 }
390 if got := requests.Load(); got != 1 {390 if got := requests.Load(); got != 1 {
@@ -394,7 +394,7 @@ func TestResidentChildRunnerRunsHighUsageFollowUpWithoutBudget(t *testing.T) {
394 t.Fatalf("latest summary = %q", summary)394 t.Fatalf("latest summary = %q", summary)
395 }395 }
396 // Without a cumulative budget, a high-usage follow-up runs normally.396 // Without a cumulative budget, a high-usage follow-up runs normally.
397- if err := runner(t.Context(), "continue"); err != nil {397+ if err := runner.Run(t.Context(), "continue"); err != nil {
398 t.Fatalf("follow-up error = %v, want success", err)398 t.Fatalf("follow-up error = %v, want success", err)
399 }399 }
400 if got := requests.Load(); got != 2 {400 if got := requests.Load(); got != 2 {
@@ -429,7 +429,7 @@ func TestResidentChildRunnerResumesWithRetainedHistory(t *testing.T) {
429 }))429 }))
430 defer server.Close()430 defer server.Close()
431 cwd := t.TempDir()431 cwd := t.TempDir()
432- manager := subagents.NewResidentManager(t.TempDir(), func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {432+ manager := subagents.NewResidentManager(t.TempDir(), func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
433 return newResidentChildRunner(Args{Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, CWD: cwd, NoContextFiles: true, NoSkill: true}, spec, journal)433 return newResidentChildRunner(Args{Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, CWD: cwd, NoContextFiles: true, NoSkill: true}, spec, journal)
434 })434 })
435 t.Cleanup(func() { _ = manager.Close(context.Background()) })435 t.Cleanup(func() { _ = manager.Close(context.Background()) })
@@ -460,7 +460,7 @@ func TestResidentChildRunnerResumesWithRetainedHistory(t *testing.T) {
460 }460 }
461 }461 }
462 462
463-func newResidentTestRunner(t *testing.T, baseURL string) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) {463+func newResidentTestRunner(t *testing.T, baseURL string) (subagents.ResidentRuntime, *subagents.ResidentJournal) {
464 t.Helper()464 t.Helper()
465 runner, journal, _ := newResidentChildTestRunner(t, "resident-child", Args{465 runner, journal, _ := newResidentChildTestRunner(t, "resident-child", Args{
466 Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: baseURL,466 Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: baseURL,
@@ -672,7 +672,7 @@ func TestResidentChildRunnerRecoversFromContextOverflowOnce(t *testing.T) {
672 if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {672 if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
673 t.Fatal(err)673 t.Fatal(err)
674 }674 }
675- runErr := runner(t.Context(), "review")675+ runErr := runner.Run(t.Context(), "review")
676 if err := journal.RecordTurnFinished(spec, "turn-1", runErr); err != nil {676 if err := journal.RecordTurnFinished(spec, "turn-1", runErr); err != nil {
677 t.Fatal(err)677 t.Fatal(err)
678 }678 }
@@ -728,7 +728,7 @@ func TestResidentChildRunnerDoesNotRecoverTwicePerTurn(t *testing.T) {
728 script := &residentOverflowScript{toolCallRequests: 2, secondOverflow: true, finalText: "unreached"}728 script := &residentOverflowScript{toolCallRequests: 2, secondOverflow: true, finalText: "unreached"}
729 server := script.start(t)729 server := script.start(t)
730 runner, journal, _ := newResidentChildTestRunner(t, "double-overflow", residentArguments(t, server.URL))730 runner, journal, _ := newResidentChildTestRunner(t, "double-overflow", residentArguments(t, server.URL))
731- err := runner(t.Context(), "review")731+ err := runner.Run(t.Context(), "review")
732 if err == nil || !provider.IsContextOverflowError(err) {732 if err == nil || !provider.IsContextOverflowError(err) {
733 t.Fatalf("runner error = %v, want the second overflow to stay terminal", err)733 t.Fatalf("runner error = %v, want the second overflow to stay terminal", err)
734 }734 }
@@ -746,7 +746,7 @@ func TestResidentChildRunnerDoesNotReplenishExplicitStepLimit(t *testing.T) {
746 args := residentArguments(t, server.URL)746 args := residentArguments(t, server.URL)
747 args.MaxSteps = 4747 args.MaxSteps = 4
748 runner, journal, _ := newResidentChildTestRunner(t, "limited-overflow", args)748 runner, journal, _ := newResidentChildTestRunner(t, "limited-overflow", args)
749- err := runner(t.Context(), "review")749+ err := runner.Run(t.Context(), "review")
750 if err == nil || !provider.IsContextOverflowError(err) {750 if err == nil || !provider.IsContextOverflowError(err) {
751 t.Fatalf("runner error = %v, want the overflow", err)751 t.Fatalf("runner error = %v, want the overflow", err)
752 }752 }
@@ -791,7 +791,7 @@ func TestResidentChildRunnerFailsTurnWhenCheckpointCannotPersist(t *testing.T) {
791 if err != nil {791 if err != nil {
792 t.Fatal(err)792 t.Fatal(err)
793 }793 }
794- if err := runner(t.Context(), "review"); err == nil {794+ if err := runner.Run(t.Context(), "review"); err == nil {
795 t.Fatal("runner recovered without persisting its checkpoint")795 t.Fatal("runner recovered without persisting its checkpoint")
796 }796 }
797 if got := script.requests.Load(); got != 4 {797 if got := script.requests.Load(); got != 4 {
@@ -815,7 +815,7 @@ func TestResidentChildRunnerFailsTurnWhenCheckpointCannotPersist(t *testing.T) {
815 if err != nil {815 if err != nil {
816 t.Fatal(err)816 t.Fatal(err)
817 }817 }
818- if err := runner(t.Context(), "verify"); err != nil {818+ if err := runner.Run(t.Context(), "verify"); err != nil {
819 t.Fatalf("follow-up error = %v", err)819 t.Fatalf("follow-up error = %v", err)
820 }820 }
821 bodies := script.capturedBodies()821 bodies := script.capturedBodies()
@@ -891,7 +891,7 @@ func TestResidentChildRunnerIgnoresCompactionUsageInReminder(t *testing.T) {
891 }891 }
892 server := script.start(t)892 server := script.start(t)
893 runner, _, _ := newResidentChildTestRunner(t, "usage-child", residentArguments(t, server.URL))893 runner, _, _ := newResidentChildTestRunner(t, "usage-child", residentArguments(t, server.URL))
894- if err := runner(t.Context(), "review"); err != nil {894+ if err := runner.Run(t.Context(), "review"); err != nil {
895 t.Fatalf("runner error = %v, want one recovered turn", err)895 t.Fatalf("runner error = %v, want one recovered turn", err)
896 }896 }
897 bodies := script.capturedBodies()897 bodies := script.capturedBodies()
@@ -936,7 +936,7 @@ func TestResidentChildRunnerKeepsHostContextOutOfCheckpoint(t *testing.T) {
936 }936 }
937 server := script.start(t)937 server := script.start(t)
938 runner, journal, _ := newResidentChildTestRunner(t, "checkpoint-context", residentArguments(t, server.URL))938 runner, journal, _ := newResidentChildTestRunner(t, "checkpoint-context", residentArguments(t, server.URL))
939- if err := runner(t.Context(), "review"); err != nil {939+ if err := runner.Run(t.Context(), "review"); err != nil {
940 t.Fatalf("runner error = %v, want one recovered turn", err)940 t.Fatalf("runner error = %v, want one recovered turn", err)
941 }941 }
942 messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())942 messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
@@ -964,7 +964,7 @@ func TestResidentChildRunnerKeepsRemainingStepAllowanceAcrossRecovery(t *testing
964 args := residentArguments(t, server.URL)964 args := residentArguments(t, server.URL)
965 args.MaxSteps = 5965 args.MaxSteps = 5
966 runner, journal, _ := newResidentChildTestRunner(t, "partial-allowance", args)966 runner, journal, _ := newResidentChildTestRunner(t, "partial-allowance", args)
967- err := runner(t.Context(), "review")967+ err := runner.Run(t.Context(), "review")
968 if !errors.Is(err, core.ErrMaxSteps) {968 if !errors.Is(err, core.ErrMaxSteps) {
969 t.Fatalf("runner error = %v, want the continuation to exhaust the remaining allowance", err)969 t.Fatalf("runner error = %v, want the continuation to exhaust the remaining allowance", err)
970 }970 }
@@ -982,7 +982,7 @@ func TestResidentChildRunnerDoesNotCompactWithoutHistory(t *testing.T) {
982 script := &residentOverflowScript{toolCallRequests: 0, finalText: "unreached"}982 script := &residentOverflowScript{toolCallRequests: 0, finalText: "unreached"}
983 server := script.start(t)983 server := script.start(t)
984 runner, journal, _ := newResidentChildTestRunner(t, "short-overflow", residentArguments(t, server.URL))984 runner, journal, _ := newResidentChildTestRunner(t, "short-overflow", residentArguments(t, server.URL))
985- err := runner(t.Context(), "review")985+ err := runner.Run(t.Context(), "review")
986 if err == nil || !provider.IsContextOverflowError(err) || !strings.Contains(err.Error(), "context_length_exceeded") {986 if err == nil || !provider.IsContextOverflowError(err) || !strings.Contains(err.Error(), "context_length_exceeded") {
987 t.Fatalf("runner error = %v, want the original overflow", err)987 t.Fatalf("runner error = %v, want the original overflow", err)
988 }988 }
M packages/agent/subagent_runtime.go +7 -1
@@ -137,7 +137,7 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
137137 root = filepath.Join(ZutHome(), "subagents")
138138 rt.root = root
139139 }
140- rt.resident = subagents.NewResidentManagerWithPolicy(root, cfg.Policy, func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
140+ rt.resident = subagents.NewResidentManagerWithPolicy(root, cfg.Policy, func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
141141 return newResidentChildRunner(residentChildArgs(rt.args, rt.credentialProvider, spec), spec, journal)
142142 })
143143 rt.resident.SetCompletionObserver(cfg.ResidentCompletion)
@@ -492,6 +492,12 @@ func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
492492 Enabled: func() bool { return true },
493493 }
494494 }
495+ if autoSubagentsInterruptToolAllowed(rt.args) {
496+ facade.Interrupt = &tools.SubagentInterruptTool{
497+ ResidentManager: rt.residentManagerForTools(),
498+ Enabled: func() bool { return true },
499+ }
500+ }
495501 reg[facade.Name()] = facade
496502 return reg
497503 }
@@ -137,7 +137,7 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
137 root = filepath.Join(ZutHome(), "subagents")137 root = filepath.Join(ZutHome(), "subagents")
138 rt.root = root138 rt.root = root
139 }139 }
140- rt.resident = subagents.NewResidentManagerWithPolicy(root, cfg.Policy, func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {140+ rt.resident = subagents.NewResidentManagerWithPolicy(root, cfg.Policy, func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
141 return newResidentChildRunner(residentChildArgs(rt.args, rt.credentialProvider, spec), spec, journal)141 return newResidentChildRunner(residentChildArgs(rt.args, rt.credentialProvider, spec), spec, journal)
142 })142 })
143 rt.resident.SetCompletionObserver(cfg.ResidentCompletion)143 rt.resident.SetCompletionObserver(cfg.ResidentCompletion)
@@ -492,6 +492,12 @@ func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
492 Enabled: func() bool { return true },492 Enabled: func() bool { return true },
493 }493 }
494 }494 }
495+ if autoSubagentsInterruptToolAllowed(rt.args) {
496+ facade.Interrupt = &tools.SubagentInterruptTool{
497+ ResidentManager: rt.residentManagerForTools(),
498+ Enabled: func() bool { return true },
499+ }
500+ }
495 reg[facade.Name()] = facade501 reg[facade.Name()] = facade
496 return reg502 return reg
497 }503 }
M packages/agent/subagent_runtime_test.go +12 -6
@@ -354,17 +354,23 @@ func TestInjectToolsRegistersOneSubagentFacade(t *testing.T) {
354354 return Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true}
355355 }
356356 cases := []struct {
357- name string
358- args Args
359- spawn, status, stop, resume bool
357+ name string
358+ args Args
359+ spawn, status, stop, resume, interrupt bool
360360 }{
361- {name: "default grants every action", args: cwdArgs(), spawn: true, status: true, stop: true, resume: true},
361+ {name: "default grants every action", args: cwdArgs(), spawn: true, status: true, stop: true, resume: true, interrupt: true},
362362 {name: "resume suffix grants resume only", args: func() Args {
363363 args := cwdArgs()
364364 args.ToolsSet = true
365365 args.Tools = []string{"subagent:resume"}
366366 return args
367367 }(), resume: true},
368+ {name: "interrupt suffix grants interrupt only", args: func() Args {
369+ args := cwdArgs()
370+ args.ToolsSet = true
371+ args.Tools = []string{"subagent:interrupt"}
372+ return args
373+ }(), interrupt: true},
368374 }
369375 legacy := []string{"subagent_spawn", "subagent_status", "subagent_stop", "subagent_resume"}
370376 for _, tc := range cases {
@@ -388,8 +394,8 @@ func TestInjectToolsRegistersOneSubagentFacade(t *testing.T) {
388394 if !ok {
389395 t.Fatalf("registry entry %q = %T, want *tools.SubagentTool", tools.SubagentToolName, tool)
390396 }
391- got := [4]bool{facade.Spawn != nil, facade.Status != nil, facade.Stop != nil, facade.Resume != nil}
392- want := [4]bool{tc.spawn, tc.status, tc.stop, tc.resume}
397+ got := [5]bool{facade.Spawn != nil, facade.Status != nil, facade.Stop != nil, facade.Resume != nil, facade.Interrupt != nil}
398+ want := [5]bool{tc.spawn, tc.status, tc.stop, tc.resume, tc.interrupt}
393399 if got != want {
394400 t.Fatalf("facade actions = %v, want %v", got, want)
395401 }
@@ -354,17 +354,23 @@ func TestInjectToolsRegistersOneSubagentFacade(t *testing.T) {
354 return Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true}354 return Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true}
355 }355 }
356 cases := []struct {356 cases := []struct {
357- name string357+ name string
358- args Args358+ args Args
359- spawn, status, stop, resume bool359+ spawn, status, stop, resume, interrupt bool
360 }{360 }{
361- {name: "default grants every action", args: cwdArgs(), spawn: true, status: true, stop: true, resume: true},361+ {name: "default grants every action", args: cwdArgs(), spawn: true, status: true, stop: true, resume: true, interrupt: true},
362 {name: "resume suffix grants resume only", args: func() Args {362 {name: "resume suffix grants resume only", args: func() Args {
363 args := cwdArgs()363 args := cwdArgs()
364 args.ToolsSet = true364 args.ToolsSet = true
365 args.Tools = []string{"subagent:resume"}365 args.Tools = []string{"subagent:resume"}
366 return args366 return args
367 }(), resume: true},367 }(), resume: true},
368+ {name: "interrupt suffix grants interrupt only", args: func() Args {
369+ args := cwdArgs()
370+ args.ToolsSet = true
371+ args.Tools = []string{"subagent:interrupt"}
372+ return args
373+ }(), interrupt: true},
368 }374 }
369 legacy := []string{"subagent_spawn", "subagent_status", "subagent_stop", "subagent_resume"}375 legacy := []string{"subagent_spawn", "subagent_status", "subagent_stop", "subagent_resume"}
370 for _, tc := range cases {376 for _, tc := range cases {
@@ -388,8 +394,8 @@ func TestInjectToolsRegistersOneSubagentFacade(t *testing.T) {
388 if !ok {394 if !ok {
389 t.Fatalf("registry entry %q = %T, want *tools.SubagentTool", tools.SubagentToolName, tool)395 t.Fatalf("registry entry %q = %T, want *tools.SubagentTool", tools.SubagentToolName, tool)
390 }396 }
391- got := [4]bool{facade.Spawn != nil, facade.Status != nil, facade.Stop != nil, facade.Resume != nil}397+ got := [5]bool{facade.Spawn != nil, facade.Status != nil, facade.Stop != nil, facade.Resume != nil, facade.Interrupt != nil}
392- want := [4]bool{tc.spawn, tc.status, tc.stop, tc.resume}398+ want := [5]bool{tc.spawn, tc.status, tc.stop, tc.resume, tc.interrupt}
393 if got != want {399 if got != want {
394 t.Fatalf("facade actions = %v, want %v", got, want)400 t.Fatalf("facade actions = %v, want %v", got, want)
395 }401 }
A packages/agent/subagent_steer_test.go +104 -0
@@ -0,0 +1,104 @@
1+package agent
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+ "net/http/httptest"
7+ "strings"
8+ "sync"
9+ "sync/atomic"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/zut/packages/agent/subagents"
14+ "github.com/bnema/zut/packages/provider"
15+)
16+
17+// A follow-up steered while the child waits on the provider reaches the next
18+// model request of the same turn and is persisted as a user message, so the
19+// child reads it without waiting for its turn to end (issue 226).
20+func TestResidentAgentRuntimeDeliversSteerMidTurn(t *testing.T) {
21+ firstRequest := make(chan struct{})
22+ releaseFirst := make(chan struct{})
23+ var requests atomic.Int32
24+ var mu sync.Mutex
25+ var bodies []string
26+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+ number := requests.Add(1)
28+ var body map[string]any
29+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
30+ t.Errorf("decode request: %v", err)
31+ }
32+ encoded, _ := json.Marshal(body["messages"])
33+ mu.Lock()
34+ bodies = append(bodies, string(encoded))
35+ mu.Unlock()
36+ w.Header().Set("content-type", "text/event-stream")
37+ if number == 1 {
38+ close(firstRequest)
39+ <-releaseFirst
40+ writeOpenAIChunk(t, w, map[string]any{
41+ "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{map[string]any{
42+ "index": 0, "id": "call-1", "type": "function",
43+ "function": map[string]any{"name": "read", "arguments": `{"path":"missing.txt"}`},
44+ }}}, "finish_reason": "tool_calls"}},
45+ })
46+ return
47+ }
48+ writeOpenAIChunk(t, w, map[string]any{
49+ "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "wrapped up"}, "finish_reason": "stop"}},
50+ })
51+ }))
52+ defer server.Close()
53+
54+ runtime, journal, _ := newResidentChildTestRunner(t, "steer-child", Args{
55+ Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL,
56+ CWD: t.TempDir(), NoContextFiles: true, NoSkill: true,
57+ })
58+ if runtime.Steer("too early") {
59+ t.Fatal("Steer accepted a follow-up with no running turn")
60+ }
61+ done := make(chan error, 1)
62+ go func() { done <- runtime.Run(t.Context(), "investigate") }()
63+ select {
64+ case <-firstRequest:
65+ case <-time.After(5 * time.Second):
66+ t.Fatal("first provider request did not arrive")
67+ }
68+ if !runtime.Steer("stop investigating and report now") {
69+ t.Fatal("Steer rejected a follow-up during a running turn")
70+ }
71+ if got := runtime.PendingSteers(); got != 1 {
72+ t.Fatalf("pending steers = %d, want 1", got)
73+ }
74+ close(releaseFirst)
75+ if err := <-done; err != nil {
76+ t.Fatalf("Run: %v", err)
77+ }
78+ if got := runtime.PendingSteers(); got != 0 {
79+ t.Fatalf("pending steers after turn = %d, want 0", got)
80+ }
81+ mu.Lock()
82+ defer mu.Unlock()
83+ if len(bodies) != 2 || !strings.Contains(bodies[1], "stop investigating and report now") {
84+ t.Fatalf("provider requests = %#v, want the steer in the second request", bodies)
85+ }
86+ messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ var users []string
91+ for _, message := range messages {
92+ if message.Role != provider.RoleUser {
93+ continue
94+ }
95+ for _, content := range message.Content {
96+ if text, ok := content.(provider.TextBlock); ok {
97+ users = append(users, text.Text)
98+ }
99+ }
100+ }
101+ if len(users) != 2 || users[1] != "stop investigating and report now" {
102+ t.Fatalf("journaled user messages = %#v", users)
103+ }
104+}
@@ -0,0 +1,104 @@
1+package agent
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+ "net/http/httptest"
7+ "strings"
8+ "sync"
9+ "sync/atomic"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/zut/packages/agent/subagents"
14+ "github.com/bnema/zut/packages/provider"
15+)
16+
17+// A follow-up steered while the child waits on the provider reaches the next
18+// model request of the same turn and is persisted as a user message, so the
19+// child reads it without waiting for its turn to end (issue 226).
20+func TestResidentAgentRuntimeDeliversSteerMidTurn(t *testing.T) {
21+ firstRequest := make(chan struct{})
22+ releaseFirst := make(chan struct{})
23+ var requests atomic.Int32
24+ var mu sync.Mutex
25+ var bodies []string
26+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+ number := requests.Add(1)
28+ var body map[string]any
29+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
30+ t.Errorf("decode request: %v", err)
31+ }
32+ encoded, _ := json.Marshal(body["messages"])
33+ mu.Lock()
34+ bodies = append(bodies, string(encoded))
35+ mu.Unlock()
36+ w.Header().Set("content-type", "text/event-stream")
37+ if number == 1 {
38+ close(firstRequest)
39+ <-releaseFirst
40+ writeOpenAIChunk(t, w, map[string]any{
41+ "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{map[string]any{
42+ "index": 0, "id": "call-1", "type": "function",
43+ "function": map[string]any{"name": "read", "arguments": `{"path":"missing.txt"}`},
44+ }}}, "finish_reason": "tool_calls"}},
45+ })
46+ return
47+ }
48+ writeOpenAIChunk(t, w, map[string]any{
49+ "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "wrapped up"}, "finish_reason": "stop"}},
50+ })
51+ }))
52+ defer server.Close()
53+
54+ runtime, journal, _ := newResidentChildTestRunner(t, "steer-child", Args{
55+ Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL,
56+ CWD: t.TempDir(), NoContextFiles: true, NoSkill: true,
57+ })
58+ if runtime.Steer("too early") {
59+ t.Fatal("Steer accepted a follow-up with no running turn")
60+ }
61+ done := make(chan error, 1)
62+ go func() { done <- runtime.Run(t.Context(), "investigate") }()
63+ select {
64+ case <-firstRequest:
65+ case <-time.After(5 * time.Second):
66+ t.Fatal("first provider request did not arrive")
67+ }
68+ if !runtime.Steer("stop investigating and report now") {
69+ t.Fatal("Steer rejected a follow-up during a running turn")
70+ }
71+ if got := runtime.PendingSteers(); got != 1 {
72+ t.Fatalf("pending steers = %d, want 1", got)
73+ }
74+ close(releaseFirst)
75+ if err := <-done; err != nil {
76+ t.Fatalf("Run: %v", err)
77+ }
78+ if got := runtime.PendingSteers(); got != 0 {
79+ t.Fatalf("pending steers after turn = %d, want 0", got)
80+ }
81+ mu.Lock()
82+ defer mu.Unlock()
83+ if len(bodies) != 2 || !strings.Contains(bodies[1], "stop investigating and report now") {
84+ t.Fatalf("provider requests = %#v, want the steer in the second request", bodies)
85+ }
86+ messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ var users []string
91+ for _, message := range messages {
92+ if message.Role != provider.RoleUser {
93+ continue
94+ }
95+ for _, content := range message.Content {
96+ if text, ok := content.(provider.TextBlock); ok {
97+ users = append(users, text.Text)
98+ }
99+ }
100+ }
101+ if len(users) != 2 || users[1] != "stop investigating and report now" {
102+ t.Fatalf("journaled user messages = %#v", users)
103+ }
104+}
M packages/agent/subagent_tool_policy_test.go +15 -12
@@ -11,18 +11,20 @@ import (
1111 // and every pre-existing disable path still disables all of them.
1212 func TestSubagentActionAllowedMatrix(t *testing.T) {
1313 cases := []struct {
14- name string
15- args Args
16- spawn bool
17- status bool
18- stop bool
19- resume bool
14+ name string
15+ args Args
16+ spawn bool
17+ status bool
18+ stop bool
19+ resume bool
20+ interrupt bool
2021 }{
21- {name: "default grants every action", args: Args{}, spawn: true, status: true, stop: true, resume: true},
22+ {name: "default grants every action", args: Args{}, spawn: true, status: true, stop: true, resume: true, interrupt: true},
2223 {name: "unrelated allowlist denies all", args: Args{ToolsSet: true, Tools: []string{"read"}}},
23- {name: "bare subagent grants every action", args: Args{ToolsSet: true, Tools: []string{"subagent"}}, spawn: true, status: true, stop: true, resume: true},
24- {name: "bare subagent wins beside a suffix", args: Args{ToolsSet: true, Tools: []string{"read", "subagent:resume", "subagent"}}, spawn: true, status: true, stop: true, resume: true},
24+ {name: "bare subagent grants every action", args: Args{ToolsSet: true, Tools: []string{"subagent"}}, spawn: true, status: true, stop: true, resume: true, interrupt: true},
25+ {name: "bare subagent wins beside a suffix", args: Args{ToolsSet: true, Tools: []string{"read", "subagent:resume", "subagent"}}, spawn: true, status: true, stop: true, resume: true, interrupt: true},
2526 {name: "one action suffix grants one action", args: Args{ToolsSet: true, Tools: []string{"subagent:resume"}}, resume: true},
27+ {name: "interrupt suffix grants interrupt only", args: Args{ToolsSet: true, Tools: []string{"subagent:interrupt"}}, interrupt: true},
2628 {name: "two action suffixes grant two actions", args: Args{ToolsSet: true, Tools: []string{"subagent:spawn", "subagent:stop"}}, spawn: true, stop: true},
2729 {name: "unknown action suffix denies all", args: Args{ToolsSet: true, Tools: []string{"subagent:bogus"}}},
2830 {name: "empty allowlist denies all", args: Args{ToolsSet: true, Tools: []string{}}},
@@ -31,17 +33,18 @@ func TestSubagentActionAllowedMatrix(t *testing.T) {
3133 }
3234 for _, tc := range cases {
3335 t.Run(tc.name, func(t *testing.T) {
34- got := [4]bool{
36+ got := [5]bool{
3537 autoSubagentsToolAllowed(tc.args),
3638 autoSubagentsStatusToolAllowed(tc.args),
3739 autoSubagentsStopToolAllowed(tc.args),
3840 autoSubagentsResumeToolAllowed(tc.args),
41+ autoSubagentsInterruptToolAllowed(tc.args),
3942 }
40- want := [4]bool{tc.spawn, tc.status, tc.stop, tc.resume}
43+ want := [5]bool{tc.spawn, tc.status, tc.stop, tc.resume, tc.interrupt}
4144 if got != want {
4245 t.Fatalf("actions = %v, want %v (args %+v)", got, want, tc.args)
4346 }
44- wantAny := tc.spawn || tc.status || tc.stop || tc.resume
47+ wantAny := tc.spawn || tc.status || tc.stop || tc.resume || tc.interrupt
4548 if any := autoSubagentsAnyToolAllowed(tc.args); any != wantAny {
4649 t.Fatalf("autoSubagentsAnyToolAllowed = %v, want %v (args %+v)", any, wantAny, tc.args)
4750 }
@@ -11,18 +11,20 @@ import (
11 // and every pre-existing disable path still disables all of them.11 // and every pre-existing disable path still disables all of them.
12 func TestSubagentActionAllowedMatrix(t *testing.T) {12 func TestSubagentActionAllowedMatrix(t *testing.T) {
13 cases := []struct {13 cases := []struct {
14- name string14+ name string
15- args Args15+ args Args
16- spawn bool16+ spawn bool
17- status bool17+ status bool
18- stop bool18+ stop bool
19- resume bool19+ resume bool
20+ interrupt bool
20 }{21 }{
21- {name: "default grants every action", args: Args{}, spawn: true, status: true, stop: true, resume: true},22+ {name: "default grants every action", args: Args{}, spawn: true, status: true, stop: true, resume: true, interrupt: true},
22 {name: "unrelated allowlist denies all", args: Args{ToolsSet: true, Tools: []string{"read"}}},23 {name: "unrelated allowlist denies all", args: Args{ToolsSet: true, Tools: []string{"read"}}},
23- {name: "bare subagent grants every action", args: Args{ToolsSet: true, Tools: []string{"subagent"}}, spawn: true, status: true, stop: true, resume: true},24+ {name: "bare subagent grants every action", args: Args{ToolsSet: true, Tools: []string{"subagent"}}, spawn: true, status: true, stop: true, resume: true, interrupt: true},
24- {name: "bare subagent wins beside a suffix", args: Args{ToolsSet: true, Tools: []string{"read", "subagent:resume", "subagent"}}, spawn: true, status: true, stop: true, resume: true},25+ {name: "bare subagent wins beside a suffix", args: Args{ToolsSet: true, Tools: []string{"read", "subagent:resume", "subagent"}}, spawn: true, status: true, stop: true, resume: true, interrupt: true},
25 {name: "one action suffix grants one action", args: Args{ToolsSet: true, Tools: []string{"subagent:resume"}}, resume: true},26 {name: "one action suffix grants one action", args: Args{ToolsSet: true, Tools: []string{"subagent:resume"}}, resume: true},
27+ {name: "interrupt suffix grants interrupt only", args: Args{ToolsSet: true, Tools: []string{"subagent:interrupt"}}, interrupt: true},
26 {name: "two action suffixes grant two actions", args: Args{ToolsSet: true, Tools: []string{"subagent:spawn", "subagent:stop"}}, spawn: true, stop: true},28 {name: "two action suffixes grant two actions", args: Args{ToolsSet: true, Tools: []string{"subagent:spawn", "subagent:stop"}}, spawn: true, stop: true},
27 {name: "unknown action suffix denies all", args: Args{ToolsSet: true, Tools: []string{"subagent:bogus"}}},29 {name: "unknown action suffix denies all", args: Args{ToolsSet: true, Tools: []string{"subagent:bogus"}}},
28 {name: "empty allowlist denies all", args: Args{ToolsSet: true, Tools: []string{}}},30 {name: "empty allowlist denies all", args: Args{ToolsSet: true, Tools: []string{}}},
@@ -31,17 +33,18 @@ func TestSubagentActionAllowedMatrix(t *testing.T) {
31 }33 }
32 for _, tc := range cases {34 for _, tc := range cases {
33 t.Run(tc.name, func(t *testing.T) {35 t.Run(tc.name, func(t *testing.T) {
34- got := [4]bool{36+ got := [5]bool{
35 autoSubagentsToolAllowed(tc.args),37 autoSubagentsToolAllowed(tc.args),
36 autoSubagentsStatusToolAllowed(tc.args),38 autoSubagentsStatusToolAllowed(tc.args),
37 autoSubagentsStopToolAllowed(tc.args),39 autoSubagentsStopToolAllowed(tc.args),
38 autoSubagentsResumeToolAllowed(tc.args),40 autoSubagentsResumeToolAllowed(tc.args),
41+ autoSubagentsInterruptToolAllowed(tc.args),
39 }42 }
40- want := [4]bool{tc.spawn, tc.status, tc.stop, tc.resume}43+ want := [5]bool{tc.spawn, tc.status, tc.stop, tc.resume, tc.interrupt}
41 if got != want {44 if got != want {
42 t.Fatalf("actions = %v, want %v (args %+v)", got, want, tc.args)45 t.Fatalf("actions = %v, want %v (args %+v)", got, want, tc.args)
43 }46 }
44- wantAny := tc.spawn || tc.status || tc.stop || tc.resume47+ wantAny := tc.spawn || tc.status || tc.stop || tc.resume || tc.interrupt
45 if any := autoSubagentsAnyToolAllowed(tc.args); any != wantAny {48 if any := autoSubagentsAnyToolAllowed(tc.args); any != wantAny {
46 t.Fatalf("autoSubagentsAnyToolAllowed = %v, want %v (args %+v)", any, wantAny, tc.args)49 t.Fatalf("autoSubagentsAnyToolAllowed = %v, want %v (args %+v)", any, wantAny, tc.args)
47 }50 }
M packages/agent/subagents/completion.go +46 -1
@@ -16,12 +16,31 @@ type Completion struct {
1616 Task string
1717 Error string
1818 Summary string
19+ // Undelivered lists follow-ups steered into the turn that the child never
20+ // read because the turn ended first.
21+ Undelivered []string
1922 }
2023
24+// CompletionDropped is the status of a queued follow-up that was discarded
25+// before the child ever started it.
26+const CompletionDropped = "dropped"
27+
28+// maxUndeliveredPreview bounds each undelivered follow-up in a parent update.
29+const maxUndeliveredPreview = 120
30+
2131 // Completion projects a typed resident outcome into the parent notification
2232 // shared by interactive and orchestrated modes.
2333 func (c ResidentCompletion) Completion() Completion {
24- result := Completion{AgentID: c.ChildID, TurnID: c.TurnID, Status: string(ResidentCompleted), Task: c.Task, Summary: c.Summary}
34+ result := Completion{AgentID: c.ChildID, TurnID: c.TurnID, Status: string(ResidentCompleted), Task: c.Task, Summary: c.Summary, Undelivered: append([]string(nil), c.Undelivered...)}
35+ if c.NotStarted {
36+ result.Status = CompletionDropped
37+ // Cancellation is the expected reason; any other error, such as a
38+ // journal failure, explains why the follow-up could not run.
39+ if c.Err != nil && !errors.Is(c.Err, context.Canceled) {
40+ result.Error = c.Err.Error()
41+ }
42+ return result
43+ }
2544 if c.Err != nil {
2645 result.Status, result.Error = string(ResidentFailed), c.Err.Error()
2746 if errors.Is(c.Err, context.Canceled) {
@@ -161,12 +180,28 @@ func FormatCompletionUpdateWithActive(batch []Completion, activeAgentIDs []strin
161180 b.WriteString("[auto-subagents update]\n")
162181 for _, completion := range batch {
163182 fmt.Fprintf(&b, "- %s: %s", completion.AgentID, completion.Status)
183+ if completion.Status == CompletionDropped {
184+ // The child never read this follow-up. Echoing it as a task would
185+ // read as work the child attempted.
186+ fmt.Fprintf(&b, " (queued follow-up never started: %s)", undeliveredPreview(completion.Task))
187+ if completion.Error != "" {
188+ fmt.Fprintf(&b, " (%s)", completion.Error)
189+ }
190+ b.WriteByte('\n')
191+ continue
192+ }
164193 if completion.Error != "" {
165194 fmt.Fprintf(&b, " (%s)", completion.Error)
166195 }
167196 if completion.Task != "" {
168197 fmt.Fprintf(&b, " — %s", completion.Task)
169198 }
199+ if len(completion.Undelivered) != 0 {
200+ fmt.Fprintf(&b, "\n undelivered: %d follow-up(s) the child never read:", len(completion.Undelivered))
201+ for _, text := range completion.Undelivered {
202+ fmt.Fprintf(&b, "\n - %s", undeliveredPreview(text))
203+ }
204+ }
170205 if completion.Summary != "" {
171206 label := "final"
172207 if completion.Status != string(ResidentCompleted) {
@@ -188,3 +223,13 @@ func FormatCompletionUpdateWithActive(batch []Completion, activeAgentIDs []strin
188223 }
189224 return strings.TrimSpace(b.String())
190225 }
226+
227+// undeliveredPreview renders one follow-up on a single bounded line.
228+func undeliveredPreview(text string) string {
229+ text = strings.Join(strings.Fields(text), " ")
230+ runes := []rune(text)
231+ if len(runes) <= maxUndeliveredPreview {
232+ return text
233+ }
234+ return strings.TrimSpace(string(runes[:maxUndeliveredPreview-1])) + "…"
235+}
@@ -16,12 +16,31 @@ type Completion struct {
16 Task string16 Task string
17 Error string17 Error string
18 Summary string18 Summary string
19+ // Undelivered lists follow-ups steered into the turn that the child never
20+ // read because the turn ended first.
21+ Undelivered []string
19 }22 }
20 23
24+// CompletionDropped is the status of a queued follow-up that was discarded
25+// before the child ever started it.
26+const CompletionDropped = "dropped"
27+
28+// maxUndeliveredPreview bounds each undelivered follow-up in a parent update.
29+const maxUndeliveredPreview = 120
30+
21 // Completion projects a typed resident outcome into the parent notification31 // Completion projects a typed resident outcome into the parent notification
22 // shared by interactive and orchestrated modes.32 // shared by interactive and orchestrated modes.
23 func (c ResidentCompletion) Completion() Completion {33 func (c ResidentCompletion) Completion() Completion {
24- result := Completion{AgentID: c.ChildID, TurnID: c.TurnID, Status: string(ResidentCompleted), Task: c.Task, Summary: c.Summary}34+ result := Completion{AgentID: c.ChildID, TurnID: c.TurnID, Status: string(ResidentCompleted), Task: c.Task, Summary: c.Summary, Undelivered: append([]string(nil), c.Undelivered...)}
35+ if c.NotStarted {
36+ result.Status = CompletionDropped
37+ // Cancellation is the expected reason; any other error, such as a
38+ // journal failure, explains why the follow-up could not run.
39+ if c.Err != nil && !errors.Is(c.Err, context.Canceled) {
40+ result.Error = c.Err.Error()
41+ }
42+ return result
43+ }
25 if c.Err != nil {44 if c.Err != nil {
26 result.Status, result.Error = string(ResidentFailed), c.Err.Error()45 result.Status, result.Error = string(ResidentFailed), c.Err.Error()
27 if errors.Is(c.Err, context.Canceled) {46 if errors.Is(c.Err, context.Canceled) {
@@ -161,12 +180,28 @@ func FormatCompletionUpdateWithActive(batch []Completion, activeAgentIDs []strin
161 b.WriteString("[auto-subagents update]\n")180 b.WriteString("[auto-subagents update]\n")
162 for _, completion := range batch {181 for _, completion := range batch {
163 fmt.Fprintf(&b, "- %s: %s", completion.AgentID, completion.Status)182 fmt.Fprintf(&b, "- %s: %s", completion.AgentID, completion.Status)
183+ if completion.Status == CompletionDropped {
184+ // The child never read this follow-up. Echoing it as a task would
185+ // read as work the child attempted.
186+ fmt.Fprintf(&b, " (queued follow-up never started: %s)", undeliveredPreview(completion.Task))
187+ if completion.Error != "" {
188+ fmt.Fprintf(&b, " (%s)", completion.Error)
189+ }
190+ b.WriteByte('\n')
191+ continue
192+ }
164 if completion.Error != "" {193 if completion.Error != "" {
165 fmt.Fprintf(&b, " (%s)", completion.Error)194 fmt.Fprintf(&b, " (%s)", completion.Error)
166 }195 }
167 if completion.Task != "" {196 if completion.Task != "" {
168 fmt.Fprintf(&b, " — %s", completion.Task)197 fmt.Fprintf(&b, " — %s", completion.Task)
169 }198 }
199+ if len(completion.Undelivered) != 0 {
200+ fmt.Fprintf(&b, "\n undelivered: %d follow-up(s) the child never read:", len(completion.Undelivered))
201+ for _, text := range completion.Undelivered {
202+ fmt.Fprintf(&b, "\n - %s", undeliveredPreview(text))
203+ }
204+ }
170 if completion.Summary != "" {205 if completion.Summary != "" {
171 label := "final"206 label := "final"
172 if completion.Status != string(ResidentCompleted) {207 if completion.Status != string(ResidentCompleted) {
@@ -188,3 +223,13 @@ func FormatCompletionUpdateWithActive(batch []Completion, activeAgentIDs []strin
188 }223 }
189 return strings.TrimSpace(b.String())224 return strings.TrimSpace(b.String())
190 }225 }
226+
227+// undeliveredPreview renders one follow-up on a single bounded line.
228+func undeliveredPreview(text string) string {
229+ text = strings.Join(strings.Fields(text), " ")
230+ runes := []rune(text)
231+ if len(runes) <= maxUndeliveredPreview {
232+ return text
233+ }
234+ return strings.TrimSpace(string(runes[:maxUndeliveredPreview-1])) + "…"
235+}
M packages/agent/subagents/completion_test.go +17 -1
@@ -3,6 +3,7 @@ package subagents
33 import (
44 "context"
55 "errors"
6+ "reflect"
67 "strings"
78 "testing"
89 )
@@ -38,6 +39,21 @@ func TestResidentCompletionProjection(t *testing.T) {
3839 }
3940 }
4041
42+// A dropped follow-up hides expected cancellation but keeps any other cause.
43+func TestDroppedCompletionKeepsNonCancellationError(t *testing.T) {
44+ canceled := (ResidentCompletion{ChildID: "c", TurnID: "t", Task: "next", Err: context.Canceled, NotStarted: true}).Completion()
45+ if canceled.Status != CompletionDropped || canceled.Error != "" {
46+ t.Fatalf("canceled drop = %#v", canceled)
47+ }
48+ failed := (ResidentCompletion{ChildID: "c", TurnID: "t", Task: "next", Err: errors.New("journal full"), NotStarted: true}).Completion()
49+ if failed.Status != CompletionDropped || failed.Error != "journal full" {
50+ t.Fatalf("failed drop = %#v", failed)
51+ }
52+ if text := FormatCompletionUpdate([]Completion{failed}, ""); !strings.Contains(text, "dropped (queued follow-up never started: next) (journal full)") {
53+ t.Fatalf("update = %q", text)
54+ }
55+}
56+
4157 func TestFormatCompletionUpdateIncludesFinalSummary(t *testing.T) {
4258 got := FormatCompletionUpdate([]Completion{{AgentID: "child", Status: "completed", Task: "review", Summary: "found the regression"}}, "")
4359 if !strings.Contains(got, "final: found the regression") {
@@ -85,7 +101,7 @@ func TestCompletionTrackerWaitReadyDoesNotWaitForSiblings(t *testing.T) {
85101 ctx, cancel := context.WithCancel(context.Background())
86102 cancel()
87103 got, err := tracker.WaitReady(ctx)
88- if err != nil || len(got) != 1 || got[0] != want {
104+ if err != nil || len(got) != 1 || !reflect.DeepEqual(got[0], want) {
89105 t.Fatalf("WaitReady = %#v, %v, want ready result despite pending sibling", got, err)
90106 }
91107 if _, err := tracker.WaitReady(ctx); !errors.Is(err, context.Canceled) {
@@ -3,6 +3,7 @@ package subagents
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "reflect"
6 "strings"7 "strings"
7 "testing"8 "testing"
8 )9 )
@@ -38,6 +39,21 @@ func TestResidentCompletionProjection(t *testing.T) {
38 }39 }
39 }40 }
40 41
42+// A dropped follow-up hides expected cancellation but keeps any other cause.
43+func TestDroppedCompletionKeepsNonCancellationError(t *testing.T) {
44+ canceled := (ResidentCompletion{ChildID: "c", TurnID: "t", Task: "next", Err: context.Canceled, NotStarted: true}).Completion()
45+ if canceled.Status != CompletionDropped || canceled.Error != "" {
46+ t.Fatalf("canceled drop = %#v", canceled)
47+ }
48+ failed := (ResidentCompletion{ChildID: "c", TurnID: "t", Task: "next", Err: errors.New("journal full"), NotStarted: true}).Completion()
49+ if failed.Status != CompletionDropped || failed.Error != "journal full" {
50+ t.Fatalf("failed drop = %#v", failed)
51+ }
52+ if text := FormatCompletionUpdate([]Completion{failed}, ""); !strings.Contains(text, "dropped (queued follow-up never started: next) (journal full)") {
53+ t.Fatalf("update = %q", text)
54+ }
55+}
56+
41 func TestFormatCompletionUpdateIncludesFinalSummary(t *testing.T) {57 func TestFormatCompletionUpdateIncludesFinalSummary(t *testing.T) {
42 got := FormatCompletionUpdate([]Completion{{AgentID: "child", Status: "completed", Task: "review", Summary: "found the regression"}}, "")58 got := FormatCompletionUpdate([]Completion{{AgentID: "child", Status: "completed", Task: "review", Summary: "found the regression"}}, "")
43 if !strings.Contains(got, "final: found the regression") {59 if !strings.Contains(got, "final: found the regression") {
@@ -85,7 +101,7 @@ func TestCompletionTrackerWaitReadyDoesNotWaitForSiblings(t *testing.T) {
85 ctx, cancel := context.WithCancel(context.Background())101 ctx, cancel := context.WithCancel(context.Background())
86 cancel()102 cancel()
87 got, err := tracker.WaitReady(ctx)103 got, err := tracker.WaitReady(ctx)
88- if err != nil || len(got) != 1 || got[0] != want {104+ if err != nil || len(got) != 1 || !reflect.DeepEqual(got[0], want) {
89 t.Fatalf("WaitReady = %#v, %v, want ready result despite pending sibling", got, err)105 t.Fatalf("WaitReady = %#v, %v, want ready result despite pending sibling", got, err)
90 }106 }
91 if _, err := tracker.WaitReady(ctx); !errors.Is(err, context.Canceled) {107 if _, err := tracker.WaitReady(ctx); !errors.Is(err, context.Canceled) {
M packages/agent/subagents/resident_cancellation_test.go +2 -2
@@ -11,8 +11,8 @@ func TestResidentCancellationPreservesInterruptedState(t *testing.T) {
1111 for _, turnErr := range []error{context.Canceled, errors.Join(context.Canceled, errors.New("worker failed"))} {
1212 t.Run(turnErr.Error(), func(t *testing.T) {
1313 root := t.TempDir()
14- manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
15- return func(context.Context, string) error { return turnErr }, nil
14+ manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
15+ return ResidentTurnRunner(func(context.Context, string) error { return turnErr }), nil
1616 })
1717 t.Cleanup(func() { _ = manager.Close(context.Background()) })
1818 spec := ResidentChildSpec{ID: "canceled", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true}
@@ -11,8 +11,8 @@ func TestResidentCancellationPreservesInterruptedState(t *testing.T) {
11 for _, turnErr := range []error{context.Canceled, errors.Join(context.Canceled, errors.New("worker failed"))} {11 for _, turnErr := range []error{context.Canceled, errors.Join(context.Canceled, errors.New("worker failed"))} {
12 t.Run(turnErr.Error(), func(t *testing.T) {12 t.Run(turnErr.Error(), func(t *testing.T) {
13 root := t.TempDir()13 root := t.TempDir()
14- manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {14+ manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
15- return func(context.Context, string) error { return turnErr }, nil15+ return ResidentTurnRunner(func(context.Context, string) error { return turnErr }), nil
16 })16 })
17 t.Cleanup(func() { _ = manager.Close(context.Background()) })17 t.Cleanup(func() { _ = manager.Close(context.Background()) })
18 spec := ResidentChildSpec{ID: "canceled", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true}18 spec := ResidentChildSpec{ID: "canceled", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true}
M packages/agent/subagents/resident_child.go +191 -32
@@ -12,16 +12,59 @@ import (
1212 "github.com/google/uuid"
1313 )
1414
15-// ResidentTurnRunner executes one already-accepted prompt. The child owns the
16-// ordering and cancellation boundary; implementations own agent/provider work.
15+// ResidentRuntime owns one child's agent. The child owns turn ordering and
16+// cancellation; the runtime owns agent/provider work.
17+//
18+// Run executes one accepted turn. Steer injects a follow-up into the turn Run
19+// is executing and reports false when no turn can still receive it; a true
20+// result guarantees Run delivers the follow-up before it returns successfully.
21+// PendingSteers counts follow-ups not yet delivered, and DrainSteers removes
22+// and returns them after a turn ended without delivering them.
23+type ResidentRuntime interface {
24+ Run(ctx context.Context, prompt string) error
25+ Steer(prompt string) bool
26+ PendingSteers() int
27+ DrainSteers() []string
28+}
29+
30+// ResidentTurnRunner is a runtime that runs turns but cannot be steered, so
31+// every follow-up becomes a separate queued turn.
1732 type ResidentTurnRunner func(context.Context, string) error
1833
34+func (r ResidentTurnRunner) Run(ctx context.Context, prompt string) error { return r(ctx, prompt) }
35+func (ResidentTurnRunner) Steer(string) bool { return false }
36+func (ResidentTurnRunner) PendingSteers() int { return 0 }
37+func (ResidentTurnRunner) DrainSteers() []string { return nil }
38+
1939 type residentPrompt struct {
2040 turnID string
2141 prompt string
2242 ack chan error
2343 }
2444
45+type residentControlKind int
46+
47+const (
48+ residentControlSteer residentControlKind = iota + 1
49+ residentControlInterrupt
50+)
51+
52+// residentControl is a request handled by the control goroutine, so it is
53+// ordered with turn start and completion.
54+type residentControl struct {
55+ kind residentControlKind
56+ prompt string
57+ // onSteered runs on the control goroutine before the active turn can
58+ // report its completion, so a caller can subscribe without a race.
59+ onSteered func(turnID string)
60+ reply chan residentControlReply
61+}
62+
63+type residentControlReply struct {
64+ ok bool
65+ turnID string
66+}
67+
2568 type residentTurnResult struct {
2669 turnID string
2770 err error
@@ -34,13 +77,19 @@ type ResidentCompletion struct {
3477 Task string
3578 Err error
3679 Summary string
80+ // NotStarted marks a queued follow-up that was dropped before the child
81+ // ever ran it, for example because the child was stopped or interrupted.
82+ NotStarted bool
83+ // Undelivered lists follow-ups steered into this turn that the child never
84+ // read because the turn ended first.
85+ Undelivered []string
3786 }
3887
3988 // ResidentChild serializes prompt execution through one control goroutine.
4089 // No mutex is held while a runner performs provider or tool I/O. When a
4190 // journal is configured, every state boundary is committed by that goroutine.
4291 type ResidentChild struct {
43- runner ResidentTurnRunner
92+ runtime ResidentRuntime
4493 journal *ResidentJournal
4594 workspace WorkspaceHandle
4695 spec ResidentChildSpec
@@ -48,11 +97,12 @@ type ResidentChild struct {
4897 onUpdate func(historyChanged bool)
4998 onStateChange func(ResidentState)
5099
51- ctx context.Context
52- cancel context.CancelFunc
53- inbox chan residentPrompt
54- done chan struct{}
55- once sync.Once
100+ ctx context.Context
101+ cancel context.CancelFunc
102+ inbox chan residentPrompt
103+ control chan residentControl
104+ done chan struct{}
105+ once sync.Once
56106
57107 mu sync.RWMutex
58108 state ResidentState
@@ -61,39 +111,41 @@ type ResidentChild struct {
61111 activityUpdatedAt time.Time
62112 live *residentLiveProjection
63113 cleanupWorkspace bool
114+ queuedTurns int
64115 }
65116
66-func NewResidentChild(runner ResidentTurnRunner) *ResidentChild {
67- return newResidentChild(ResidentChildSpec{}, nil, runner)
117+func NewResidentChild(runtime ResidentRuntime) *ResidentChild {
118+ return newResidentChild(ResidentChildSpec{}, nil, runtime)
68119 }
69120
70-func newJournaledResidentChild(spec ResidentChildSpec, journal *ResidentJournal, runner ResidentTurnRunner, onCompletion func(ResidentCompletion)) *ResidentChild {
71- child := newResidentChildWithWorkspace(spec, journal, nil, runner)
121+func newJournaledResidentChild(spec ResidentChildSpec, journal *ResidentJournal, runtime ResidentRuntime, onCompletion func(ResidentCompletion)) *ResidentChild {
122+ child := newResidentChildWithWorkspace(spec, journal, nil, runtime)
72123 child.onCompletion = onCompletion
73124 return child
74125 }
75126
76-func newJournaledResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJournal, workspace WorkspaceHandle, runner ResidentTurnRunner, onCompletion func(ResidentCompletion)) *ResidentChild {
77- child := newResidentChildWithWorkspace(spec, journal, workspace, runner)
127+func newJournaledResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJournal, workspace WorkspaceHandle, runtime ResidentRuntime, onCompletion func(ResidentCompletion)) *ResidentChild {
128+ child := newResidentChildWithWorkspace(spec, journal, workspace, runtime)
78129 child.onCompletion = onCompletion
79130 return child
80131 }
81132
82-func newResidentChild(spec ResidentChildSpec, journal *ResidentJournal, runner ResidentTurnRunner) *ResidentChild {
83- return newResidentChildWithWorkspace(spec, journal, nil, runner)
133+func newResidentChild(spec ResidentChildSpec, journal *ResidentJournal, runtime ResidentRuntime) *ResidentChild {
134+ return newResidentChildWithWorkspace(spec, journal, nil, runtime)
84135 }
85136
86-func newResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJournal, workspace WorkspaceHandle, runner ResidentTurnRunner) *ResidentChild {
137+func newResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJournal, workspace WorkspaceHandle, runtime ResidentRuntime) *ResidentChild {
87138 ctx, cancel := context.WithCancel(context.Background())
88139 now := time.Now().UTC()
89140 child := &ResidentChild{
90- runner: runner,
141+ runtime: runtime,
91142 journal: journal,
92143 workspace: workspace,
93144 spec: spec,
94145 ctx: ctx,
95146 cancel: cancel,
96147 inbox: make(chan residentPrompt),
148+ control: make(chan residentControl),
97149 done: make(chan struct{}),
98150 state: ResidentQueued,
99151 stateUpdatedAt: now,
@@ -183,6 +235,27 @@ func (c *ResidentChild) ActivityUpdatedAt() time.Time {
183235 return c.activityUpdatedAt
184236 }
185237
238+// PendingFollowUps counts follow-ups the child has accepted but not yet read:
239+// queued turns plus messages steered into the running turn.
240+func (c *ResidentChild) PendingFollowUps() int {
241+ if c == nil {
242+ return 0
243+ }
244+ c.mu.RLock()
245+ queued := c.queuedTurns
246+ c.mu.RUnlock()
247+ if c.runtime != nil {
248+ queued += c.runtime.PendingSteers()
249+ }
250+ return queued
251+}
252+
253+func (c *ResidentChild) setQueuedTurns(n int) {
254+ c.mu.Lock()
255+ c.queuedTurns = n
256+ c.mu.Unlock()
257+}
258+
186259 func (c *ResidentChild) setState(state ResidentState) {
187260 c.mu.Lock()
188261 now := time.Now().UTC()
@@ -270,8 +343,42 @@ func (c *ResidentChild) enqueue(ctx context.Context, request residentPrompt) err
270343 return err
271344 }
272345
346+// steer injects a follow-up into the running turn. It reports false, leaving
347+// the child unchanged, when no turn is executing or earlier follow-ups are
348+// still queued; the caller must then queue the follow-up as a new turn.
349+func (c *ResidentChild) steer(ctx context.Context, prompt string, onSteered func(turnID string)) (bool, string, error) {
350+ reply, err := c.sendControl(ctx, residentControl{kind: residentControlSteer, prompt: strings.TrimSpace(prompt), onSteered: onSteered})
351+ return reply.ok, reply.turnID, err
352+}
353+
354+// interruptTurn cancels only the running turn. The child stays alive with its
355+// transcript, and queued follow-ups are dropped. It reports false when no turn
356+// is running.
357+func (c *ResidentChild) interruptTurn(ctx context.Context) (bool, error) {
358+ reply, err := c.sendControl(ctx, residentControl{kind: residentControlInterrupt})
359+ return reply.ok, err
360+}
361+
362+func (c *ResidentChild) sendControl(ctx context.Context, request residentControl) (residentControlReply, error) {
363+ if c == nil || c.runtime == nil {
364+ return residentControlReply{}, errors.New("resident child: no runtime")
365+ }
366+ if ctx == nil {
367+ ctx = context.Background()
368+ }
369+ request.reply = make(chan residentControlReply, 1)
370+ select {
371+ case <-ctx.Done():
372+ return residentControlReply{}, ctx.Err()
373+ case <-c.done:
374+ return residentControlReply{}, errors.New("resident child: closed")
375+ case c.control <- request:
376+ }
377+ return <-request.reply, nil
378+}
379+
273380 func (c *ResidentChild) enqueueAccepted(ctx context.Context, request residentPrompt) (bool, error) {
274- if c == nil || c.runner == nil {
381+ if c == nil || c.runtime == nil {
275382 return false, errors.New("resident child: no runner")
276383 }
277384 if ctx == nil {
@@ -336,10 +443,28 @@ func (c *ResidentChild) run() {
336443
337444 var queue []residentPrompt
338445 var interruptedPrompts []residentPrompt
446+ var droppedPrompts []residentPrompt
339447 results := make(chan residentTurnResult, 1)
340448 var active residentPrompt
341- running, interrupted := false, false
449+ turnCancel := context.CancelFunc(func() {})
450+ defer func() { turnCancel() }()
451+ running, interrupted, turnInterrupted := false, false, false
452+ // dropQueued reports queued follow-ups that never started. Each was
453+ // accepted and tracked, so each still gets exactly one completion, marked
454+ // NotStarted so the parent does not mistake it for interrupted work.
455+ dropQueued := func(err error) {
456+ for _, pending := range queue {
457+ if c.journal != nil {
458+ _ = c.journal.RecordTurnInterrupted(c.spec, pending.turnID)
459+ }
460+ if c.onCompletion != nil {
461+ c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: pending.turnID, Task: pending.prompt, Err: err, NotStarted: true})
462+ }
463+ }
464+ queue = nil
465+ }
342466 for {
467+ c.setQueuedTurns(len(queue))
343468 if !interrupted && !running && len(queue) > 0 {
344469 active, queue = queue[0], queue[1:]
345470 if c.journal != nil {
@@ -358,10 +483,12 @@ func (c *ResidentChild) run() {
358483 }
359484 }
360485 c.startTurn(active.turnID)
361- running = true
362- go func(request residentPrompt) {
363- results <- residentTurnResult{turnID: request.turnID, err: c.runner(c.ctx, request.prompt)}
364- }(active)
486+ running, turnInterrupted = true, false
487+ turnCtx, cancelTurn := context.WithCancel(c.ctx)
488+ turnCancel = cancelTurn
489+ go func(ctx context.Context, request residentPrompt) {
490+ results <- residentTurnResult{turnID: request.turnID, err: c.runtime.Run(ctx, request.prompt)}
491+ }(turnCtx, active)
365492 }
366493
367494 if interrupted && !running {
@@ -369,7 +496,10 @@ func (c *ResidentChild) run() {
369496 c.setState(ResidentInterrupted)
370497 if c.onCompletion != nil {
371498 for _, prompt := range interruptedPrompts {
372- c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: prompt.turnID, Task: prompt.prompt, Err: context.Canceled})
499+ c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: prompt.turnID, Task: prompt.prompt, Err: context.Canceled, Undelivered: c.runtime.DrainSteers()})
500+ }
501+ for _, prompt := range droppedPrompts {
502+ c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: prompt.turnID, Task: prompt.prompt, Err: context.Canceled, NotStarted: true})
373503 }
374504 }
375505 return
@@ -392,12 +522,38 @@ func (c *ResidentChild) run() {
392522 }
393523 }
394524 for _, pending := range queue {
395- interruptedPrompts = append(interruptedPrompts, pending)
525+ droppedPrompts = append(droppedPrompts, pending)
396526 if c.journal != nil {
397527 _ = c.journal.RecordTurnInterrupted(c.spec, pending.turnID)
398528 }
399529 }
400530 queue = nil
531+ c.setQueuedTurns(0)
532+ case request := <-c.control:
533+ switch request.kind {
534+ case residentControlSteer:
535+ // Steering past queued turns would reorder follow-ups, and an
536+ // interrupted turn can no longer read new input.
537+ if running && !interrupted && !turnInterrupted && len(queue) == 0 && request.prompt != "" && c.runtime.Steer(request.prompt) {
538+ if request.onSteered != nil {
539+ request.onSteered(active.turnID)
540+ }
541+ c.notifyUpdate(false)
542+ request.reply <- residentControlReply{ok: true, turnID: active.turnID}
543+ } else {
544+ request.reply <- residentControlReply{}
545+ }
546+ case residentControlInterrupt:
547+ if !running || interrupted || turnInterrupted {
548+ request.reply <- residentControlReply{}
549+ continue
550+ }
551+ turnInterrupted = true
552+ turnCancel()
553+ dropQueued(context.Canceled)
554+ c.setQueuedTurns(0)
555+ request.reply <- residentControlReply{ok: true, turnID: active.turnID}
556+ }
401557 case request := <-c.inbox:
402558 if interrupted {
403559 request.ack <- errors.New("resident child: closed")
@@ -407,10 +563,17 @@ func (c *ResidentChild) run() {
407563 request.ack <- nil
408564 case result := <-results:
409565 running = false
566+ turnCancel()
410567 if interrupted {
411568 continue
412569 }
413570 terminalErr := result.err
571+ if turnInterrupted && terminalErr == nil {
572+ // The turn finished before cancellation reached it; the
573+ // manager still asked for an interruption.
574+ terminalErr = context.Canceled
575+ }
576+ undelivered := c.runtime.DrainSteers()
414577 var capture *WorkspaceCapture
415578 if (terminalErr == nil || !errors.Is(terminalErr, context.Canceled)) && c.workspace != nil && c.workspace.Mode() == WorkspaceWorktree {
416579 captured, err := c.workspace.Capture(c.ctx)
@@ -441,14 +604,10 @@ func (c *ResidentChild) run() {
441604 c.live.Finish(state)
442605 c.setState(state)
443606 if c.onCompletion != nil {
444- c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: result.turnID, Task: active.prompt, Err: terminalErr, Summary: summary})
445- if persistenceFailed {
446- for _, pending := range queue {
447- c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: pending.turnID, Task: pending.prompt, Err: terminalErr})
448- }
449- }
607+ c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: result.turnID, Task: active.prompt, Err: terminalErr, Summary: summary, Undelivered: undelivered})
450608 }
451609 if persistenceFailed {
610+ dropQueued(terminalErr)
452611 return
453612 }
454613 }
@@ -12,16 +12,59 @@ import (
12 "github.com/google/uuid"12 "github.com/google/uuid"
13 )13 )
14 14
15-// ResidentTurnRunner executes one already-accepted prompt. The child owns the15+// ResidentRuntime owns one child's agent. The child owns turn ordering and
16-// ordering and cancellation boundary; implementations own agent/provider work.16+// cancellation; the runtime owns agent/provider work.
17+//
18+// Run executes one accepted turn. Steer injects a follow-up into the turn Run
19+// is executing and reports false when no turn can still receive it; a true
20+// result guarantees Run delivers the follow-up before it returns successfully.
21+// PendingSteers counts follow-ups not yet delivered, and DrainSteers removes
22+// and returns them after a turn ended without delivering them.
23+type ResidentRuntime interface {
24+ Run(ctx context.Context, prompt string) error
25+ Steer(prompt string) bool
26+ PendingSteers() int
27+ DrainSteers() []string
28+}
29+
30+// ResidentTurnRunner is a runtime that runs turns but cannot be steered, so
31+// every follow-up becomes a separate queued turn.
17 type ResidentTurnRunner func(context.Context, string) error32 type ResidentTurnRunner func(context.Context, string) error
18 33
34+func (r ResidentTurnRunner) Run(ctx context.Context, prompt string) error { return r(ctx, prompt) }
35+func (ResidentTurnRunner) Steer(string) bool { return false }
36+func (ResidentTurnRunner) PendingSteers() int { return 0 }
37+func (ResidentTurnRunner) DrainSteers() []string { return nil }
38+
19 type residentPrompt struct {39 type residentPrompt struct {
20 turnID string40 turnID string
21 prompt string41 prompt string
22 ack chan error42 ack chan error
23 }43 }
24 44
45+type residentControlKind int
46+
47+const (
48+ residentControlSteer residentControlKind = iota + 1
49+ residentControlInterrupt
50+)
51+
52+// residentControl is a request handled by the control goroutine, so it is
53+// ordered with turn start and completion.
54+type residentControl struct {
55+ kind residentControlKind
56+ prompt string
57+ // onSteered runs on the control goroutine before the active turn can
58+ // report its completion, so a caller can subscribe without a race.
59+ onSteered func(turnID string)
60+ reply chan residentControlReply
61+}
62+
63+type residentControlReply struct {
64+ ok bool
65+ turnID string
66+}
67+
25 type residentTurnResult struct {68 type residentTurnResult struct {
26 turnID string69 turnID string
27 err error70 err error
@@ -34,13 +77,19 @@ type ResidentCompletion struct {
34 Task string77 Task string
35 Err error78 Err error
36 Summary string79 Summary string
80+ // NotStarted marks a queued follow-up that was dropped before the child
81+ // ever ran it, for example because the child was stopped or interrupted.
82+ NotStarted bool
83+ // Undelivered lists follow-ups steered into this turn that the child never
84+ // read because the turn ended first.
85+ Undelivered []string
37 }86 }
38 87
39 // ResidentChild serializes prompt execution through one control goroutine.88 // ResidentChild serializes prompt execution through one control goroutine.
40 // No mutex is held while a runner performs provider or tool I/O. When a89 // No mutex is held while a runner performs provider or tool I/O. When a
41 // journal is configured, every state boundary is committed by that goroutine.90 // journal is configured, every state boundary is committed by that goroutine.
42 type ResidentChild struct {91 type ResidentChild struct {
43- runner ResidentTurnRunner92+ runtime ResidentRuntime
44 journal *ResidentJournal93 journal *ResidentJournal
45 workspace WorkspaceHandle94 workspace WorkspaceHandle
46 spec ResidentChildSpec95 spec ResidentChildSpec
@@ -48,11 +97,12 @@ type ResidentChild struct {
48 onUpdate func(historyChanged bool)97 onUpdate func(historyChanged bool)
49 onStateChange func(ResidentState)98 onStateChange func(ResidentState)
50 99
51- ctx context.Context100+ ctx context.Context
52- cancel context.CancelFunc101+ cancel context.CancelFunc
53- inbox chan residentPrompt102+ inbox chan residentPrompt
54- done chan struct{}103+ control chan residentControl
55- once sync.Once104+ done chan struct{}
105+ once sync.Once
56 106
57 mu sync.RWMutex107 mu sync.RWMutex
58 state ResidentState108 state ResidentState
@@ -61,39 +111,41 @@ type ResidentChild struct {
61 activityUpdatedAt time.Time111 activityUpdatedAt time.Time
62 live *residentLiveProjection112 live *residentLiveProjection
63 cleanupWorkspace bool113 cleanupWorkspace bool
114+ queuedTurns int
64 }115 }
65 116
66-func NewResidentChild(runner ResidentTurnRunner) *ResidentChild {117+func NewResidentChild(runtime ResidentRuntime) *ResidentChild {
67- return newResidentChild(ResidentChildSpec{}, nil, runner)118+ return newResidentChild(ResidentChildSpec{}, nil, runtime)
68 }119 }
69 120
70-func newJournaledResidentChild(spec ResidentChildSpec, journal *ResidentJournal, runner ResidentTurnRunner, onCompletion func(ResidentCompletion)) *ResidentChild {121+func newJournaledResidentChild(spec ResidentChildSpec, journal *ResidentJournal, runtime ResidentRuntime, onCompletion func(ResidentCompletion)) *ResidentChild {
71- child := newResidentChildWithWorkspace(spec, journal, nil, runner)122+ child := newResidentChildWithWorkspace(spec, journal, nil, runtime)
72 child.onCompletion = onCompletion123 child.onCompletion = onCompletion
73 return child124 return child
74 }125 }
75 126
76-func newJournaledResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJournal, workspace WorkspaceHandle, runner ResidentTurnRunner, onCompletion func(ResidentCompletion)) *ResidentChild {127+func newJournaledResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJournal, workspace WorkspaceHandle, runtime ResidentRuntime, onCompletion func(ResidentCompletion)) *ResidentChild {
77- child := newResidentChildWithWorkspace(spec, journal, workspace, runner)128+ child := newResidentChildWithWorkspace(spec, journal, workspace, runtime)
78 child.onCompletion = onCompletion129 child.onCompletion = onCompletion
79 return child130 return child
80 }131 }
81 132
82-func newResidentChild(spec ResidentChildSpec, journal *ResidentJournal, runner ResidentTurnRunner) *ResidentChild {133+func newResidentChild(spec ResidentChildSpec, journal *ResidentJournal, runtime ResidentRuntime) *ResidentChild {
83- return newResidentChildWithWorkspace(spec, journal, nil, runner)134+ return newResidentChildWithWorkspace(spec, journal, nil, runtime)
84 }135 }
85 136
86-func newResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJournal, workspace WorkspaceHandle, runner ResidentTurnRunner) *ResidentChild {137+func newResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJournal, workspace WorkspaceHandle, runtime ResidentRuntime) *ResidentChild {
87 ctx, cancel := context.WithCancel(context.Background())138 ctx, cancel := context.WithCancel(context.Background())
88 now := time.Now().UTC()139 now := time.Now().UTC()
89 child := &ResidentChild{140 child := &ResidentChild{
90- runner: runner,141+ runtime: runtime,
91 journal: journal,142 journal: journal,
92 workspace: workspace,143 workspace: workspace,
93 spec: spec,144 spec: spec,
94 ctx: ctx,145 ctx: ctx,
95 cancel: cancel,146 cancel: cancel,
96 inbox: make(chan residentPrompt),147 inbox: make(chan residentPrompt),
148+ control: make(chan residentControl),
97 done: make(chan struct{}),149 done: make(chan struct{}),
98 state: ResidentQueued,150 state: ResidentQueued,
99 stateUpdatedAt: now,151 stateUpdatedAt: now,
@@ -183,6 +235,27 @@ func (c *ResidentChild) ActivityUpdatedAt() time.Time {
183 return c.activityUpdatedAt235 return c.activityUpdatedAt
184 }236 }
185 237
238+// PendingFollowUps counts follow-ups the child has accepted but not yet read:
239+// queued turns plus messages steered into the running turn.
240+func (c *ResidentChild) PendingFollowUps() int {
241+ if c == nil {
242+ return 0
243+ }
244+ c.mu.RLock()
245+ queued := c.queuedTurns
246+ c.mu.RUnlock()
247+ if c.runtime != nil {
248+ queued += c.runtime.PendingSteers()
249+ }
250+ return queued
251+}
252+
253+func (c *ResidentChild) setQueuedTurns(n int) {
254+ c.mu.Lock()
255+ c.queuedTurns = n
256+ c.mu.Unlock()
257+}
258+
186 func (c *ResidentChild) setState(state ResidentState) {259 func (c *ResidentChild) setState(state ResidentState) {
187 c.mu.Lock()260 c.mu.Lock()
188 now := time.Now().UTC()261 now := time.Now().UTC()
@@ -270,8 +343,42 @@ func (c *ResidentChild) enqueue(ctx context.Context, request residentPrompt) err
270 return err343 return err
271 }344 }
272 345
346+// steer injects a follow-up into the running turn. It reports false, leaving
347+// the child unchanged, when no turn is executing or earlier follow-ups are
348+// still queued; the caller must then queue the follow-up as a new turn.
349+func (c *ResidentChild) steer(ctx context.Context, prompt string, onSteered func(turnID string)) (bool, string, error) {
350+ reply, err := c.sendControl(ctx, residentControl{kind: residentControlSteer, prompt: strings.TrimSpace(prompt), onSteered: onSteered})
351+ return reply.ok, reply.turnID, err
352+}
353+
354+// interruptTurn cancels only the running turn. The child stays alive with its
355+// transcript, and queued follow-ups are dropped. It reports false when no turn
356+// is running.
357+func (c *ResidentChild) interruptTurn(ctx context.Context) (bool, error) {
358+ reply, err := c.sendControl(ctx, residentControl{kind: residentControlInterrupt})
359+ return reply.ok, err
360+}
361+
362+func (c *ResidentChild) sendControl(ctx context.Context, request residentControl) (residentControlReply, error) {
363+ if c == nil || c.runtime == nil {
364+ return residentControlReply{}, errors.New("resident child: no runtime")
365+ }
366+ if ctx == nil {
367+ ctx = context.Background()
368+ }
369+ request.reply = make(chan residentControlReply, 1)
370+ select {
371+ case <-ctx.Done():
372+ return residentControlReply{}, ctx.Err()
373+ case <-c.done:
374+ return residentControlReply{}, errors.New("resident child: closed")
375+ case c.control <- request:
376+ }
377+ return <-request.reply, nil
378+}
379+
273 func (c *ResidentChild) enqueueAccepted(ctx context.Context, request residentPrompt) (bool, error) {380 func (c *ResidentChild) enqueueAccepted(ctx context.Context, request residentPrompt) (bool, error) {
274- if c == nil || c.runner == nil {381+ if c == nil || c.runtime == nil {
275 return false, errors.New("resident child: no runner")382 return false, errors.New("resident child: no runner")
276 }383 }
277 if ctx == nil {384 if ctx == nil {
@@ -336,10 +443,28 @@ func (c *ResidentChild) run() {
336 443
337 var queue []residentPrompt444 var queue []residentPrompt
338 var interruptedPrompts []residentPrompt445 var interruptedPrompts []residentPrompt
446+ var droppedPrompts []residentPrompt
339 results := make(chan residentTurnResult, 1)447 results := make(chan residentTurnResult, 1)
340 var active residentPrompt448 var active residentPrompt
341- running, interrupted := false, false449+ turnCancel := context.CancelFunc(func() {})
450+ defer func() { turnCancel() }()
451+ running, interrupted, turnInterrupted := false, false, false
452+ // dropQueued reports queued follow-ups that never started. Each was
453+ // accepted and tracked, so each still gets exactly one completion, marked
454+ // NotStarted so the parent does not mistake it for interrupted work.
455+ dropQueued := func(err error) {
456+ for _, pending := range queue {
457+ if c.journal != nil {
458+ _ = c.journal.RecordTurnInterrupted(c.spec, pending.turnID)
459+ }
460+ if c.onCompletion != nil {
461+ c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: pending.turnID, Task: pending.prompt, Err: err, NotStarted: true})
462+ }
463+ }
464+ queue = nil
465+ }
342 for {466 for {
467+ c.setQueuedTurns(len(queue))
343 if !interrupted && !running && len(queue) > 0 {468 if !interrupted && !running && len(queue) > 0 {
344 active, queue = queue[0], queue[1:]469 active, queue = queue[0], queue[1:]
345 if c.journal != nil {470 if c.journal != nil {
@@ -358,10 +483,12 @@ func (c *ResidentChild) run() {
358 }483 }
359 }484 }
360 c.startTurn(active.turnID)485 c.startTurn(active.turnID)
361- running = true486+ running, turnInterrupted = true, false
362- go func(request residentPrompt) {487+ turnCtx, cancelTurn := context.WithCancel(c.ctx)
363- results <- residentTurnResult{turnID: request.turnID, err: c.runner(c.ctx, request.prompt)}488+ turnCancel = cancelTurn
364- }(active)489+ go func(ctx context.Context, request residentPrompt) {
490+ results <- residentTurnResult{turnID: request.turnID, err: c.runtime.Run(ctx, request.prompt)}
491+ }(turnCtx, active)
365 }492 }
366 493
367 if interrupted && !running {494 if interrupted && !running {
@@ -369,7 +496,10 @@ func (c *ResidentChild) run() {
369 c.setState(ResidentInterrupted)496 c.setState(ResidentInterrupted)
370 if c.onCompletion != nil {497 if c.onCompletion != nil {
371 for _, prompt := range interruptedPrompts {498 for _, prompt := range interruptedPrompts {
372- c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: prompt.turnID, Task: prompt.prompt, Err: context.Canceled})499+ c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: prompt.turnID, Task: prompt.prompt, Err: context.Canceled, Undelivered: c.runtime.DrainSteers()})
500+ }
501+ for _, prompt := range droppedPrompts {
502+ c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: prompt.turnID, Task: prompt.prompt, Err: context.Canceled, NotStarted: true})
373 }503 }
374 }504 }
375 return505 return
@@ -392,12 +522,38 @@ func (c *ResidentChild) run() {
392 }522 }
393 }523 }
394 for _, pending := range queue {524 for _, pending := range queue {
395- interruptedPrompts = append(interruptedPrompts, pending)525+ droppedPrompts = append(droppedPrompts, pending)
396 if c.journal != nil {526 if c.journal != nil {
397 _ = c.journal.RecordTurnInterrupted(c.spec, pending.turnID)527 _ = c.journal.RecordTurnInterrupted(c.spec, pending.turnID)
398 }528 }
399 }529 }
400 queue = nil530 queue = nil
531+ c.setQueuedTurns(0)
532+ case request := <-c.control:
533+ switch request.kind {
534+ case residentControlSteer:
535+ // Steering past queued turns would reorder follow-ups, and an
536+ // interrupted turn can no longer read new input.
537+ if running && !interrupted && !turnInterrupted && len(queue) == 0 && request.prompt != "" && c.runtime.Steer(request.prompt) {
538+ if request.onSteered != nil {
539+ request.onSteered(active.turnID)
540+ }
541+ c.notifyUpdate(false)
542+ request.reply <- residentControlReply{ok: true, turnID: active.turnID}
543+ } else {
544+ request.reply <- residentControlReply{}
545+ }
546+ case residentControlInterrupt:
547+ if !running || interrupted || turnInterrupted {
548+ request.reply <- residentControlReply{}
549+ continue
550+ }
551+ turnInterrupted = true
552+ turnCancel()
553+ dropQueued(context.Canceled)
554+ c.setQueuedTurns(0)
555+ request.reply <- residentControlReply{ok: true, turnID: active.turnID}
556+ }
401 case request := <-c.inbox:557 case request := <-c.inbox:
402 if interrupted {558 if interrupted {
403 request.ack <- errors.New("resident child: closed")559 request.ack <- errors.New("resident child: closed")
@@ -407,10 +563,17 @@ func (c *ResidentChild) run() {
407 request.ack <- nil563 request.ack <- nil
408 case result := <-results:564 case result := <-results:
409 running = false565 running = false
566+ turnCancel()
410 if interrupted {567 if interrupted {
411 continue568 continue
412 }569 }
413 terminalErr := result.err570 terminalErr := result.err
571+ if turnInterrupted && terminalErr == nil {
572+ // The turn finished before cancellation reached it; the
573+ // manager still asked for an interruption.
574+ terminalErr = context.Canceled
575+ }
576+ undelivered := c.runtime.DrainSteers()
414 var capture *WorkspaceCapture577 var capture *WorkspaceCapture
415 if (terminalErr == nil || !errors.Is(terminalErr, context.Canceled)) && c.workspace != nil && c.workspace.Mode() == WorkspaceWorktree {578 if (terminalErr == nil || !errors.Is(terminalErr, context.Canceled)) && c.workspace != nil && c.workspace.Mode() == WorkspaceWorktree {
416 captured, err := c.workspace.Capture(c.ctx)579 captured, err := c.workspace.Capture(c.ctx)
@@ -441,14 +604,10 @@ func (c *ResidentChild) run() {
441 c.live.Finish(state)604 c.live.Finish(state)
442 c.setState(state)605 c.setState(state)
443 if c.onCompletion != nil {606 if c.onCompletion != nil {
444- c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: result.turnID, Task: active.prompt, Err: terminalErr, Summary: summary})607+ c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: result.turnID, Task: active.prompt, Err: terminalErr, Summary: summary, Undelivered: undelivered})
445- if persistenceFailed {
446- for _, pending := range queue {
447- c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: pending.turnID, Task: pending.prompt, Err: terminalErr})
448- }
449- }
450 }608 }
451 if persistenceFailed {609 if persistenceFailed {
610+ dropQueued(terminalErr)
452 return611 return
453 }612 }
454 }613 }
M packages/agent/subagents/resident_child_test.go +5 -5
@@ -14,7 +14,7 @@ func TestJournaledResidentChildReceivesLiveJournalEvents(t *testing.T) {
1414 if err != nil {
1515 t.Fatal(err)
1616 }
17- child := newJournaledResidentChild(ResidentChildSpec{}, journal, func(context.Context, string) error { return nil }, nil)
17+ child := newJournaledResidentChild(ResidentChildSpec{}, journal, ResidentTurnRunner(func(context.Context, string) error { return nil }), nil)
1818 defer child.Close(context.Background())
1919 oldActivity := time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)
2020 child.mu.Lock()
@@ -36,7 +36,7 @@ func TestResidentChildRunsAcceptedPromptsFIFOWithOneActiveTurn(t *testing.T) {
3636 started := make(chan string, 2)
3737 releaseFirst := make(chan struct{})
3838 completed := make(chan string, 2)
39- child := NewResidentChild(func(ctx context.Context, prompt string) error {
39+ child := NewResidentChild(ResidentTurnRunner(func(ctx context.Context, prompt string) error {
4040 started <- prompt
4141 if prompt == "first" {
4242 select {
@@ -47,7 +47,7 @@ func TestResidentChildRunsAcceptedPromptsFIFOWithOneActiveTurn(t *testing.T) {
4747 }
4848 completed <- prompt
4949 return nil
50- })
50+ }))
5151 defer child.Close(context.Background())
5252
5353 if err := child.Resume(context.Background(), "first"); err != nil {
@@ -95,10 +95,10 @@ func TestResidentChildReportsAndPreservesFailureWhenTurnStartCannotPersist(t *te
9595 child := newJournaledResidentChild(
9696 ResidentChildSpec{ID: "start-persistence-failure"},
9797 journal,
98- func(context.Context, string) error {
98+ ResidentTurnRunner(func(context.Context, string) error {
9999 run <- struct{}{}
100100 return nil
101- },
101+ }),
102102 func(completion ResidentCompletion) { completed <- completion },
103103 )
104104
@@ -14,7 +14,7 @@ func TestJournaledResidentChildReceivesLiveJournalEvents(t *testing.T) {
14 if err != nil {14 if err != nil {
15 t.Fatal(err)15 t.Fatal(err)
16 }16 }
17- child := newJournaledResidentChild(ResidentChildSpec{}, journal, func(context.Context, string) error { return nil }, nil)17+ child := newJournaledResidentChild(ResidentChildSpec{}, journal, ResidentTurnRunner(func(context.Context, string) error { return nil }), nil)
18 defer child.Close(context.Background())18 defer child.Close(context.Background())
19 oldActivity := time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)19 oldActivity := time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)
20 child.mu.Lock()20 child.mu.Lock()
@@ -36,7 +36,7 @@ func TestResidentChildRunsAcceptedPromptsFIFOWithOneActiveTurn(t *testing.T) {
36 started := make(chan string, 2)36 started := make(chan string, 2)
37 releaseFirst := make(chan struct{})37 releaseFirst := make(chan struct{})
38 completed := make(chan string, 2)38 completed := make(chan string, 2)
39- child := NewResidentChild(func(ctx context.Context, prompt string) error {39+ child := NewResidentChild(ResidentTurnRunner(func(ctx context.Context, prompt string) error {
40 started <- prompt40 started <- prompt
41 if prompt == "first" {41 if prompt == "first" {
42 select {42 select {
@@ -47,7 +47,7 @@ func TestResidentChildRunsAcceptedPromptsFIFOWithOneActiveTurn(t *testing.T) {
47 }47 }
48 completed <- prompt48 completed <- prompt
49 return nil49 return nil
50- })50+ }))
51 defer child.Close(context.Background())51 defer child.Close(context.Background())
52 52
53 if err := child.Resume(context.Background(), "first"); err != nil {53 if err := child.Resume(context.Background(), "first"); err != nil {
@@ -95,10 +95,10 @@ func TestResidentChildReportsAndPreservesFailureWhenTurnStartCannotPersist(t *te
95 child := newJournaledResidentChild(95 child := newJournaledResidentChild(
96 ResidentChildSpec{ID: "start-persistence-failure"},96 ResidentChildSpec{ID: "start-persistence-failure"},
97 journal,97 journal,
98- func(context.Context, string) error {98+ ResidentTurnRunner(func(context.Context, string) error {
99 run <- struct{}{}99 run <- struct{}{}
100 return nil100 return nil
101- },101+ }),
102 func(completion ResidentCompletion) { completed <- completion },102 func(completion ResidentCompletion) { completed <- completion },
103 )103 )
104 104
M packages/agent/subagents/resident_failure_recovery_test.go +3 -3
@@ -28,8 +28,8 @@ func TestResidentFailureRecoveryKeepsRequiredUnmetUntilResume(t *testing.T) {
2828 spec.WorkspaceMode, spec.WorkspaceCapture = WorkspaceWorktree, CapturePatch
2929 }
3030 turnErr := errors.New("turn failed")
31- factory := func(spec ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {
32- return func(_ context.Context, prompt string) error {
31+ factory := func(spec ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) {
32+ return ResidentTurnRunner(func(_ context.Context, prompt string) error {
3333 if prompt == "continue remaining work" {
3434 messages, err := ReadResidentTranscriptMessages(journal.Dir())
3535 if err != nil || len(messages) < 3 {
@@ -66,7 +66,7 @@ func TestResidentFailureRecoveryKeepsRequiredUnmetUntilResume(t *testing.T) {
6666 }
6767 }
6868 return turnErr
69- }, nil
69+ }), nil
7070 }
7171 manager := NewResidentManager(root, factory)
7272 t.Cleanup(func() { _ = manager.Close(context.Background()) })
@@ -28,8 +28,8 @@ func TestResidentFailureRecoveryKeepsRequiredUnmetUntilResume(t *testing.T) {
28 spec.WorkspaceMode, spec.WorkspaceCapture = WorkspaceWorktree, CapturePatch28 spec.WorkspaceMode, spec.WorkspaceCapture = WorkspaceWorktree, CapturePatch
29 }29 }
30 turnErr := errors.New("turn failed")30 turnErr := errors.New("turn failed")
31- factory := func(spec ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {31+ factory := func(spec ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) {
32- return func(_ context.Context, prompt string) error {32+ return ResidentTurnRunner(func(_ context.Context, prompt string) error {
33 if prompt == "continue remaining work" {33 if prompt == "continue remaining work" {
34 messages, err := ReadResidentTranscriptMessages(journal.Dir())34 messages, err := ReadResidentTranscriptMessages(journal.Dir())
35 if err != nil || len(messages) < 3 {35 if err != nil || len(messages) < 3 {
@@ -66,7 +66,7 @@ func TestResidentFailureRecoveryKeepsRequiredUnmetUntilResume(t *testing.T) {
66 }66 }
67 }67 }
68 return turnErr68 return turnErr
69- }, nil69+ }), nil
70 }70 }
71 manager := NewResidentManager(root, factory)71 manager := NewResidentManager(root, factory)
72 t.Cleanup(func() { _ = manager.Close(context.Background()) })72 t.Cleanup(func() { _ = manager.Close(context.Background()) })
M packages/agent/subagents/resident_history_test.go +2 -2
@@ -261,8 +261,8 @@ func TestReadResidentHistoryPageCursorSurvivesJournalAppend(t *testing.T) {
261261 }
262262
263263 func TestResidentManagerHistoryRejectsPathTraversal(t *testing.T) {
264- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
265- return func(context.Context, string) error { return nil }, nil
264+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
265+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
266266 })
267267 if _, err := manager.History("../escape", 10); err == nil {
268268 t.Fatal("History accepted traversal")
@@ -261,8 +261,8 @@ func TestReadResidentHistoryPageCursorSurvivesJournalAppend(t *testing.T) {
261 }261 }
262 262
263 func TestResidentManagerHistoryRejectsPathTraversal(t *testing.T) {263 func TestResidentManagerHistoryRejectsPathTraversal(t *testing.T) {
264- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {264+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
265- return func(context.Context, string) error { return nil }, nil265+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
266 })266 })
267 if _, err := manager.History("../escape", 10); err == nil {267 if _, err := manager.History("../escape", 10); err == nil {
268 t.Fatal("History accepted traversal")268 t.Fatal("History accepted traversal")
M packages/agent/subagents/resident_journal_test.go +2 -2
@@ -722,8 +722,8 @@ func TestReconcileResidentJournalTranslatesV2BudgetHistory(t *testing.T) {
722722 if err != nil {
723723 t.Fatal(err)
724724 }
725- factory := func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
726- return func(context.Context, string) error { return nil }, nil
725+ factory := func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
726+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
727727 }
728728 manager := NewResidentManager(root, factory)
729729 t.Cleanup(func() { _ = manager.Close(context.Background()) })
@@ -722,8 +722,8 @@ func TestReconcileResidentJournalTranslatesV2BudgetHistory(t *testing.T) {
722 if err != nil {722 if err != nil {
723 t.Fatal(err)723 t.Fatal(err)
724 }724 }
725- factory := func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {725+ factory := func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
726- return func(context.Context, string) error { return nil }, nil726+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
727 }727 }
728 manager := NewResidentManager(root, factory)728 manager := NewResidentManager(root, factory)
729 t.Cleanup(func() { _ = manager.Close(context.Background()) })729 t.Cleanup(func() { _ = manager.Close(context.Background()) })
M packages/agent/subagents/resident_manager.go +98 -9
@@ -16,9 +16,9 @@ import (
1616 )
1717
1818 // ResidentFactory is the host-owned construction boundary. It receives only
19-// an already-resolved non-secret specification and returns an in-process turn
20-// runner; it never starts another zut binary.
21-type ResidentFactory func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error)
19+// an already-resolved non-secret specification and returns an in-process
20+// runtime; it never starts another zut binary.
21+type ResidentFactory func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error)
2222
2323 type ResidentManager struct {
2424 root string
@@ -216,6 +216,7 @@ type ResidentSnapshot struct {
216216 TurnStartedAt time.Time
217217 ActivityUpdatedAt time.Time
218218 WaitingForModel bool
219+ PendingFollowUps int
219220 OwnedElsewhere bool
220221 Usage provider.Usage
221222 ContextUsed int
@@ -232,7 +233,7 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot {
232233 Provider: child.spec.Provider, Model: child.spec.Model,
233234 WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required,
234235 UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(),
235- ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel,
236+ ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel, PendingFollowUps: child.PendingFollowUps(),
236237 Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription}
237238 }
238239
@@ -378,7 +379,20 @@ func (m *ResidentManager) Spawn(ctx context.Context, spec ResidentChildSpec, tas
378379 return child, nil
379380 }
380381
381-func (m *ResidentManager) scheduledRunner(childID string, runner ResidentTurnRunner) ResidentTurnRunner {
382+// scheduledRuntime gates each turn on a global scheduler slot. Steering needs
383+// no slot: it only reaches a turn that already holds one.
384+type scheduledRuntime struct {
385+ ResidentRuntime
386+ run ResidentTurnRunner
387+}
388+
389+func (r scheduledRuntime) Run(ctx context.Context, prompt string) error { return r.run(ctx, prompt) }
390+
391+func (m *ResidentManager) scheduledRunner(childID string, runtime ResidentRuntime) ResidentRuntime {
392+ return scheduledRuntime{ResidentRuntime: runtime, run: m.scheduledRun(childID, runtime)}
393+}
394+
395+func (m *ResidentManager) scheduledRun(childID string, runtime ResidentRuntime) ResidentTurnRunner {
382396 return func(ctx context.Context, prompt string) error {
383397 if ctx == nil {
384398 ctx = context.Background()
@@ -418,7 +432,7 @@ func (m *ResidentManager) scheduledRunner(childID string, runner ResidentTurnRun
418432 _ = m.scheduler.Release(childID)
419433 m.dispatch()
420434 }()
421- return runner(ctx, prompt)
435+ return runtime.Run(ctx, prompt)
422436 }
423437 }
424438
@@ -439,15 +453,69 @@ func (m *ResidentManager) dispatch() {
439453 // resident child. A disk-only child is rebuilt only for this explicit new
440454 // prompt; reconciliation itself never constructs or replays one.
441455 func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) error {
442- return m.ResumeWithTurn(ctx, childID, prompt, uuid.NewString())
456+ _, err := m.ResumeFollowUp(ctx, childID, prompt, ResumeSteer, uuid.NewString(), nil)
457+ return err
443458 }
444459
445-// ResumeWithTurn is Resume with a caller-owned turn ID. The ID names the
460+// ResumeMode selects how a follow-up reaches a child that is running a turn.
461+type ResumeMode string
462+
463+const (
464+ // ResumeSteer injects the follow-up into the running turn at its next
465+ // tool or model boundary. An idle child starts a new turn instead.
466+ ResumeSteer ResumeMode = "steer"
467+ // ResumeQueue runs the follow-up as a separate turn after the current one.
468+ ResumeQueue ResumeMode = "queue"
469+)
470+
471+// ResumeOutcome identifies the turn that will answer a follow-up.
472+type ResumeOutcome struct {
473+ // TurnID is the new turn for a queued follow-up, or the running turn
474+ // that received a steered one.
475+ TurnID string
476+ Steered bool
477+}
478+
479+// ResumeFollowUp delivers a follow-up by mode. A steered follow-up joins the
480+// running turn, so it creates no new turn and no new completion. onSteered, when
481+// set, runs before that turn can complete so a caller can watch its completion
482+// without racing it. When steering is impossible, the follow-up is queued as a
483+// new turn named turnID.
484+func (m *ResidentManager) ResumeFollowUp(ctx context.Context, childID, prompt string, mode ResumeMode, turnID string, onSteered func(turnID string)) (ResumeOutcome, error) {
485+ if mode == "" {
486+ mode = ResumeSteer
487+ }
488+ if mode != ResumeSteer && mode != ResumeQueue {
489+ return ResumeOutcome{}, fmt.Errorf("resident manager: unknown resume mode %q", mode)
490+ }
491+ if mode == ResumeSteer && m != nil && strings.TrimSpace(prompt) != "" {
492+ m.lifecycleMu.Lock()
493+ child := m.Get(strings.TrimSpace(childID))
494+ if child != nil {
495+ steered, activeTurn, err := child.steer(ctx, prompt, onSteered)
496+ if err == nil && steered {
497+ m.lifecycleMu.Unlock()
498+ m.notifyUpdate(child.spec.ID, false)
499+ return ResumeOutcome{TurnID: activeTurn, Steered: true}, nil
500+ }
501+ }
502+ m.lifecycleMu.Unlock()
503+ }
504+ if strings.TrimSpace(turnID) == "" {
505+ turnID = uuid.NewString()
506+ }
507+ if err := m.resumeWithTurn(ctx, childID, prompt, turnID); err != nil {
508+ return ResumeOutcome{}, err
509+ }
510+ return ResumeOutcome{TurnID: turnID}, nil
511+}
512+
513+// resumeWithTurn queues a follow-up turn with a caller-owned ID. The ID names the
446514 // accepted follow-up turn in its completion, so a caller that must observe the
447515 // outcome can subscribe with WatchCompletion before the turn can finish. The
448516 // ID must be unique per child: replay rejects a duplicate turn.accepted record,
449517 // so reusing one makes the journal unreplayable.
450-func (m *ResidentManager) ResumeWithTurn(ctx context.Context, childID, prompt, turnID string) error {
518+func (m *ResidentManager) resumeWithTurn(ctx context.Context, childID, prompt, turnID string) error {
451519 if strings.TrimSpace(turnID) == "" {
452520 turnID = uuid.NewString()
453521 }
@@ -928,6 +996,27 @@ func (m *ResidentManager) UnmetRequired() []ResidentSnapshot {
928996 return result
929997 }
930998
999+// Interrupt cancels only a live child's running turn and drops its queued
1000+// follow-ups. The child stays live with its transcript, so a later resume
1001+// continues with full context. It reports false when no turn is running.
1002+func (m *ResidentManager) Interrupt(ctx context.Context, childID string) (bool, error) {
1003+ if m == nil {
1004+ return false, errors.New("resident manager: unavailable")
1005+ }
1006+ m.lifecycleMu.Lock()
1007+ defer m.lifecycleMu.Unlock()
1008+ child := m.Get(strings.TrimSpace(childID))
1009+ if child == nil {
1010+ return false, errors.New("resident manager: child is not live")
1011+ }
1012+ interrupted, err := child.interruptTurn(ctx)
1013+ if err != nil {
1014+ return false, err
1015+ }
1016+ m.notifyUpdate(child.spec.ID, false)
1017+ return interrupted, nil
1018+}
1019+
9311020 // Stop cancels one live child and waits for its control loop. A disk-only
9321021 // interrupted child must be explicitly resumed by host construction instead.
9331022 func (m *ResidentManager) Stop(ctx context.Context, childID string) error {
@@ -16,9 +16,9 @@ import (
16 )16 )
17 17
18 // ResidentFactory is the host-owned construction boundary. It receives only18 // ResidentFactory is the host-owned construction boundary. It receives only
19-// an already-resolved non-secret specification and returns an in-process turn19+// an already-resolved non-secret specification and returns an in-process
20-// runner; it never starts another zut binary.20+// runtime; it never starts another zut binary.
21-type ResidentFactory func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error)21+type ResidentFactory func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error)
22 22
23 type ResidentManager struct {23 type ResidentManager struct {
24 root string24 root string
@@ -216,6 +216,7 @@ type ResidentSnapshot struct {
216 TurnStartedAt time.Time216 TurnStartedAt time.Time
217 ActivityUpdatedAt time.Time217 ActivityUpdatedAt time.Time
218 WaitingForModel bool218 WaitingForModel bool
219+ PendingFollowUps int
219 OwnedElsewhere bool220 OwnedElsewhere bool
220 Usage provider.Usage221 Usage provider.Usage
221 ContextUsed int222 ContextUsed int
@@ -232,7 +233,7 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot {
232 Provider: child.spec.Provider, Model: child.spec.Model,233 Provider: child.spec.Provider, Model: child.spec.Model,
233 WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required,234 WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required,
234 UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(),235 UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(),
235- ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel,236+ ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel, PendingFollowUps: child.PendingFollowUps(),
236 Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription}237 Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription}
237 }238 }
238 239
@@ -378,7 +379,20 @@ func (m *ResidentManager) Spawn(ctx context.Context, spec ResidentChildSpec, tas
378 return child, nil379 return child, nil
379 }380 }
380 381
381-func (m *ResidentManager) scheduledRunner(childID string, runner ResidentTurnRunner) ResidentTurnRunner {382+// scheduledRuntime gates each turn on a global scheduler slot. Steering needs
383+// no slot: it only reaches a turn that already holds one.
384+type scheduledRuntime struct {
385+ ResidentRuntime
386+ run ResidentTurnRunner
387+}
388+
389+func (r scheduledRuntime) Run(ctx context.Context, prompt string) error { return r.run(ctx, prompt) }
390+
391+func (m *ResidentManager) scheduledRunner(childID string, runtime ResidentRuntime) ResidentRuntime {
392+ return scheduledRuntime{ResidentRuntime: runtime, run: m.scheduledRun(childID, runtime)}
393+}
394+
395+func (m *ResidentManager) scheduledRun(childID string, runtime ResidentRuntime) ResidentTurnRunner {
382 return func(ctx context.Context, prompt string) error {396 return func(ctx context.Context, prompt string) error {
383 if ctx == nil {397 if ctx == nil {
384 ctx = context.Background()398 ctx = context.Background()
@@ -418,7 +432,7 @@ func (m *ResidentManager) scheduledRunner(childID string, runner ResidentTurnRun
418 _ = m.scheduler.Release(childID)432 _ = m.scheduler.Release(childID)
419 m.dispatch()433 m.dispatch()
420 }()434 }()
421- return runner(ctx, prompt)435+ return runtime.Run(ctx, prompt)
422 }436 }
423 }437 }
424 438
@@ -439,15 +453,69 @@ func (m *ResidentManager) dispatch() {
439 // resident child. A disk-only child is rebuilt only for this explicit new453 // resident child. A disk-only child is rebuilt only for this explicit new
440 // prompt; reconciliation itself never constructs or replays one.454 // prompt; reconciliation itself never constructs or replays one.
441 func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) error {455 func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) error {
442- return m.ResumeWithTurn(ctx, childID, prompt, uuid.NewString())456+ _, err := m.ResumeFollowUp(ctx, childID, prompt, ResumeSteer, uuid.NewString(), nil)
457+ return err
443 }458 }
444 459
445-// ResumeWithTurn is Resume with a caller-owned turn ID. The ID names the460+// ResumeMode selects how a follow-up reaches a child that is running a turn.
461+type ResumeMode string
462+
463+const (
464+ // ResumeSteer injects the follow-up into the running turn at its next
465+ // tool or model boundary. An idle child starts a new turn instead.
466+ ResumeSteer ResumeMode = "steer"
467+ // ResumeQueue runs the follow-up as a separate turn after the current one.
468+ ResumeQueue ResumeMode = "queue"
469+)
470+
471+// ResumeOutcome identifies the turn that will answer a follow-up.
472+type ResumeOutcome struct {
473+ // TurnID is the new turn for a queued follow-up, or the running turn
474+ // that received a steered one.
475+ TurnID string
476+ Steered bool
477+}
478+
479+// ResumeFollowUp delivers a follow-up by mode. A steered follow-up joins the
480+// running turn, so it creates no new turn and no new completion. onSteered, when
481+// set, runs before that turn can complete so a caller can watch its completion
482+// without racing it. When steering is impossible, the follow-up is queued as a
483+// new turn named turnID.
484+func (m *ResidentManager) ResumeFollowUp(ctx context.Context, childID, prompt string, mode ResumeMode, turnID string, onSteered func(turnID string)) (ResumeOutcome, error) {
485+ if mode == "" {
486+ mode = ResumeSteer
487+ }
488+ if mode != ResumeSteer && mode != ResumeQueue {
489+ return ResumeOutcome{}, fmt.Errorf("resident manager: unknown resume mode %q", mode)
490+ }
491+ if mode == ResumeSteer && m != nil && strings.TrimSpace(prompt) != "" {
492+ m.lifecycleMu.Lock()
493+ child := m.Get(strings.TrimSpace(childID))
494+ if child != nil {
495+ steered, activeTurn, err := child.steer(ctx, prompt, onSteered)
496+ if err == nil && steered {
497+ m.lifecycleMu.Unlock()
498+ m.notifyUpdate(child.spec.ID, false)
499+ return ResumeOutcome{TurnID: activeTurn, Steered: true}, nil
500+ }
501+ }
502+ m.lifecycleMu.Unlock()
503+ }
504+ if strings.TrimSpace(turnID) == "" {
505+ turnID = uuid.NewString()
506+ }
507+ if err := m.resumeWithTurn(ctx, childID, prompt, turnID); err != nil {
508+ return ResumeOutcome{}, err
509+ }
510+ return ResumeOutcome{TurnID: turnID}, nil
511+}
512+
513+// resumeWithTurn queues a follow-up turn with a caller-owned ID. The ID names the
446 // accepted follow-up turn in its completion, so a caller that must observe the514 // accepted follow-up turn in its completion, so a caller that must observe the
447 // outcome can subscribe with WatchCompletion before the turn can finish. The515 // outcome can subscribe with WatchCompletion before the turn can finish. The
448 // ID must be unique per child: replay rejects a duplicate turn.accepted record,516 // ID must be unique per child: replay rejects a duplicate turn.accepted record,
449 // so reusing one makes the journal unreplayable.517 // so reusing one makes the journal unreplayable.
450-func (m *ResidentManager) ResumeWithTurn(ctx context.Context, childID, prompt, turnID string) error {518+func (m *ResidentManager) resumeWithTurn(ctx context.Context, childID, prompt, turnID string) error {
451 if strings.TrimSpace(turnID) == "" {519 if strings.TrimSpace(turnID) == "" {
452 turnID = uuid.NewString()520 turnID = uuid.NewString()
453 }521 }
@@ -928,6 +996,27 @@ func (m *ResidentManager) UnmetRequired() []ResidentSnapshot {
928 return result996 return result
929 }997 }
930 998
999+// Interrupt cancels only a live child's running turn and drops its queued
1000+// follow-ups. The child stays live with its transcript, so a later resume
1001+// continues with full context. It reports false when no turn is running.
1002+func (m *ResidentManager) Interrupt(ctx context.Context, childID string) (bool, error) {
1003+ if m == nil {
1004+ return false, errors.New("resident manager: unavailable")
1005+ }
1006+ m.lifecycleMu.Lock()
1007+ defer m.lifecycleMu.Unlock()
1008+ child := m.Get(strings.TrimSpace(childID))
1009+ if child == nil {
1010+ return false, errors.New("resident manager: child is not live")
1011+ }
1012+ interrupted, err := child.interruptTurn(ctx)
1013+ if err != nil {
1014+ return false, err
1015+ }
1016+ m.notifyUpdate(child.spec.ID, false)
1017+ return interrupted, nil
1018+}
1019+
931 // Stop cancels one live child and waits for its control loop. A disk-only1020 // Stop cancels one live child and waits for its control loop. A disk-only
932 // interrupted child must be explicitly resumed by host construction instead.1021 // interrupted child must be explicitly resumed by host construction instead.
933 func (m *ResidentManager) Stop(ctx context.Context, childID string) error {1022 func (m *ResidentManager) Stop(ctx context.Context, childID string) error {
M packages/agent/subagents/resident_manager_test.go +69 -69
@@ -20,8 +20,8 @@ import (
2020
2121 func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) {
2222 run := make(chan string, 1)
23- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
24- return func(_ context.Context, prompt string) error { run <- prompt; return nil }, nil
23+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
24+ return ResidentTurnRunner(func(_ context.Context, prompt string) error { run <- prompt; return nil }), nil
2525 })
2626 defer manager.Close(context.Background())
2727
@@ -57,10 +57,10 @@ func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) {
5757 }
5858
5959 func TestResidentManagerCompletedAnswerCanResume(t *testing.T) {
60- manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {
61- return func(context.Context, string) error {
60+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) {
61+ return ResidentTurnRunner(func(context.Context, string) error {
6262 return journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}})
63- }, nil
63+ }), nil
6464 })
6565 t.Cleanup(func() { _ = manager.Close(context.Background()) })
6666 spec := ResidentChildSpec{ID: "completed-child", SessionID: "completed-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
@@ -82,11 +82,11 @@ func TestResidentManagerCompletedAnswerCanResume(t *testing.T) {
8282 }
8383 }
8484
85-// ResumeWithTurn lets a caller subscribe to the exact follow-up turn before it
85+// resumeWithTurn lets a caller subscribe to the exact follow-up turn before it
8686 // can complete.
8787 func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) {
88- manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) {
89- return func(context.Context, string) error { return nil }, nil
88+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
89+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
9090 })
9191 t.Cleanup(func() { _ = manager.Close(context.Background()) })
9292 initial, cancelInitial := manager.WatchCompletion("turn-child", "initial-turn")
@@ -101,8 +101,8 @@ func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) {
101101 }
102102 followUp, cancelFollowUp := manager.WatchCompletion("turn-child", "follow-up-turn")
103103 defer cancelFollowUp()
104- if err := manager.ResumeWithTurn(context.Background(), "turn-child", "continue", "follow-up-turn"); err != nil {
105- t.Fatalf("ResumeWithTurn: %v", err)
104+ if err := manager.resumeWithTurn(context.Background(), "turn-child", "continue", "follow-up-turn"); err != nil {
105+ t.Fatalf("resumeWithTurn: %v", err)
106106 }
107107 select {
108108 case completion := <-followUp:
@@ -121,8 +121,8 @@ func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) {
121121 // the completion is still addressable and the journal stays replayable.
122122 func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) {
123123 completions := make(chan ResidentCompletion, 4)
124- manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) {
125- return func(context.Context, string) error { return nil }, nil
124+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
125+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
126126 })
127127 t.Cleanup(func() { _ = manager.Close(context.Background()) })
128128 initial, cancelInitial := manager.WatchCompletion("blank-turn-child", "initial-turn")
@@ -136,8 +136,8 @@ func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) {
136136 t.Fatal("initial turn did not complete")
137137 }
138138 manager.SetCompletionObserver(func(completion ResidentCompletion) { completions <- completion })
139- if err := manager.ResumeWithTurn(context.Background(), "blank-turn-child", "continue", " "); err != nil {
140- t.Fatalf("ResumeWithTurn: %v", err)
139+ if err := manager.resumeWithTurn(context.Background(), "blank-turn-child", "continue", " "); err != nil {
140+ t.Fatalf("resumeWithTurn: %v", err)
141141 }
142142 select {
143143 case completion := <-completions:
@@ -151,10 +151,10 @@ func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) {
151151
152152 func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) {
153153 completed := make(chan ResidentCompletion, 1)
154- manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {
155- return func(context.Context, string) error {
154+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) {
155+ return ResidentTurnRunner(func(context.Context, string) error {
156156 return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "the actual child answer"}}}})
157- }, nil
157+ }), nil
158158 })
159159 manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion })
160160 t.Cleanup(func() { _ = manager.Close(context.Background()) })
@@ -179,13 +179,13 @@ func TestResidentManagerReportsQueuedTurnsWhenFinalResultCannotPersist(t *testin
179179 release := make(chan struct{})
180180 completed := make(chan ResidentCompletion, 2)
181181 var journal *ResidentJournal
182- manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, childJournal *ResidentJournal) (ResidentTurnRunner, error) {
182+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, childJournal *ResidentJournal) (ResidentRuntime, error) {
183183 journal = childJournal
184- return func(context.Context, string) error {
184+ return ResidentTurnRunner(func(context.Context, string) error {
185185 close(started)
186186 <-release
187187 return nil
188- }, nil
188+ }), nil
189189 })
190190 manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion })
191191 t.Cleanup(func() { _ = manager.Close(context.Background()) })
@@ -226,12 +226,12 @@ func TestResidentManagerReportsQueuedTurnsWhenFinalResultCannotPersist(t *testin
226226 func TestResidentManagerReportsAcceptedInterruptedTurn(t *testing.T) {
227227 started := make(chan struct{})
228228 completed := make(chan ResidentCompletion, 1)
229- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
230- return func(ctx context.Context, _ string) error {
229+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
230+ return ResidentTurnRunner(func(ctx context.Context, _ string) error {
231231 close(started)
232232 <-ctx.Done()
233233 return ctx.Err()
234- }, nil
234+ }), nil
235235 })
236236 manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion })
237237 t.Cleanup(func() { _ = manager.Close(context.Background()) })
@@ -261,12 +261,12 @@ func TestResidentManagerReportsAcceptedInterruptedTurn(t *testing.T) {
261261 func TestResidentManagerLiveReturnsChildSnapshot(t *testing.T) {
262262 started := make(chan struct{})
263263 release := make(chan struct{})
264- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
265- return func(context.Context, string) error {
264+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
265+ return ResidentTurnRunner(func(context.Context, string) error {
266266 close(started)
267267 <-release
268268 return nil
269- }, nil
269+ }), nil
270270 })
271271 defer manager.Close(context.Background())
272272 defer close(release)
@@ -294,8 +294,8 @@ func TestResidentManagerHistoryObserverSkipsTransientStreamEvents(t *testing.T)
294294 persist := make(chan struct{})
295295 release := make(chan struct{})
296296 historyUpdates := make(chan string, 1)
297- manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {
298- return func(context.Context, string) error {
297+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) {
298+ return ResidentTurnRunner(func(context.Context, string) error {
299299 close(started)
300300 if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streaming"}); err != nil {
301301 return err
@@ -307,7 +307,7 @@ func TestResidentManagerHistoryObserverSkipsTransientStreamEvents(t *testing.T)
307307 }
308308 <-release
309309 return nil
310- }, nil
310+ }), nil
311311 })
312312 manager.SetHistoryUpdateObserver(func(childID string) { historyUpdates <- childID })
313313 t.Cleanup(func() {
@@ -341,12 +341,12 @@ func TestResidentManagerActivityObserverTracksRunningChildrenWithoutPolling(t *t
341341 started := make(chan struct{})
342342 release := make(chan struct{})
343343 activity := make(chan bool, 2)
344- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
345- return func(context.Context, string) error {
344+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
345+ return ResidentTurnRunner(func(context.Context, string) error {
346346 close(started)
347347 <-release
348348 return nil
349- }, nil
349+ }), nil
350350 })
351351 manager.SetActivityObserver(func(active bool) { activity <- active })
352352 t.Cleanup(func() {
@@ -387,7 +387,7 @@ func TestResidentManagerActivityObserverTracksRunningChildrenWithoutPolling(t *t
387387
388388 func TestResidentManagerRecordsFactoryFailureAfterAcceptance(t *testing.T) {
389389 root := t.TempDir()
390- manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
390+ manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
391391 return nil, errors.New("synthetic factory failure")
392392 })
393393 spec := ResidentChildSpec{ID: "factory-failure", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
@@ -412,7 +412,7 @@ func TestResidentManagerReconcileIgnoresLegacyAgentsContainer(t *testing.T) {
412412 if err := os.WriteFile(filepath.Join(legacyChild, "events.jsonl"), []byte("legacy\n"), 0o600); err != nil {
413413 t.Fatal(err)
414414 }
415- manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
415+ manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
416416 return nil, nil
417417 })
418418 if errs := manager.Reconcile(); len(errs) != 0 {
@@ -425,15 +425,15 @@ func TestResidentManagerReconcileIgnoresLegacyAgentsContainer(t *testing.T) {
425425
426426 func TestResidentManagerResumeRebuildsStoppedChild(t *testing.T) {
427427 runs := make(chan string, 2)
428- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
429- return func(ctx context.Context, prompt string) error {
428+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
429+ return ResidentTurnRunner(func(ctx context.Context, prompt string) error {
430430 runs <- prompt
431431 if prompt == "initial" {
432432 <-ctx.Done()
433433 return ctx.Err()
434434 }
435435 return nil
436- }, nil
436+ }), nil
437437 })
438438 defer manager.Close(context.Background())
439439 spec := ResidentChildSpec{ID: "stopped-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", Required: true}
@@ -470,11 +470,11 @@ func TestResidentManagerPreparesWorkspaceBeforeAcceptance(t *testing.T) {
470470 t.Fatalf("workspace request = %#v", req)
471471 }
472472 return SharedWorkspace{Root: req.RepositoryRoot}.Prepare(context.Background(), req)
473- }, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) {
473+ }, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
474474 if !prepared || spec.Workspace != "/repo" {
475475 t.Fatalf("factory spec = %#v, prepared=%t", spec, prepared)
476476 }
477- return func(context.Context, string) error { return nil }, nil
477+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
478478 })
479479 defer manager.Close(context.Background())
480480 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "workspace-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo"}, "task"); err != nil {
@@ -487,8 +487,8 @@ func TestResidentManagerRejectsWorkspaceOutsideAllowedRootsBeforeAcceptance(t *t
487487 manager := newResidentManager(t.TempDir(), SubagentPolicy{AllowedRoots: []string{t.TempDir()}}, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) {
488488 prepared = true
489489 return nil, nil
490- }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
491- return func(context.Context, string) error { return nil }, nil
490+ }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
491+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
492492 })
493493 outside := t.TempDir()
494494 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "outside-root", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: outside}, "task"); err == nil {
@@ -504,8 +504,8 @@ func TestResidentManagerCapturesSuccessfulWorktreeBeforeCleanup(t *testing.T) {
504504 workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree, capture: WorkspaceCapture{Patch: []byte("patch"), ChangedFiles: []string{"changed.go"}}}
505505 manager := NewResidentManagerWithWorkspace(root, 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) {
506506 return workspace, nil
507- }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
508- return func(context.Context, string) error { return nil }, nil
507+ }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
508+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
509509 })
510510 defer manager.Close(context.Background())
511511 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "capture-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil {
@@ -545,8 +545,8 @@ func TestResidentManagerCapturesFailedWorktreePatch(t *testing.T) {
545545 workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree, capture: WorkspaceCapture{Patch: []byte("partial patch"), ChangedFiles: []string{"changed.go"}}}
546546 manager := NewResidentManagerWithWorkspace(root, 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) {
547547 return workspace, nil
548- }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
549- return func(context.Context, string) error { return errors.New("turn failed") }, nil
548+ }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
549+ return ResidentTurnRunner(func(context.Context, string) error { return errors.New("turn failed") }), nil
550550 })
551551 defer manager.Close(context.Background())
552552 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "failed-capture", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil {
@@ -582,8 +582,8 @@ func TestResidentManagerCapturesFailedWorktreePatch(t *testing.T) {
582582
583583 func TestResidentManagerRetainsFailedWorktree(t *testing.T) {
584584 workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree}
585- manager := NewResidentManagerWithWorkspace(t.TempDir(), 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { return workspace, nil }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
586- return func(context.Context, string) error { return errors.New("turn failed") }, nil
585+ manager := NewResidentManagerWithWorkspace(t.TempDir(), 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { return workspace, nil }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
586+ return ResidentTurnRunner(func(context.Context, string) error { return errors.New("turn failed") }), nil
587587 })
588588 defer manager.Close(context.Background())
589589 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "failed-worktree", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil {
@@ -608,10 +608,10 @@ func TestResidentManagerCapturesRealWorktreePatch(t *testing.T) {
608608 t.Skip("git is required")
609609 }
610610 root, repo := t.TempDir(), initTestRepo(t)
611- manager := NewResidentManager(root, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) {
612- return func(context.Context, string) error {
611+ manager := NewResidentManager(root, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
612+ return ResidentTurnRunner(func(context.Context, string) error {
613613 return os.WriteFile(filepath.Join(spec.Workspace, "README.md"), []byte("resident change\n"), 0o600)
614- }, nil
614+ }), nil
615615 })
616616 defer manager.Close(context.Background())
617617 child, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "git-worktree", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: repo, WorkspaceMode: WorkspaceWorktree, WorkspaceBase: "HEAD", WorkspaceCapture: CapturePatch}, "task")
@@ -673,8 +673,8 @@ func (w *testResidentWorkspace) Cleanup(context.Context) error { w.cleaned.Store
673673
674674 func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing.T) {
675675 root := t.TempDir()
676- manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
677- return func(context.Context, string) error { return nil }, nil
676+ manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
677+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
678678 })
679679 defer manager.Close(context.Background())
680680 spec := ResidentChildSpec{ID: "duplicate", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
@@ -794,9 +794,9 @@ func TestResidentManagerReconcileMakesInterruptedJournalDiscoverableWithoutRepla
794794 t.Fatal(err)
795795 }
796796 runs := 0
797- manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
797+ manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
798798 runs++
799- return func(context.Context, string) error { return nil }, nil
799+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
800800 })
801801 if errs := manager.Reconcile(); len(errs) != 0 {
802802 t.Fatalf("Reconcile errors = %v", errs)
@@ -881,8 +881,8 @@ func TestResidentManagerReconcileDoesNotMutateForeignProcessJournal(t *testing.T
881881 t.Fatal(err)
882882 }
883883
884- observer := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
885- return func(context.Context, string) error { return nil }, nil
884+ observer := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
885+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
886886 })
887887 if errs := observer.Reconcile(); len(errs) != 0 {
888888 t.Fatalf("Reconcile errors = %v", errs)
@@ -944,11 +944,11 @@ func TestResidentManagerExplicitlyResumesRecoveredChildWithoutReplayingTask(t *t
944944 t.Fatal(err)
945945 }
946946 runs := make(chan string, 1)
947- manager := NewResidentManager(root, func(got ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) {
947+ manager := NewResidentManager(root, func(got ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
948948 if got.SessionID != spec.SessionID {
949949 t.Fatalf("session = %q, want %q", got.SessionID, spec.SessionID)
950950 }
951- return func(_ context.Context, prompt string) error { runs <- prompt; return nil }, nil
951+ return ResidentTurnRunner(func(_ context.Context, prompt string) error { runs <- prompt; return nil }), nil
952952 })
953953 defer manager.Close(context.Background())
954954 if errs := manager.Reconcile(); len(errs) != 0 {
@@ -989,8 +989,8 @@ func TestResidentManagerExplicitResumeReusesInterruptedWorktree(t *testing.T) {
989989 t.Fatalf("resume workspace request = %#v", req)
990990 }
991991 return workspace, nil
992- }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
993- return func(context.Context, string) error { return nil }, nil
992+ }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
993+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
994994 })
995995 defer manager.Close(context.Background())
996996 if errs := manager.Reconcile(); len(errs) != 0 {
@@ -1004,14 +1004,14 @@ func TestResidentManagerExplicitResumeReusesInterruptedWorktree(t *testing.T) {
10041004 func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) {
10051005 started := make(chan string, 2)
10061006 release := make(chan struct{})
1007- manager := NewResidentManagerWithPolicy(t.TempDir(), SubagentPolicy{MaxConcurrent: 1, QueueTimeout: 20 * time.Millisecond}, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) {
1008- return func(_ context.Context, _ string) error {
1007+ manager := NewResidentManagerWithPolicy(t.TempDir(), SubagentPolicy{MaxConcurrent: 1, QueueTimeout: 20 * time.Millisecond}, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
1008+ return ResidentTurnRunner(func(_ context.Context, _ string) error {
10091009 started <- spec.ID
10101010 if spec.ID == "first" {
10111011 <-release
10121012 }
10131013 return nil
1014- }, nil
1014+ }), nil
10151015 })
10161016 defer manager.Close(context.Background())
10171017 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "first", SessionID: "first", Provider: "openai", Model: "gpt-5"}, "first"); err != nil {
@@ -1048,8 +1048,8 @@ func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) {
10481048 }
10491049
10501050 func TestResidentManagerNotifiesAcceptedObserverForFollowUps(t *testing.T) {
1051- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
1052- return func(context.Context, string) error { return nil }, nil
1051+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
1052+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
10531053 })
10541054 defer manager.Close(context.Background())
10551055 accepted := make(chan string, 2)
@@ -1082,7 +1082,7 @@ func TestResidentManagerReconcileReplacesStaleRecoveredSnapshots(t *testing.T) {
10821082 if err := journal.Close(); err != nil {
10831083 t.Fatal(err)
10841084 }
1085- manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { return nil, nil })
1085+ manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { return nil, nil })
10861086 if errs := manager.Reconcile(); len(errs) != 0 || len(manager.Snapshot()) != 1 {
10871087 t.Fatalf("first Reconcile = %v, snapshots=%#v", errs, manager.Snapshot())
10881088 }
@@ -1097,8 +1097,8 @@ func TestResidentManagerReconcileReplacesStaleRecoveredSnapshots(t *testing.T) {
10971097 func TestResidentManagerLimitsConcurrentTurnsGlobally(t *testing.T) {
10981098 started := make(chan string, DefaultResidentConcurrency+1)
10991099 release := make(chan struct{})
1100- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
1101- return func(ctx context.Context, prompt string) error {
1100+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
1101+ return ResidentTurnRunner(func(ctx context.Context, prompt string) error {
11021102 started <- prompt
11031103 select {
11041104 case <-release:
@@ -1106,7 +1106,7 @@ func TestResidentManagerLimitsConcurrentTurnsGlobally(t *testing.T) {
11061106 case <-ctx.Done():
11071107 return ctx.Err()
11081108 }
1109- }, nil
1109+ }), nil
11101110 })
11111111 defer manager.Close(context.Background())
11121112
@@ -20,8 +20,8 @@ import (
20 20
21 func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) {21 func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) {
22 run := make(chan string, 1)22 run := make(chan string, 1)
23- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {23+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
24- return func(_ context.Context, prompt string) error { run <- prompt; return nil }, nil24+ return ResidentTurnRunner(func(_ context.Context, prompt string) error { run <- prompt; return nil }), nil
25 })25 })
26 defer manager.Close(context.Background())26 defer manager.Close(context.Background())
27 27
@@ -57,10 +57,10 @@ func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) {
57 }57 }
58 58
59 func TestResidentManagerCompletedAnswerCanResume(t *testing.T) {59 func TestResidentManagerCompletedAnswerCanResume(t *testing.T) {
60- manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {60+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) {
61- return func(context.Context, string) error {61+ return ResidentTurnRunner(func(context.Context, string) error {
62 return journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}})62 return journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}})
63- }, nil63+ }), nil
64 })64 })
65 t.Cleanup(func() { _ = manager.Close(context.Background()) })65 t.Cleanup(func() { _ = manager.Close(context.Background()) })
66 spec := ResidentChildSpec{ID: "completed-child", SessionID: "completed-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}66 spec := ResidentChildSpec{ID: "completed-child", SessionID: "completed-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
@@ -82,11 +82,11 @@ func TestResidentManagerCompletedAnswerCanResume(t *testing.T) {
82 }82 }
83 }83 }
84 84
85-// ResumeWithTurn lets a caller subscribe to the exact follow-up turn before it85+// resumeWithTurn lets a caller subscribe to the exact follow-up turn before it
86 // can complete.86 // can complete.
87 func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) {87 func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) {
88- manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) {88+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
89- return func(context.Context, string) error { return nil }, nil89+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
90 })90 })
91 t.Cleanup(func() { _ = manager.Close(context.Background()) })91 t.Cleanup(func() { _ = manager.Close(context.Background()) })
92 initial, cancelInitial := manager.WatchCompletion("turn-child", "initial-turn")92 initial, cancelInitial := manager.WatchCompletion("turn-child", "initial-turn")
@@ -101,8 +101,8 @@ func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) {
101 }101 }
102 followUp, cancelFollowUp := manager.WatchCompletion("turn-child", "follow-up-turn")102 followUp, cancelFollowUp := manager.WatchCompletion("turn-child", "follow-up-turn")
103 defer cancelFollowUp()103 defer cancelFollowUp()
104- if err := manager.ResumeWithTurn(context.Background(), "turn-child", "continue", "follow-up-turn"); err != nil {104+ if err := manager.resumeWithTurn(context.Background(), "turn-child", "continue", "follow-up-turn"); err != nil {
105- t.Fatalf("ResumeWithTurn: %v", err)105+ t.Fatalf("resumeWithTurn: %v", err)
106 }106 }
107 select {107 select {
108 case completion := <-followUp:108 case completion := <-followUp:
@@ -121,8 +121,8 @@ func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) {
121 // the completion is still addressable and the journal stays replayable.121 // the completion is still addressable and the journal stays replayable.
122 func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) {122 func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) {
123 completions := make(chan ResidentCompletion, 4)123 completions := make(chan ResidentCompletion, 4)
124- manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) {124+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
125- return func(context.Context, string) error { return nil }, nil125+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
126 })126 })
127 t.Cleanup(func() { _ = manager.Close(context.Background()) })127 t.Cleanup(func() { _ = manager.Close(context.Background()) })
128 initial, cancelInitial := manager.WatchCompletion("blank-turn-child", "initial-turn")128 initial, cancelInitial := manager.WatchCompletion("blank-turn-child", "initial-turn")
@@ -136,8 +136,8 @@ func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) {
136 t.Fatal("initial turn did not complete")136 t.Fatal("initial turn did not complete")
137 }137 }
138 manager.SetCompletionObserver(func(completion ResidentCompletion) { completions <- completion })138 manager.SetCompletionObserver(func(completion ResidentCompletion) { completions <- completion })
139- if err := manager.ResumeWithTurn(context.Background(), "blank-turn-child", "continue", " "); err != nil {139+ if err := manager.resumeWithTurn(context.Background(), "blank-turn-child", "continue", " "); err != nil {
140- t.Fatalf("ResumeWithTurn: %v", err)140+ t.Fatalf("resumeWithTurn: %v", err)
141 }141 }
142 select {142 select {
143 case completion := <-completions:143 case completion := <-completions:
@@ -151,10 +151,10 @@ func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) {
151 151
152 func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) {152 func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) {
153 completed := make(chan ResidentCompletion, 1)153 completed := make(chan ResidentCompletion, 1)
154- manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {154+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) {
155- return func(context.Context, string) error {155+ return ResidentTurnRunner(func(context.Context, string) error {
156 return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "the actual child answer"}}}})156 return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "the actual child answer"}}}})
157- }, nil157+ }), nil
158 })158 })
159 manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion })159 manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion })
160 t.Cleanup(func() { _ = manager.Close(context.Background()) })160 t.Cleanup(func() { _ = manager.Close(context.Background()) })
@@ -179,13 +179,13 @@ func TestResidentManagerReportsQueuedTurnsWhenFinalResultCannotPersist(t *testin
179 release := make(chan struct{})179 release := make(chan struct{})
180 completed := make(chan ResidentCompletion, 2)180 completed := make(chan ResidentCompletion, 2)
181 var journal *ResidentJournal181 var journal *ResidentJournal
182- manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, childJournal *ResidentJournal) (ResidentTurnRunner, error) {182+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, childJournal *ResidentJournal) (ResidentRuntime, error) {
183 journal = childJournal183 journal = childJournal
184- return func(context.Context, string) error {184+ return ResidentTurnRunner(func(context.Context, string) error {
185 close(started)185 close(started)
186 <-release186 <-release
187 return nil187 return nil
188- }, nil188+ }), nil
189 })189 })
190 manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion })190 manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion })
191 t.Cleanup(func() { _ = manager.Close(context.Background()) })191 t.Cleanup(func() { _ = manager.Close(context.Background()) })
@@ -226,12 +226,12 @@ func TestResidentManagerReportsQueuedTurnsWhenFinalResultCannotPersist(t *testin
226 func TestResidentManagerReportsAcceptedInterruptedTurn(t *testing.T) {226 func TestResidentManagerReportsAcceptedInterruptedTurn(t *testing.T) {
227 started := make(chan struct{})227 started := make(chan struct{})
228 completed := make(chan ResidentCompletion, 1)228 completed := make(chan ResidentCompletion, 1)
229- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {229+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
230- return func(ctx context.Context, _ string) error {230+ return ResidentTurnRunner(func(ctx context.Context, _ string) error {
231 close(started)231 close(started)
232 <-ctx.Done()232 <-ctx.Done()
233 return ctx.Err()233 return ctx.Err()
234- }, nil234+ }), nil
235 })235 })
236 manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion })236 manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion })
237 t.Cleanup(func() { _ = manager.Close(context.Background()) })237 t.Cleanup(func() { _ = manager.Close(context.Background()) })
@@ -261,12 +261,12 @@ func TestResidentManagerReportsAcceptedInterruptedTurn(t *testing.T) {
261 func TestResidentManagerLiveReturnsChildSnapshot(t *testing.T) {261 func TestResidentManagerLiveReturnsChildSnapshot(t *testing.T) {
262 started := make(chan struct{})262 started := make(chan struct{})
263 release := make(chan struct{})263 release := make(chan struct{})
264- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {264+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
265- return func(context.Context, string) error {265+ return ResidentTurnRunner(func(context.Context, string) error {
266 close(started)266 close(started)
267 <-release267 <-release
268 return nil268 return nil
269- }, nil269+ }), nil
270 })270 })
271 defer manager.Close(context.Background())271 defer manager.Close(context.Background())
272 defer close(release)272 defer close(release)
@@ -294,8 +294,8 @@ func TestResidentManagerHistoryObserverSkipsTransientStreamEvents(t *testing.T)
294 persist := make(chan struct{})294 persist := make(chan struct{})
295 release := make(chan struct{})295 release := make(chan struct{})
296 historyUpdates := make(chan string, 1)296 historyUpdates := make(chan string, 1)
297- manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {297+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) {
298- return func(context.Context, string) error {298+ return ResidentTurnRunner(func(context.Context, string) error {
299 close(started)299 close(started)
300 if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streaming"}); err != nil {300 if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streaming"}); err != nil {
301 return err301 return err
@@ -307,7 +307,7 @@ func TestResidentManagerHistoryObserverSkipsTransientStreamEvents(t *testing.T)
307 }307 }
308 <-release308 <-release
309 return nil309 return nil
310- }, nil310+ }), nil
311 })311 })
312 manager.SetHistoryUpdateObserver(func(childID string) { historyUpdates <- childID })312 manager.SetHistoryUpdateObserver(func(childID string) { historyUpdates <- childID })
313 t.Cleanup(func() {313 t.Cleanup(func() {
@@ -341,12 +341,12 @@ func TestResidentManagerActivityObserverTracksRunningChildrenWithoutPolling(t *t
341 started := make(chan struct{})341 started := make(chan struct{})
342 release := make(chan struct{})342 release := make(chan struct{})
343 activity := make(chan bool, 2)343 activity := make(chan bool, 2)
344- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {344+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
345- return func(context.Context, string) error {345+ return ResidentTurnRunner(func(context.Context, string) error {
346 close(started)346 close(started)
347 <-release347 <-release
348 return nil348 return nil
349- }, nil349+ }), nil
350 })350 })
351 manager.SetActivityObserver(func(active bool) { activity <- active })351 manager.SetActivityObserver(func(active bool) { activity <- active })
352 t.Cleanup(func() {352 t.Cleanup(func() {
@@ -387,7 +387,7 @@ func TestResidentManagerActivityObserverTracksRunningChildrenWithoutPolling(t *t
387 387
388 func TestResidentManagerRecordsFactoryFailureAfterAcceptance(t *testing.T) {388 func TestResidentManagerRecordsFactoryFailureAfterAcceptance(t *testing.T) {
389 root := t.TempDir()389 root := t.TempDir()
390- manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {390+ manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
391 return nil, errors.New("synthetic factory failure")391 return nil, errors.New("synthetic factory failure")
392 })392 })
393 spec := ResidentChildSpec{ID: "factory-failure", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}393 spec := ResidentChildSpec{ID: "factory-failure", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
@@ -412,7 +412,7 @@ func TestResidentManagerReconcileIgnoresLegacyAgentsContainer(t *testing.T) {
412 if err := os.WriteFile(filepath.Join(legacyChild, "events.jsonl"), []byte("legacy\n"), 0o600); err != nil {412 if err := os.WriteFile(filepath.Join(legacyChild, "events.jsonl"), []byte("legacy\n"), 0o600); err != nil {
413 t.Fatal(err)413 t.Fatal(err)
414 }414 }
415- manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {415+ manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
416 return nil, nil416 return nil, nil
417 })417 })
418 if errs := manager.Reconcile(); len(errs) != 0 {418 if errs := manager.Reconcile(); len(errs) != 0 {
@@ -425,15 +425,15 @@ func TestResidentManagerReconcileIgnoresLegacyAgentsContainer(t *testing.T) {
425 425
426 func TestResidentManagerResumeRebuildsStoppedChild(t *testing.T) {426 func TestResidentManagerResumeRebuildsStoppedChild(t *testing.T) {
427 runs := make(chan string, 2)427 runs := make(chan string, 2)
428- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {428+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
429- return func(ctx context.Context, prompt string) error {429+ return ResidentTurnRunner(func(ctx context.Context, prompt string) error {
430 runs <- prompt430 runs <- prompt
431 if prompt == "initial" {431 if prompt == "initial" {
432 <-ctx.Done()432 <-ctx.Done()
433 return ctx.Err()433 return ctx.Err()
434 }434 }
435 return nil435 return nil
436- }, nil436+ }), nil
437 })437 })
438 defer manager.Close(context.Background())438 defer manager.Close(context.Background())
439 spec := ResidentChildSpec{ID: "stopped-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", Required: true}439 spec := ResidentChildSpec{ID: "stopped-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", Required: true}
@@ -470,11 +470,11 @@ func TestResidentManagerPreparesWorkspaceBeforeAcceptance(t *testing.T) {
470 t.Fatalf("workspace request = %#v", req)470 t.Fatalf("workspace request = %#v", req)
471 }471 }
472 return SharedWorkspace{Root: req.RepositoryRoot}.Prepare(context.Background(), req)472 return SharedWorkspace{Root: req.RepositoryRoot}.Prepare(context.Background(), req)
473- }, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) {473+ }, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
474 if !prepared || spec.Workspace != "/repo" {474 if !prepared || spec.Workspace != "/repo" {
475 t.Fatalf("factory spec = %#v, prepared=%t", spec, prepared)475 t.Fatalf("factory spec = %#v, prepared=%t", spec, prepared)
476 }476 }
477- return func(context.Context, string) error { return nil }, nil477+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
478 })478 })
479 defer manager.Close(context.Background())479 defer manager.Close(context.Background())
480 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "workspace-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo"}, "task"); err != nil {480 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "workspace-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo"}, "task"); err != nil {
@@ -487,8 +487,8 @@ func TestResidentManagerRejectsWorkspaceOutsideAllowedRootsBeforeAcceptance(t *t
487 manager := newResidentManager(t.TempDir(), SubagentPolicy{AllowedRoots: []string{t.TempDir()}}, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) {487 manager := newResidentManager(t.TempDir(), SubagentPolicy{AllowedRoots: []string{t.TempDir()}}, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) {
488 prepared = true488 prepared = true
489 return nil, nil489 return nil, nil
490- }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {490+ }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
491- return func(context.Context, string) error { return nil }, nil491+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
492 })492 })
493 outside := t.TempDir()493 outside := t.TempDir()
494 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "outside-root", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: outside}, "task"); err == nil {494 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "outside-root", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: outside}, "task"); err == nil {
@@ -504,8 +504,8 @@ func TestResidentManagerCapturesSuccessfulWorktreeBeforeCleanup(t *testing.T) {
504 workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree, capture: WorkspaceCapture{Patch: []byte("patch"), ChangedFiles: []string{"changed.go"}}}504 workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree, capture: WorkspaceCapture{Patch: []byte("patch"), ChangedFiles: []string{"changed.go"}}}
505 manager := NewResidentManagerWithWorkspace(root, 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) {505 manager := NewResidentManagerWithWorkspace(root, 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) {
506 return workspace, nil506 return workspace, nil
507- }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {507+ }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
508- return func(context.Context, string) error { return nil }, nil508+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
509 })509 })
510 defer manager.Close(context.Background())510 defer manager.Close(context.Background())
511 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "capture-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil {511 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "capture-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil {
@@ -545,8 +545,8 @@ func TestResidentManagerCapturesFailedWorktreePatch(t *testing.T) {
545 workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree, capture: WorkspaceCapture{Patch: []byte("partial patch"), ChangedFiles: []string{"changed.go"}}}545 workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree, capture: WorkspaceCapture{Patch: []byte("partial patch"), ChangedFiles: []string{"changed.go"}}}
546 manager := NewResidentManagerWithWorkspace(root, 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) {546 manager := NewResidentManagerWithWorkspace(root, 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) {
547 return workspace, nil547 return workspace, nil
548- }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {548+ }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
549- return func(context.Context, string) error { return errors.New("turn failed") }, nil549+ return ResidentTurnRunner(func(context.Context, string) error { return errors.New("turn failed") }), nil
550 })550 })
551 defer manager.Close(context.Background())551 defer manager.Close(context.Background())
552 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "failed-capture", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil {552 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "failed-capture", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil {
@@ -582,8 +582,8 @@ func TestResidentManagerCapturesFailedWorktreePatch(t *testing.T) {
582 582
583 func TestResidentManagerRetainsFailedWorktree(t *testing.T) {583 func TestResidentManagerRetainsFailedWorktree(t *testing.T) {
584 workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree}584 workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree}
585- manager := NewResidentManagerWithWorkspace(t.TempDir(), 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { return workspace, nil }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {585+ manager := NewResidentManagerWithWorkspace(t.TempDir(), 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { return workspace, nil }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
586- return func(context.Context, string) error { return errors.New("turn failed") }, nil586+ return ResidentTurnRunner(func(context.Context, string) error { return errors.New("turn failed") }), nil
587 })587 })
588 defer manager.Close(context.Background())588 defer manager.Close(context.Background())
589 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "failed-worktree", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil {589 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "failed-worktree", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil {
@@ -608,10 +608,10 @@ func TestResidentManagerCapturesRealWorktreePatch(t *testing.T) {
608 t.Skip("git is required")608 t.Skip("git is required")
609 }609 }
610 root, repo := t.TempDir(), initTestRepo(t)610 root, repo := t.TempDir(), initTestRepo(t)
611- manager := NewResidentManager(root, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) {611+ manager := NewResidentManager(root, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
612- return func(context.Context, string) error {612+ return ResidentTurnRunner(func(context.Context, string) error {
613 return os.WriteFile(filepath.Join(spec.Workspace, "README.md"), []byte("resident change\n"), 0o600)613 return os.WriteFile(filepath.Join(spec.Workspace, "README.md"), []byte("resident change\n"), 0o600)
614- }, nil614+ }), nil
615 })615 })
616 defer manager.Close(context.Background())616 defer manager.Close(context.Background())
617 child, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "git-worktree", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: repo, WorkspaceMode: WorkspaceWorktree, WorkspaceBase: "HEAD", WorkspaceCapture: CapturePatch}, "task")617 child, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "git-worktree", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: repo, WorkspaceMode: WorkspaceWorktree, WorkspaceBase: "HEAD", WorkspaceCapture: CapturePatch}, "task")
@@ -673,8 +673,8 @@ func (w *testResidentWorkspace) Cleanup(context.Context) error { w.cleaned.Store
673 673
674 func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing.T) {674 func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing.T) {
675 root := t.TempDir()675 root := t.TempDir()
676- manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {676+ manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
677- return func(context.Context, string) error { return nil }, nil677+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
678 })678 })
679 defer manager.Close(context.Background())679 defer manager.Close(context.Background())
680 spec := ResidentChildSpec{ID: "duplicate", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}680 spec := ResidentChildSpec{ID: "duplicate", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
@@ -794,9 +794,9 @@ func TestResidentManagerReconcileMakesInterruptedJournalDiscoverableWithoutRepla
794 t.Fatal(err)794 t.Fatal(err)
795 }795 }
796 runs := 0796 runs := 0
797- manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {797+ manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
798 runs++798 runs++
799- return func(context.Context, string) error { return nil }, nil799+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
800 })800 })
801 if errs := manager.Reconcile(); len(errs) != 0 {801 if errs := manager.Reconcile(); len(errs) != 0 {
802 t.Fatalf("Reconcile errors = %v", errs)802 t.Fatalf("Reconcile errors = %v", errs)
@@ -881,8 +881,8 @@ func TestResidentManagerReconcileDoesNotMutateForeignProcessJournal(t *testing.T
881 t.Fatal(err)881 t.Fatal(err)
882 }882 }
883 883
884- observer := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {884+ observer := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
885- return func(context.Context, string) error { return nil }, nil885+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
886 })886 })
887 if errs := observer.Reconcile(); len(errs) != 0 {887 if errs := observer.Reconcile(); len(errs) != 0 {
888 t.Fatalf("Reconcile errors = %v", errs)888 t.Fatalf("Reconcile errors = %v", errs)
@@ -944,11 +944,11 @@ func TestResidentManagerExplicitlyResumesRecoveredChildWithoutReplayingTask(t *t
944 t.Fatal(err)944 t.Fatal(err)
945 }945 }
946 runs := make(chan string, 1)946 runs := make(chan string, 1)
947- manager := NewResidentManager(root, func(got ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) {947+ manager := NewResidentManager(root, func(got ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
948 if got.SessionID != spec.SessionID {948 if got.SessionID != spec.SessionID {
949 t.Fatalf("session = %q, want %q", got.SessionID, spec.SessionID)949 t.Fatalf("session = %q, want %q", got.SessionID, spec.SessionID)
950 }950 }
951- return func(_ context.Context, prompt string) error { runs <- prompt; return nil }, nil951+ return ResidentTurnRunner(func(_ context.Context, prompt string) error { runs <- prompt; return nil }), nil
952 })952 })
953 defer manager.Close(context.Background())953 defer manager.Close(context.Background())
954 if errs := manager.Reconcile(); len(errs) != 0 {954 if errs := manager.Reconcile(); len(errs) != 0 {
@@ -989,8 +989,8 @@ func TestResidentManagerExplicitResumeReusesInterruptedWorktree(t *testing.T) {
989 t.Fatalf("resume workspace request = %#v", req)989 t.Fatalf("resume workspace request = %#v", req)
990 }990 }
991 return workspace, nil991 return workspace, nil
992- }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {992+ }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
993- return func(context.Context, string) error { return nil }, nil993+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
994 })994 })
995 defer manager.Close(context.Background())995 defer manager.Close(context.Background())
996 if errs := manager.Reconcile(); len(errs) != 0 {996 if errs := manager.Reconcile(); len(errs) != 0 {
@@ -1004,14 +1004,14 @@ func TestResidentManagerExplicitResumeReusesInterruptedWorktree(t *testing.T) {
1004 func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) {1004 func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) {
1005 started := make(chan string, 2)1005 started := make(chan string, 2)
1006 release := make(chan struct{})1006 release := make(chan struct{})
1007- manager := NewResidentManagerWithPolicy(t.TempDir(), SubagentPolicy{MaxConcurrent: 1, QueueTimeout: 20 * time.Millisecond}, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) {1007+ manager := NewResidentManagerWithPolicy(t.TempDir(), SubagentPolicy{MaxConcurrent: 1, QueueTimeout: 20 * time.Millisecond}, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) {
1008- return func(_ context.Context, _ string) error {1008+ return ResidentTurnRunner(func(_ context.Context, _ string) error {
1009 started <- spec.ID1009 started <- spec.ID
1010 if spec.ID == "first" {1010 if spec.ID == "first" {
1011 <-release1011 <-release
1012 }1012 }
1013 return nil1013 return nil
1014- }, nil1014+ }), nil
1015 })1015 })
1016 defer manager.Close(context.Background())1016 defer manager.Close(context.Background())
1017 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "first", SessionID: "first", Provider: "openai", Model: "gpt-5"}, "first"); err != nil {1017 if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "first", SessionID: "first", Provider: "openai", Model: "gpt-5"}, "first"); err != nil {
@@ -1048,8 +1048,8 @@ func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) {
1048 }1048 }
1049 1049
1050 func TestResidentManagerNotifiesAcceptedObserverForFollowUps(t *testing.T) {1050 func TestResidentManagerNotifiesAcceptedObserverForFollowUps(t *testing.T) {
1051- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {1051+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
1052- return func(context.Context, string) error { return nil }, nil1052+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
1053 })1053 })
1054 defer manager.Close(context.Background())1054 defer manager.Close(context.Background())
1055 accepted := make(chan string, 2)1055 accepted := make(chan string, 2)
@@ -1082,7 +1082,7 @@ func TestResidentManagerReconcileReplacesStaleRecoveredSnapshots(t *testing.T) {
1082 if err := journal.Close(); err != nil {1082 if err := journal.Close(); err != nil {
1083 t.Fatal(err)1083 t.Fatal(err)
1084 }1084 }
1085- manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { return nil, nil })1085+ manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { return nil, nil })
1086 if errs := manager.Reconcile(); len(errs) != 0 || len(manager.Snapshot()) != 1 {1086 if errs := manager.Reconcile(); len(errs) != 0 || len(manager.Snapshot()) != 1 {
1087 t.Fatalf("first Reconcile = %v, snapshots=%#v", errs, manager.Snapshot())1087 t.Fatalf("first Reconcile = %v, snapshots=%#v", errs, manager.Snapshot())
1088 }1088 }
@@ -1097,8 +1097,8 @@ func TestResidentManagerReconcileReplacesStaleRecoveredSnapshots(t *testing.T) {
1097 func TestResidentManagerLimitsConcurrentTurnsGlobally(t *testing.T) {1097 func TestResidentManagerLimitsConcurrentTurnsGlobally(t *testing.T) {
1098 started := make(chan string, DefaultResidentConcurrency+1)1098 started := make(chan string, DefaultResidentConcurrency+1)
1099 release := make(chan struct{})1099 release := make(chan struct{})
1100- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {1100+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
1101- return func(ctx context.Context, prompt string) error {1101+ return ResidentTurnRunner(func(ctx context.Context, prompt string) error {
1102 started <- prompt1102 started <- prompt
1103 select {1103 select {
1104 case <-release:1104 case <-release:
@@ -1106,7 +1106,7 @@ func TestResidentManagerLimitsConcurrentTurnsGlobally(t *testing.T) {
1106 case <-ctx.Done():1106 case <-ctx.Done():
1107 return ctx.Err()1107 return ctx.Err()
1108 }1108 }
1109- }, nil1109+ }), nil
1110 })1110 })
1111 defer manager.Close(context.Background())1111 defer manager.Close(context.Background())
1112 1112
M packages/agent/subagents/resident_snapshot_test.go +2 -2
@@ -8,8 +8,8 @@ import (
88 )
99
1010 func TestResidentManagerSnapshotPageAndLookupAreBounded(t *testing.T) {
11- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
12- return func(context.Context, string) error { return nil }, nil
11+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
12+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
1313 })
1414 t.Cleanup(func() { _ = manager.Close(context.Background()) })
1515 for i := 0; i < 5; i++ {
@@ -8,8 +8,8 @@ import (
8 )8 )
9 9
10 func TestResidentManagerSnapshotPageAndLookupAreBounded(t *testing.T) {10 func TestResidentManagerSnapshotPageAndLookupAreBounded(t *testing.T) {
11- manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {11+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) {
12- return func(context.Context, string) error { return nil }, nil12+ return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
13 })13 })
14 t.Cleanup(func() { _ = manager.Close(context.Background()) })14 t.Cleanup(func() { _ = manager.Close(context.Background()) })
15 for i := 0; i < 5; i++ {15 for i := 0; i < 5; i++ {
A packages/agent/subagents/resident_steer_test.go +325 -0
@@ -0,0 +1,325 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+ "testing"
8+ "time"
9+)
10+
11+// steerableRuntime is a ResidentRuntime whose turn blocks until released or
12+// canceled, recording steered follow-ups it would deliver at the next boundary.
13+type steerableRuntime struct {
14+ started chan string
15+ release chan struct{}
16+
17+ mu sync.Mutex
18+ running bool
19+ pending []string
20+ delivered []string
21+}
22+
23+func newSteerableRuntime() *steerableRuntime {
24+ return &steerableRuntime{started: make(chan string, 4), release: make(chan struct{})}
25+}
26+
27+func (r *steerableRuntime) Run(ctx context.Context, prompt string) error {
28+ r.mu.Lock()
29+ r.running = true
30+ r.mu.Unlock()
31+ defer func() {
32+ r.mu.Lock()
33+ r.running = false
34+ r.mu.Unlock()
35+ }()
36+ r.started <- prompt
37+ select {
38+ case <-ctx.Done():
39+ return ctx.Err()
40+ case <-r.release:
41+ }
42+ r.mu.Lock()
43+ r.delivered = append(r.delivered, r.pending...)
44+ r.pending = nil
45+ r.mu.Unlock()
46+ return nil
47+}
48+
49+func (r *steerableRuntime) Steer(prompt string) bool {
50+ r.mu.Lock()
51+ defer r.mu.Unlock()
52+ if !r.running {
53+ return false
54+ }
55+ r.pending = append(r.pending, prompt)
56+ return true
57+}
58+
59+func (r *steerableRuntime) PendingSteers() int {
60+ r.mu.Lock()
61+ defer r.mu.Unlock()
62+ return len(r.pending)
63+}
64+
65+func (r *steerableRuntime) DrainSteers() []string {
66+ r.mu.Lock()
67+ defer r.mu.Unlock()
68+ out := r.pending
69+ r.pending = nil
70+ return out
71+}
72+
73+func (r *steerableRuntime) awaitStart(t *testing.T) string {
74+ t.Helper()
75+ select {
76+ case prompt := <-r.started:
77+ return prompt
78+ case <-time.After(5 * time.Second):
79+ t.Fatal("turn did not start")
80+ return ""
81+ }
82+}
83+
84+func newSteerTestManager(t *testing.T, runtime ResidentRuntime) (*ResidentManager, chan ResidentCompletion) {
85+ t.Helper()
86+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { return runtime, nil })
87+ completions := make(chan ResidentCompletion, 8)
88+ manager.SetCompletionObserver(func(c ResidentCompletion) { completions <- c })
89+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
90+ return manager, completions
91+}
92+
93+func drainCompletions(t *testing.T, completions chan ResidentCompletion, want int) []ResidentCompletion {
94+ t.Helper()
95+ var got []ResidentCompletion
96+ for len(got) < want {
97+ select {
98+ case c := <-completions:
99+ got = append(got, c)
100+ case <-time.After(5 * time.Second):
101+ t.Fatalf("got %d completions, want %d: %#v", len(got), want, got)
102+ }
103+ }
104+ select {
105+ case extra := <-completions:
106+ t.Fatalf("unexpected extra completion %#v", extra)
107+ case <-time.After(50 * time.Millisecond):
108+ }
109+ return got
110+}
111+
112+// Issue 226: stopping a child must not echo an undelivered queued follow-up
113+// back to the parent as if it were an interrupted task.
114+func TestResidentStopReportsQueuedFollowUpAsDroppedNotInterrupted(t *testing.T) {
115+ runtime := ResidentTurnRunner(func(ctx context.Context, _ string) error { <-ctx.Done(); return ctx.Err() })
116+ started := make(chan struct{}, 1)
117+ manager, completions := newSteerTestManager(t, ResidentTurnRunner(func(ctx context.Context, prompt string) error {
118+ started <- struct{}{}
119+ return runtime(ctx, prompt)
120+ }))
121+ spec := ResidentChildSpec{ID: "stop-echo", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
122+ if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil {
123+ t.Fatal(err)
124+ }
125+ <-started
126+ if _, err := manager.ResumeFollowUp(t.Context(), spec.ID, "stop and report now", ResumeQueue, "follow-up", nil); err != nil {
127+ t.Fatal(err)
128+ }
129+ if err := manager.Stop(t.Context(), spec.ID); err != nil {
130+ t.Fatal(err)
131+ }
132+ got := drainCompletions(t, completions, 2)
133+ byTurn := map[string]Completion{}
134+ for _, c := range got {
135+ byTurn[c.TurnID] = c.Completion()
136+ }
137+ if byTurn["initial"].Status != string(ResidentInterrupted) {
138+ t.Fatalf("initial completion = %#v", byTurn["initial"])
139+ }
140+ if byTurn["follow-up"].Status != CompletionDropped {
141+ t.Fatalf("follow-up completion = %#v, want dropped", byTurn["follow-up"])
142+ }
143+ update := FormatCompletionUpdate([]Completion{byTurn["initial"], byTurn["follow-up"]}, "")
144+ if strings.Contains(update, "interrupted — stop and report now") || !strings.Contains(update, "dropped (queued follow-up never started: stop and report now)") {
145+ t.Fatalf("update = %q", update)
146+ }
147+}
148+
149+// A steered follow-up joins the running turn: no new turn, no extra
150+// completion, and the running runtime receives it.
151+func TestResidentResumeSteersRunningTurn(t *testing.T) {
152+ runtime := newSteerableRuntime()
153+ manager, completions := newSteerTestManager(t, runtime)
154+ spec := ResidentChildSpec{ID: "steered", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
155+ if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil {
156+ t.Fatal(err)
157+ }
158+ runtime.awaitStart(t)
159+ var watched string
160+ outcome, err := manager.ResumeFollowUp(t.Context(), spec.ID, "wrap up now", ResumeSteer, "unused", func(turnID string) { watched = turnID })
161+ if err != nil {
162+ t.Fatal(err)
163+ }
164+ if !outcome.Steered || outcome.TurnID != "initial" || watched != "initial" {
165+ t.Fatalf("outcome = %#v, watched = %q", outcome, watched)
166+ }
167+ if snapshot, _ := manager.SnapshotFor(spec.ID); snapshot.PendingFollowUps != 1 {
168+ t.Fatalf("pending follow-ups = %d, want 1", snapshot.PendingFollowUps)
169+ }
170+ close(runtime.release)
171+ got := drainCompletions(t, completions, 1)
172+ if got[0].TurnID != "initial" || got[0].Err != nil || len(got[0].Undelivered) != 0 {
173+ t.Fatalf("completion = %#v", got[0])
174+ }
175+ if len(runtime.delivered) != 1 || runtime.delivered[0] != "wrap up now" {
176+ t.Fatalf("delivered = %#v", runtime.delivered)
177+ }
178+}
179+
180+// An idle child cannot be steered, so steer mode falls back to a new turn.
181+func TestResidentResumeSteerFallsBackToNewTurnWhenIdle(t *testing.T) {
182+ runtime := newSteerableRuntime()
183+ close(runtime.release)
184+ manager, completions := newSteerTestManager(t, runtime)
185+ spec := ResidentChildSpec{ID: "idle", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
186+ if _, err := manager.Spawn(t.Context(), spec, "task"); err != nil {
187+ t.Fatal(err)
188+ }
189+ drainCompletions(t, completions, 1)
190+ outcome, err := manager.ResumeFollowUp(t.Context(), spec.ID, "next", ResumeSteer, "second", nil)
191+ if err != nil || outcome.Steered || outcome.TurnID != "second" {
192+ t.Fatalf("outcome = %#v, err = %v", outcome, err)
193+ }
194+ if got := drainCompletions(t, completions, 1); got[0].TurnID != "second" {
195+ t.Fatalf("completion = %#v", got[0])
196+ }
197+}
198+
199+// Stopping a child with a steered follow-up it never read reports that
200+// follow-up as undelivered on the interrupted turn.
201+func TestResidentStopReportsUndeliveredSteer(t *testing.T) {
202+ runtime := newSteerableRuntime()
203+ manager, completions := newSteerTestManager(t, runtime)
204+ spec := ResidentChildSpec{ID: "undelivered", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
205+ if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil {
206+ t.Fatal(err)
207+ }
208+ runtime.awaitStart(t)
209+ if _, err := manager.ResumeFollowUp(t.Context(), spec.ID, "stop and report now", ResumeSteer, "", nil); err != nil {
210+ t.Fatal(err)
211+ }
212+ if err := manager.Stop(t.Context(), spec.ID); err != nil {
213+ t.Fatal(err)
214+ }
215+ got := drainCompletions(t, completions, 1)
216+ completion := got[0].Completion()
217+ if completion.Status != string(ResidentInterrupted) || len(completion.Undelivered) != 1 || completion.Undelivered[0] != "stop and report now" {
218+ t.Fatalf("completion = %#v", completion)
219+ }
220+ if update := FormatCompletionUpdate([]Completion{completion}, ""); !strings.Contains(update, "undelivered: 1 follow-up(s) the child never read:\n - stop and report now") {
221+ t.Fatalf("update = %q", update)
222+ }
223+}
224+
225+// Interrupt cancels only the running turn; the child stays live and a later
226+// resume runs as a new turn with the same runtime.
227+func TestResidentInterruptKeepsChildResumable(t *testing.T) {
228+ runtime := newSteerableRuntime()
229+ manager, completions := newSteerTestManager(t, runtime)
230+ spec := ResidentChildSpec{ID: "interrupted", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true}
231+ if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil {
232+ t.Fatal(err)
233+ }
234+ runtime.awaitStart(t)
235+ if _, err := manager.ResumeFollowUp(t.Context(), spec.ID, "queued work", ResumeQueue, "queued", nil); err != nil {
236+ t.Fatal(err)
237+ }
238+ interrupted, err := manager.Interrupt(t.Context(), spec.ID)
239+ if err != nil || !interrupted {
240+ t.Fatalf("Interrupt = %t, %v", interrupted, err)
241+ }
242+ got := drainCompletions(t, completions, 2)
243+ byTurn := map[string]Completion{}
244+ for _, c := range got {
245+ byTurn[c.TurnID] = c.Completion()
246+ }
247+ if byTurn["initial"].Status != string(ResidentInterrupted) || byTurn["queued"].Status != CompletionDropped {
248+ t.Fatalf("completions = %#v", byTurn)
249+ }
250+ if manager.Get(spec.ID) == nil {
251+ t.Fatal("interrupt removed the live child")
252+ }
253+ if again, err := manager.Interrupt(t.Context(), spec.ID); err != nil || again {
254+ t.Fatalf("second Interrupt = %t, %v, want no running turn", again, err)
255+ }
256+ close(runtime.release)
257+ outcome, err := manager.ResumeFollowUp(t.Context(), spec.ID, "wrap up with what you have", ResumeSteer, "wrap-up", nil)
258+ if err != nil || outcome.Steered {
259+ t.Fatalf("resume after interrupt = %#v, %v", outcome, err)
260+ }
261+ if prompt := runtime.awaitStart(t); prompt != "wrap up with what you have" {
262+ t.Fatalf("resumed prompt = %q", prompt)
263+ }
264+ if got := drainCompletions(t, completions, 1); got[0].TurnID != "wrap-up" || got[0].Err != nil {
265+ t.Fatalf("wrap-up completion = %#v", got[0])
266+ }
267+ if len(manager.UnmetRequired()) != 0 {
268+ t.Fatal("successful wrap-up left required work unmet")
269+ }
270+}
271+
272+// An interrupt can be accepted just before the turn finishes on its own. The
273+// turn must still report interrupted exactly once, and a late interrupt on the
274+// now-idle child must be refused.
275+func TestResidentInterruptRacingNaturalCompletion(t *testing.T) {
276+ started, release := make(chan struct{}, 1), make(chan struct{})
277+ // This runtime ignores cancellation, so its turn ends successfully after
278+ // the interrupt was already accepted.
279+ runtime := ResidentTurnRunner(func(context.Context, string) error {
280+ started <- struct{}{}
281+ <-release
282+ return nil
283+ })
284+ manager, completions := newSteerTestManager(t, runtime)
285+ spec := ResidentChildSpec{ID: "racing", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
286+ if _, err := manager.Spawn(t.Context(), spec, "work"); err != nil {
287+ t.Fatal(err)
288+ }
289+ <-started
290+ if ok, err := manager.Interrupt(t.Context(), spec.ID); err != nil || !ok {
291+ t.Fatalf("Interrupt = %t, %v", ok, err)
292+ }
293+ close(release)
294+ got := drainCompletions(t, completions, 1)
295+ if got[0].TurnID != "initial" || got[0].Completion().Status != string(ResidentInterrupted) {
296+ t.Fatalf("completion = %#v", got[0])
297+ }
298+ if ok, err := manager.Interrupt(t.Context(), spec.ID); err != nil || ok {
299+ t.Fatalf("late Interrupt = %t, %v, want refused on idle child", ok, err)
300+ }
301+ if manager.Get(spec.ID) == nil {
302+ t.Fatal("interrupt removed the live child")
303+ }
304+}
305+
306+// Steering must not jump ahead of a queued follow-up.
307+func TestResidentSteerQueuesBehindPendingTurns(t *testing.T) {
308+ runtime := newSteerableRuntime()
309+ manager, _ := newSteerTestManager(t, runtime)
310+ spec := ResidentChildSpec{ID: "ordered", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
311+ if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil {
312+ t.Fatal(err)
313+ }
314+ runtime.awaitStart(t)
315+ if _, err := manager.ResumeFollowUp(t.Context(), spec.ID, "first", ResumeQueue, "first", nil); err != nil {
316+ t.Fatal(err)
317+ }
318+ outcome, err := manager.ResumeFollowUp(t.Context(), spec.ID, "second", ResumeSteer, "second", nil)
319+ if err != nil || outcome.Steered || outcome.TurnID != "second" {
320+ t.Fatalf("outcome = %#v, err = %v", outcome, err)
321+ }
322+ if snapshot, _ := manager.SnapshotFor(spec.ID); snapshot.PendingFollowUps != 2 {
323+ t.Fatalf("pending follow-ups = %d, want 2 queued turns", snapshot.PendingFollowUps)
324+ }
325+}
@@ -0,0 +1,325 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+ "testing"
8+ "time"
9+)
10+
11+// steerableRuntime is a ResidentRuntime whose turn blocks until released or
12+// canceled, recording steered follow-ups it would deliver at the next boundary.
13+type steerableRuntime struct {
14+ started chan string
15+ release chan struct{}
16+
17+ mu sync.Mutex
18+ running bool
19+ pending []string
20+ delivered []string
21+}
22+
23+func newSteerableRuntime() *steerableRuntime {
24+ return &steerableRuntime{started: make(chan string, 4), release: make(chan struct{})}
25+}
26+
27+func (r *steerableRuntime) Run(ctx context.Context, prompt string) error {
28+ r.mu.Lock()
29+ r.running = true
30+ r.mu.Unlock()
31+ defer func() {
32+ r.mu.Lock()
33+ r.running = false
34+ r.mu.Unlock()
35+ }()
36+ r.started <- prompt
37+ select {
38+ case <-ctx.Done():
39+ return ctx.Err()
40+ case <-r.release:
41+ }
42+ r.mu.Lock()
43+ r.delivered = append(r.delivered, r.pending...)
44+ r.pending = nil
45+ r.mu.Unlock()
46+ return nil
47+}
48+
49+func (r *steerableRuntime) Steer(prompt string) bool {
50+ r.mu.Lock()
51+ defer r.mu.Unlock()
52+ if !r.running {
53+ return false
54+ }
55+ r.pending = append(r.pending, prompt)
56+ return true
57+}
58+
59+func (r *steerableRuntime) PendingSteers() int {
60+ r.mu.Lock()
61+ defer r.mu.Unlock()
62+ return len(r.pending)
63+}
64+
65+func (r *steerableRuntime) DrainSteers() []string {
66+ r.mu.Lock()
67+ defer r.mu.Unlock()
68+ out := r.pending
69+ r.pending = nil
70+ return out
71+}
72+
73+func (r *steerableRuntime) awaitStart(t *testing.T) string {
74+ t.Helper()
75+ select {
76+ case prompt := <-r.started:
77+ return prompt
78+ case <-time.After(5 * time.Second):
79+ t.Fatal("turn did not start")
80+ return ""
81+ }
82+}
83+
84+func newSteerTestManager(t *testing.T, runtime ResidentRuntime) (*ResidentManager, chan ResidentCompletion) {
85+ t.Helper()
86+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { return runtime, nil })
87+ completions := make(chan ResidentCompletion, 8)
88+ manager.SetCompletionObserver(func(c ResidentCompletion) { completions <- c })
89+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
90+ return manager, completions
91+}
92+
93+func drainCompletions(t *testing.T, completions chan ResidentCompletion, want int) []ResidentCompletion {
94+ t.Helper()
95+ var got []ResidentCompletion
96+ for len(got) < want {
97+ select {
98+ case c := <-completions:
99+ got = append(got, c)
100+ case <-time.After(5 * time.Second):
101+ t.Fatalf("got %d completions, want %d: %#v", len(got), want, got)
102+ }
103+ }
104+ select {
105+ case extra := <-completions:
106+ t.Fatalf("unexpected extra completion %#v", extra)
107+ case <-time.After(50 * time.Millisecond):
108+ }
109+ return got
110+}
111+
112+// Issue 226: stopping a child must not echo an undelivered queued follow-up
113+// back to the parent as if it were an interrupted task.
114+func TestResidentStopReportsQueuedFollowUpAsDroppedNotInterrupted(t *testing.T) {
115+ runtime := ResidentTurnRunner(func(ctx context.Context, _ string) error { <-ctx.Done(); return ctx.Err() })
116+ started := make(chan struct{}, 1)
117+ manager, completions := newSteerTestManager(t, ResidentTurnRunner(func(ctx context.Context, prompt string) error {
118+ started <- struct{}{}
119+ return runtime(ctx, prompt)
120+ }))
121+ spec := ResidentChildSpec{ID: "stop-echo", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
122+ if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil {
123+ t.Fatal(err)
124+ }
125+ <-started
126+ if _, err := manager.ResumeFollowUp(t.Context(), spec.ID, "stop and report now", ResumeQueue, "follow-up", nil); err != nil {
127+ t.Fatal(err)
128+ }
129+ if err := manager.Stop(t.Context(), spec.ID); err != nil {
130+ t.Fatal(err)
131+ }
132+ got := drainCompletions(t, completions, 2)
133+ byTurn := map[string]Completion{}
134+ for _, c := range got {
135+ byTurn[c.TurnID] = c.Completion()
136+ }
137+ if byTurn["initial"].Status != string(ResidentInterrupted) {
138+ t.Fatalf("initial completion = %#v", byTurn["initial"])
139+ }
140+ if byTurn["follow-up"].Status != CompletionDropped {
141+ t.Fatalf("follow-up completion = %#v, want dropped", byTurn["follow-up"])
142+ }
143+ update := FormatCompletionUpdate([]Completion{byTurn["initial"], byTurn["follow-up"]}, "")
144+ if strings.Contains(update, "interrupted — stop and report now") || !strings.Contains(update, "dropped (queued follow-up never started: stop and report now)") {
145+ t.Fatalf("update = %q", update)
146+ }
147+}
148+
149+// A steered follow-up joins the running turn: no new turn, no extra
150+// completion, and the running runtime receives it.
151+func TestResidentResumeSteersRunningTurn(t *testing.T) {
152+ runtime := newSteerableRuntime()
153+ manager, completions := newSteerTestManager(t, runtime)
154+ spec := ResidentChildSpec{ID: "steered", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
155+ if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil {
156+ t.Fatal(err)
157+ }
158+ runtime.awaitStart(t)
159+ var watched string
160+ outcome, err := manager.ResumeFollowUp(t.Context(), spec.ID, "wrap up now", ResumeSteer, "unused", func(turnID string) { watched = turnID })
161+ if err != nil {
162+ t.Fatal(err)
163+ }
164+ if !outcome.Steered || outcome.TurnID != "initial" || watched != "initial" {
165+ t.Fatalf("outcome = %#v, watched = %q", outcome, watched)
166+ }
167+ if snapshot, _ := manager.SnapshotFor(spec.ID); snapshot.PendingFollowUps != 1 {
168+ t.Fatalf("pending follow-ups = %d, want 1", snapshot.PendingFollowUps)
169+ }
170+ close(runtime.release)
171+ got := drainCompletions(t, completions, 1)
172+ if got[0].TurnID != "initial" || got[0].Err != nil || len(got[0].Undelivered) != 0 {
173+ t.Fatalf("completion = %#v", got[0])
174+ }
175+ if len(runtime.delivered) != 1 || runtime.delivered[0] != "wrap up now" {
176+ t.Fatalf("delivered = %#v", runtime.delivered)
177+ }
178+}
179+
180+// An idle child cannot be steered, so steer mode falls back to a new turn.
181+func TestResidentResumeSteerFallsBackToNewTurnWhenIdle(t *testing.T) {
182+ runtime := newSteerableRuntime()
183+ close(runtime.release)
184+ manager, completions := newSteerTestManager(t, runtime)
185+ spec := ResidentChildSpec{ID: "idle", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
186+ if _, err := manager.Spawn(t.Context(), spec, "task"); err != nil {
187+ t.Fatal(err)
188+ }
189+ drainCompletions(t, completions, 1)
190+ outcome, err := manager.ResumeFollowUp(t.Context(), spec.ID, "next", ResumeSteer, "second", nil)
191+ if err != nil || outcome.Steered || outcome.TurnID != "second" {
192+ t.Fatalf("outcome = %#v, err = %v", outcome, err)
193+ }
194+ if got := drainCompletions(t, completions, 1); got[0].TurnID != "second" {
195+ t.Fatalf("completion = %#v", got[0])
196+ }
197+}
198+
199+// Stopping a child with a steered follow-up it never read reports that
200+// follow-up as undelivered on the interrupted turn.
201+func TestResidentStopReportsUndeliveredSteer(t *testing.T) {
202+ runtime := newSteerableRuntime()
203+ manager, completions := newSteerTestManager(t, runtime)
204+ spec := ResidentChildSpec{ID: "undelivered", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
205+ if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil {
206+ t.Fatal(err)
207+ }
208+ runtime.awaitStart(t)
209+ if _, err := manager.ResumeFollowUp(t.Context(), spec.ID, "stop and report now", ResumeSteer, "", nil); err != nil {
210+ t.Fatal(err)
211+ }
212+ if err := manager.Stop(t.Context(), spec.ID); err != nil {
213+ t.Fatal(err)
214+ }
215+ got := drainCompletions(t, completions, 1)
216+ completion := got[0].Completion()
217+ if completion.Status != string(ResidentInterrupted) || len(completion.Undelivered) != 1 || completion.Undelivered[0] != "stop and report now" {
218+ t.Fatalf("completion = %#v", completion)
219+ }
220+ if update := FormatCompletionUpdate([]Completion{completion}, ""); !strings.Contains(update, "undelivered: 1 follow-up(s) the child never read:\n - stop and report now") {
221+ t.Fatalf("update = %q", update)
222+ }
223+}
224+
225+// Interrupt cancels only the running turn; the child stays live and a later
226+// resume runs as a new turn with the same runtime.
227+func TestResidentInterruptKeepsChildResumable(t *testing.T) {
228+ runtime := newSteerableRuntime()
229+ manager, completions := newSteerTestManager(t, runtime)
230+ spec := ResidentChildSpec{ID: "interrupted", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true}
231+ if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil {
232+ t.Fatal(err)
233+ }
234+ runtime.awaitStart(t)
235+ if _, err := manager.ResumeFollowUp(t.Context(), spec.ID, "queued work", ResumeQueue, "queued", nil); err != nil {
236+ t.Fatal(err)
237+ }
238+ interrupted, err := manager.Interrupt(t.Context(), spec.ID)
239+ if err != nil || !interrupted {
240+ t.Fatalf("Interrupt = %t, %v", interrupted, err)
241+ }
242+ got := drainCompletions(t, completions, 2)
243+ byTurn := map[string]Completion{}
244+ for _, c := range got {
245+ byTurn[c.TurnID] = c.Completion()
246+ }
247+ if byTurn["initial"].Status != string(ResidentInterrupted) || byTurn["queued"].Status != CompletionDropped {
248+ t.Fatalf("completions = %#v", byTurn)
249+ }
250+ if manager.Get(spec.ID) == nil {
251+ t.Fatal("interrupt removed the live child")
252+ }
253+ if again, err := manager.Interrupt(t.Context(), spec.ID); err != nil || again {
254+ t.Fatalf("second Interrupt = %t, %v, want no running turn", again, err)
255+ }
256+ close(runtime.release)
257+ outcome, err := manager.ResumeFollowUp(t.Context(), spec.ID, "wrap up with what you have", ResumeSteer, "wrap-up", nil)
258+ if err != nil || outcome.Steered {
259+ t.Fatalf("resume after interrupt = %#v, %v", outcome, err)
260+ }
261+ if prompt := runtime.awaitStart(t); prompt != "wrap up with what you have" {
262+ t.Fatalf("resumed prompt = %q", prompt)
263+ }
264+ if got := drainCompletions(t, completions, 1); got[0].TurnID != "wrap-up" || got[0].Err != nil {
265+ t.Fatalf("wrap-up completion = %#v", got[0])
266+ }
267+ if len(manager.UnmetRequired()) != 0 {
268+ t.Fatal("successful wrap-up left required work unmet")
269+ }
270+}
271+
272+// An interrupt can be accepted just before the turn finishes on its own. The
273+// turn must still report interrupted exactly once, and a late interrupt on the
274+// now-idle child must be refused.
275+func TestResidentInterruptRacingNaturalCompletion(t *testing.T) {
276+ started, release := make(chan struct{}, 1), make(chan struct{})
277+ // This runtime ignores cancellation, so its turn ends successfully after
278+ // the interrupt was already accepted.
279+ runtime := ResidentTurnRunner(func(context.Context, string) error {
280+ started <- struct{}{}
281+ <-release
282+ return nil
283+ })
284+ manager, completions := newSteerTestManager(t, runtime)
285+ spec := ResidentChildSpec{ID: "racing", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
286+ if _, err := manager.Spawn(t.Context(), spec, "work"); err != nil {
287+ t.Fatal(err)
288+ }
289+ <-started
290+ if ok, err := manager.Interrupt(t.Context(), spec.ID); err != nil || !ok {
291+ t.Fatalf("Interrupt = %t, %v", ok, err)
292+ }
293+ close(release)
294+ got := drainCompletions(t, completions, 1)
295+ if got[0].TurnID != "initial" || got[0].Completion().Status != string(ResidentInterrupted) {
296+ t.Fatalf("completion = %#v", got[0])
297+ }
298+ if ok, err := manager.Interrupt(t.Context(), spec.ID); err != nil || ok {
299+ t.Fatalf("late Interrupt = %t, %v, want refused on idle child", ok, err)
300+ }
301+ if manager.Get(spec.ID) == nil {
302+ t.Fatal("interrupt removed the live child")
303+ }
304+}
305+
306+// Steering must not jump ahead of a queued follow-up.
307+func TestResidentSteerQueuesBehindPendingTurns(t *testing.T) {
308+ runtime := newSteerableRuntime()
309+ manager, _ := newSteerTestManager(t, runtime)
310+ spec := ResidentChildSpec{ID: "ordered", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
311+ if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil {
312+ t.Fatal(err)
313+ }
314+ runtime.awaitStart(t)
315+ if _, err := manager.ResumeFollowUp(t.Context(), spec.ID, "first", ResumeQueue, "first", nil); err != nil {
316+ t.Fatal(err)
317+ }
318+ outcome, err := manager.ResumeFollowUp(t.Context(), spec.ID, "second", ResumeSteer, "second", nil)
319+ if err != nil || outcome.Steered || outcome.TurnID != "second" {
320+ t.Fatalf("outcome = %#v, err = %v", outcome, err)
321+ }
322+ if snapshot, _ := manager.SnapshotFor(spec.ID); snapshot.PendingFollowUps != 2 {
323+ t.Fatalf("pending follow-ups = %d, want 2 queued turns", snapshot.PendingFollowUps)
324+ }
325+}
M packages/agent/tools/resident_tools_test.go +27 -27
@@ -17,13 +17,13 @@ import (
1717
1818 func TestResidentStatusRetrievesFailedResultWithoutExecution(t *testing.T) {
1919 failure := errors.New("worker failed")
20- manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
21- return func(context.Context, string) error {
20+ manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
21+ return subagents.ResidentTurnRunner(func(context.Context, string) error {
2222 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "partial finding"}}}}); err != nil {
2323 return err
2424 }
2525 return failure
26- }, nil
26+ }), nil
2727 })
2828 t.Cleanup(func() { _ = manager.Close(context.Background()) })
2929 spec := subagents.ResidentChildSpec{ID: "failed-status", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true}
@@ -66,8 +66,8 @@ func TestResidentStatusRetrievesFailedResultWithoutExecution(t *testing.T) {
6666
6767 func TestResidentToolsUseManagerOnly(t *testing.T) {
6868 runs := make(chan string, 2)
69- manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
70- return func(_ context.Context, prompt string) error { runs <- prompt; return nil }, nil
69+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
70+ return subagents.ResidentTurnRunner(func(_ context.Context, prompt string) error { runs <- prompt; return nil }), nil
7171 })
7272 t.Cleanup(func() { _ = manager.Close(context.Background()) })
7373 spawn := &SubagentSpawnTool{
@@ -141,8 +141,8 @@ func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) {
141141 {"cancellation with failure", errors.Join(context.Canceled, errors.New("worker failed")), "interrupted"},
142142 } {
143143 t.Run(tc.name, func(t *testing.T) {
144- manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
145- return func(context.Context, string) error { return tc.err }, nil
144+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
145+ return subagents.ResidentTurnRunner(func(context.Context, string) error { return tc.err }), nil
146146 })
147147 t.Cleanup(func() { _ = manager.Close(context.Background()) })
148148 spawn := &SubagentSpawnTool{
@@ -174,8 +174,8 @@ func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) {
174174 var releaseOnce sync.Once
175175 releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
176176 finished := make(chan string, 1)
177- manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 1, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
178- return func(ctx context.Context, prompt string) error {
177+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 1, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
178+ return subagents.ResidentTurnRunner(func(ctx context.Context, prompt string) error {
179179 switch prompt {
180180 case "blocker":
181181 started <- struct{}{}
@@ -189,7 +189,7 @@ func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) {
189189 finished <- prompt
190190 }
191191 return nil
192- }, nil
192+ }), nil
193193 })
194194 t.Cleanup(func() {
195195 releaseBlocker()
@@ -234,8 +234,8 @@ func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) {
234234 }
235235
236236 func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
237- manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
238- return func(context.Context, string) error { return nil }, nil
237+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
238+ return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
239239 })
240240 t.Cleanup(func() { _ = manager.Close(context.Background()) })
241241 built := 0
@@ -271,8 +271,8 @@ func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
271271 }
272272
273273 func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {
274- manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
275- return func(context.Context, string) error { return nil }, nil
274+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
275+ return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
276276 })
277277 t.Cleanup(func() { _ = manager.Close(context.Background()) })
278278 profileFast := false
@@ -318,10 +318,10 @@ func TestResidentToolsRejectUnknownFields(t *testing.T) {
318318 // A resume wait subscribes to the accepted follow-up turn before it can finish,
319319 // so it returns that turn's completion instead of timing out.
320320 func TestResidentResumeWaitReturnsFollowUpCompletion(t *testing.T) {
321- manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
322- return func(_ context.Context, prompt string) error {
321+ manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
322+ return subagents.ResidentTurnRunner(func(_ context.Context, prompt string) error {
323323 return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer for " + prompt}}}})
324- }, nil
324+ }), nil
325325 })
326326 t.Cleanup(func() { _ = manager.Close(context.Background()) })
327327 initial, cancelInitial := manager.WatchCompletion("waited-resume", "initial-turn")
@@ -339,8 +339,8 @@ func TestResidentResumeWaitReturnsFollowUpCompletion(t *testing.T) {
339339 if err != nil || result.IsError {
340340 t.Fatalf("resume = (%#v, %v)", result, err)
341341 }
342- response, ok := result.Details.(subagentActionResponse)
343- if !ok || response.Wait == nil || response.Wait.TimedOut {
342+ response, ok := result.Details.(subagentResumeResponse)
343+ if !ok || response.Wait == nil || response.Wait.TimedOut || response.Delivery != "queued" {
344344 t.Fatalf("wait outcome = %#v", result.Details)
345345 }
346346 if response.Wait.Status != string(subagents.ResidentCompleted) || response.Wait.Summary != "answer for confirm" {
@@ -357,8 +357,8 @@ func TestResidentResumeWaitExpiryLeavesChildActive(t *testing.T) {
357357 release := make(chan struct{})
358358 var releaseOnce sync.Once
359359 releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
360- manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
361- return func(ctx context.Context, _ string) error {
360+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
361+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
362362 started <- struct{}{}
363363 select {
364364 case <-release:
@@ -366,7 +366,7 @@ func TestResidentResumeWaitExpiryLeavesChildActive(t *testing.T) {
366366 case <-ctx.Done():
367367 return ctx.Err()
368368 }
369- }, nil
369+ }), nil
370370 })
371371 t.Cleanup(func() {
372372 releaseBlocker()
@@ -381,11 +381,11 @@ func TestResidentResumeWaitExpiryLeavesChildActive(t *testing.T) {
381381 t.Fatal("blocking turn did not start")
382382 }
383383 resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
384- result, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"blocked-resume","prompt":"follow up","wait":1}`), nil)
384+ result, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"blocked-resume","prompt":"follow up","mode":"queue","wait":1}`), nil)
385385 if err != nil || result.IsError {
386386 t.Fatalf("resume = (%#v, %v)", result, err)
387387 }
388- response, ok := result.Details.(subagentActionResponse)
388+ response, ok := result.Details.(subagentResumeResponse)
389389 if !ok || response.Wait == nil || !response.Wait.TimedOut || response.Wait.Seconds != 1 {
390390 t.Fatalf("wait outcome = %#v", result.Details)
391391 }
@@ -403,8 +403,8 @@ func TestResidentResumeWaitCancellationBeatsTimeout(t *testing.T) {
403403 releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
404404 var calls atomic.Int32
405405 followUpStarted := make(chan struct{}, 1)
406- manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
407- return func(ctx context.Context, _ string) error {
406+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
407+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
408408 if calls.Add(1) == 1 {
409409 return nil
410410 }
@@ -415,7 +415,7 @@ func TestResidentResumeWaitCancellationBeatsTimeout(t *testing.T) {
415415 case <-ctx.Done():
416416 return ctx.Err()
417417 }
418- }, nil
418+ }), nil
419419 })
420420 t.Cleanup(func() {
421421 releaseBlocker()
@@ -17,13 +17,13 @@ import (
17 17
18 func TestResidentStatusRetrievesFailedResultWithoutExecution(t *testing.T) {18 func TestResidentStatusRetrievesFailedResultWithoutExecution(t *testing.T) {
19 failure := errors.New("worker failed")19 failure := errors.New("worker failed")
20- manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {20+ manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
21- return func(context.Context, string) error {21+ return subagents.ResidentTurnRunner(func(context.Context, string) error {
22 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "partial finding"}}}}); err != nil {22 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "partial finding"}}}}); err != nil {
23 return err23 return err
24 }24 }
25 return failure25 return failure
26- }, nil26+ }), nil
27 })27 })
28 t.Cleanup(func() { _ = manager.Close(context.Background()) })28 t.Cleanup(func() { _ = manager.Close(context.Background()) })
29 spec := subagents.ResidentChildSpec{ID: "failed-status", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true}29 spec := subagents.ResidentChildSpec{ID: "failed-status", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true}
@@ -66,8 +66,8 @@ func TestResidentStatusRetrievesFailedResultWithoutExecution(t *testing.T) {
66 66
67 func TestResidentToolsUseManagerOnly(t *testing.T) {67 func TestResidentToolsUseManagerOnly(t *testing.T) {
68 runs := make(chan string, 2)68 runs := make(chan string, 2)
69- manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {69+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
70- return func(_ context.Context, prompt string) error { runs <- prompt; return nil }, nil70+ return subagents.ResidentTurnRunner(func(_ context.Context, prompt string) error { runs <- prompt; return nil }), nil
71 })71 })
72 t.Cleanup(func() { _ = manager.Close(context.Background()) })72 t.Cleanup(func() { _ = manager.Close(context.Background()) })
73 spawn := &SubagentSpawnTool{73 spawn := &SubagentSpawnTool{
@@ -141,8 +141,8 @@ func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) {
141 {"cancellation with failure", errors.Join(context.Canceled, errors.New("worker failed")), "interrupted"},141 {"cancellation with failure", errors.Join(context.Canceled, errors.New("worker failed")), "interrupted"},
142 } {142 } {
143 t.Run(tc.name, func(t *testing.T) {143 t.Run(tc.name, func(t *testing.T) {
144- manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {144+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
145- return func(context.Context, string) error { return tc.err }, nil145+ return subagents.ResidentTurnRunner(func(context.Context, string) error { return tc.err }), nil
146 })146 })
147 t.Cleanup(func() { _ = manager.Close(context.Background()) })147 t.Cleanup(func() { _ = manager.Close(context.Background()) })
148 spawn := &SubagentSpawnTool{148 spawn := &SubagentSpawnTool{
@@ -174,8 +174,8 @@ func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) {
174 var releaseOnce sync.Once174 var releaseOnce sync.Once
175 releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }175 releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
176 finished := make(chan string, 1)176 finished := make(chan string, 1)
177- manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 1, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {177+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 1, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
178- return func(ctx context.Context, prompt string) error {178+ return subagents.ResidentTurnRunner(func(ctx context.Context, prompt string) error {
179 switch prompt {179 switch prompt {
180 case "blocker":180 case "blocker":
181 started <- struct{}{}181 started <- struct{}{}
@@ -189,7 +189,7 @@ func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) {
189 finished <- prompt189 finished <- prompt
190 }190 }
191 return nil191 return nil
192- }, nil192+ }), nil
193 })193 })
194 t.Cleanup(func() {194 t.Cleanup(func() {
195 releaseBlocker()195 releaseBlocker()
@@ -234,8 +234,8 @@ func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) {
234 }234 }
235 235
236 func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {236 func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
237- manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {237+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
238- return func(context.Context, string) error { return nil }, nil238+ return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
239 })239 })
240 t.Cleanup(func() { _ = manager.Close(context.Background()) })240 t.Cleanup(func() { _ = manager.Close(context.Background()) })
241 built := 0241 built := 0
@@ -271,8 +271,8 @@ func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
271 }271 }
272 272
273 func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {273 func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {
274- manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {274+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
275- return func(context.Context, string) error { return nil }, nil275+ return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
276 })276 })
277 t.Cleanup(func() { _ = manager.Close(context.Background()) })277 t.Cleanup(func() { _ = manager.Close(context.Background()) })
278 profileFast := false278 profileFast := false
@@ -318,10 +318,10 @@ func TestResidentToolsRejectUnknownFields(t *testing.T) {
318 // A resume wait subscribes to the accepted follow-up turn before it can finish,318 // A resume wait subscribes to the accepted follow-up turn before it can finish,
319 // so it returns that turn's completion instead of timing out.319 // so it returns that turn's completion instead of timing out.
320 func TestResidentResumeWaitReturnsFollowUpCompletion(t *testing.T) {320 func TestResidentResumeWaitReturnsFollowUpCompletion(t *testing.T) {
321- manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {321+ manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
322- return func(_ context.Context, prompt string) error {322+ return subagents.ResidentTurnRunner(func(_ context.Context, prompt string) error {
323 return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer for " + prompt}}}})323 return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer for " + prompt}}}})
324- }, nil324+ }), nil
325 })325 })
326 t.Cleanup(func() { _ = manager.Close(context.Background()) })326 t.Cleanup(func() { _ = manager.Close(context.Background()) })
327 initial, cancelInitial := manager.WatchCompletion("waited-resume", "initial-turn")327 initial, cancelInitial := manager.WatchCompletion("waited-resume", "initial-turn")
@@ -339,8 +339,8 @@ func TestResidentResumeWaitReturnsFollowUpCompletion(t *testing.T) {
339 if err != nil || result.IsError {339 if err != nil || result.IsError {
340 t.Fatalf("resume = (%#v, %v)", result, err)340 t.Fatalf("resume = (%#v, %v)", result, err)
341 }341 }
342- response, ok := result.Details.(subagentActionResponse)342+ response, ok := result.Details.(subagentResumeResponse)
343- if !ok || response.Wait == nil || response.Wait.TimedOut {343+ if !ok || response.Wait == nil || response.Wait.TimedOut || response.Delivery != "queued" {
344 t.Fatalf("wait outcome = %#v", result.Details)344 t.Fatalf("wait outcome = %#v", result.Details)
345 }345 }
346 if response.Wait.Status != string(subagents.ResidentCompleted) || response.Wait.Summary != "answer for confirm" {346 if response.Wait.Status != string(subagents.ResidentCompleted) || response.Wait.Summary != "answer for confirm" {
@@ -357,8 +357,8 @@ func TestResidentResumeWaitExpiryLeavesChildActive(t *testing.T) {
357 release := make(chan struct{})357 release := make(chan struct{})
358 var releaseOnce sync.Once358 var releaseOnce sync.Once
359 releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }359 releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
360- manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {360+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
361- return func(ctx context.Context, _ string) error {361+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
362 started <- struct{}{}362 started <- struct{}{}
363 select {363 select {
364 case <-release:364 case <-release:
@@ -366,7 +366,7 @@ func TestResidentResumeWaitExpiryLeavesChildActive(t *testing.T) {
366 case <-ctx.Done():366 case <-ctx.Done():
367 return ctx.Err()367 return ctx.Err()
368 }368 }
369- }, nil369+ }), nil
370 })370 })
371 t.Cleanup(func() {371 t.Cleanup(func() {
372 releaseBlocker()372 releaseBlocker()
@@ -381,11 +381,11 @@ func TestResidentResumeWaitExpiryLeavesChildActive(t *testing.T) {
381 t.Fatal("blocking turn did not start")381 t.Fatal("blocking turn did not start")
382 }382 }
383 resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}383 resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
384- result, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"blocked-resume","prompt":"follow up","wait":1}`), nil)384+ result, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"blocked-resume","prompt":"follow up","mode":"queue","wait":1}`), nil)
385 if err != nil || result.IsError {385 if err != nil || result.IsError {
386 t.Fatalf("resume = (%#v, %v)", result, err)386 t.Fatalf("resume = (%#v, %v)", result, err)
387 }387 }
388- response, ok := result.Details.(subagentActionResponse)388+ response, ok := result.Details.(subagentResumeResponse)
389 if !ok || response.Wait == nil || !response.Wait.TimedOut || response.Wait.Seconds != 1 {389 if !ok || response.Wait == nil || !response.Wait.TimedOut || response.Wait.Seconds != 1 {
390 t.Fatalf("wait outcome = %#v", result.Details)390 t.Fatalf("wait outcome = %#v", result.Details)
391 }391 }
@@ -403,8 +403,8 @@ func TestResidentResumeWaitCancellationBeatsTimeout(t *testing.T) {
403 releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }403 releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
404 var calls atomic.Int32404 var calls atomic.Int32
405 followUpStarted := make(chan struct{}, 1)405 followUpStarted := make(chan struct{}, 1)
406- manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {406+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
407- return func(ctx context.Context, _ string) error {407+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
408 if calls.Add(1) == 1 {408 if calls.Add(1) == 1 {
409 return nil409 return nil
410 }410 }
@@ -415,7 +415,7 @@ func TestResidentResumeWaitCancellationBeatsTimeout(t *testing.T) {
415 case <-ctx.Done():415 case <-ctx.Done():
416 return ctx.Err()416 return ctx.Err()
417 }417 }
418- }, nil418+ }), nil
419 })419 })
420 t.Cleanup(func() {420 t.Cleanup(func() {
421 releaseBlocker()421 releaseBlocker()
M packages/agent/tools/subagent.go +31 -19
@@ -16,10 +16,11 @@ import (
1616 // action; each action delegates to an internal implementation, and a nil
1717 // implementation means that action is unavailable under the launch-time policy.
1818 type SubagentTool struct {
19- Spawn *SubagentSpawnTool
20- Status *SubagentStatusTool
21- Stop *SubagentStopTool
22- Resume *SubagentResumeTool
19+ Spawn *SubagentSpawnTool
20+ Status *SubagentStatusTool
21+ Stop *SubagentStopTool
22+ Resume *SubagentResumeTool
23+ Interrupt *SubagentInterruptTool
2324 }
2425
2526 // subagentSpawnGuidance is the operational contract for delegation. It is the
@@ -27,11 +28,11 @@ type SubagentTool struct {
2728 // cannot drift between them.
2829 const subagentSpawnGuidance = "Delegate a concrete, bounded scope to a resident sub-agent. For proactive delegation, use an independent sidecar only when the parent has useful non-overlapping work; keep immediate blockers local. A worker owns its scope until completion, so never duplicate it in the parent. If delegation owns the blocking task, end or yield the parent turn. Omit wait to return immediately and receive completion through [auto-subagents update]; set wait to an explicit 1–300 second value only when this turn should wait for the initial task. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through the resume action. Never use bash sleep, watch, tail -f, polling loops, repeated status calls, dashboard, metadata, or file checks solely to wait."
2930
30-// subagentSchema merges the four action schemas into the flat, model-facing
31-// schema the project keeps for provider compatibility. Every property is
32-// annotated with the action that owns it; agent_id carries one merged
33-// description because it means "omit to list" for status and "required" for
34-// stop and resume. Because the schema advertises every action's arguments at
31+// subagentSchema merges the action schemas into the flat, model-facing schema
32+// the project keeps for provider compatibility. Every property is annotated
33+// with the action that owns it; agent_id carries one merged description because
34+// it means "omit to list" for status and "required" for stop, resume, and
35+// interrupt. Because the schema advertises every action's arguments at
3536 // once, a field that belongs to a different action is reported instead of
3637 // ignored, so a mis-selected action cannot silently run as another one. Fields
3738 // that no action owns still reach the sub-tool, whose strict decoder rejects
@@ -42,8 +43,8 @@ const subagentSchema = `{
4243 "properties": {
4344 "action": {
4445 "type": "string",
45- "enum": ["spawn", "status", "stop", "resume"],
46- "description": "One action per call. spawn delegates a new resident sub-agent. status reads bounded state for one child, including its current live activity, or lists the current set. stop requests termination of a stuck child. resume continues an existing child with a new prompt, keeping its session context, and can wait for that follow-up turn."
46+ "enum": ["spawn", "status", "stop", "resume", "interrupt"],
47+ "description": "One action per call. spawn delegates a new resident sub-agent. status reads bounded state for one child, including its current live activity and pending follow-ups, or lists the current set. resume sends an existing child a follow-up, keeping its session context; by default it steers a running child mid-turn, and it can wait for the answering turn. interrupt cancels only the child's running turn and keeps the child and its context, so a later resume can ask it to wrap up. stop terminates a stuck child."
4748 },
4849 "task": {
4950 "type": "string",
@@ -87,7 +88,7 @@ const subagentSchema = `{
8788 },
8889 "agent_id": {
8990 "type": "string",
90- "description": "Child id or unique id prefix. With status, omit it to list all resident sub-agents. Required when action is stop or resume."
91+ "description": "Child id or unique id prefix. With status, omit it to list all resident sub-agents. Required when action is stop, resume, or interrupt."
9192 },
9293 "include_result": {
9394 "type": "boolean",
@@ -101,7 +102,12 @@ const subagentSchema = `{
101102 },
102103 "prompt": {
103104 "type": "string",
104- "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session. Required when action is resume. After a terminal failure, inspect its saved result first; resume continues the retained session without discarding progress or satisfying required work. Combine with wait to block on that follow-up turn."
105+ "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session. Required when action is resume. After a terminal failure, inspect its saved result first; resume continues the retained session without discarding progress or satisfying required work. Combine with wait to block on the turn that answers it."
106+ },
107+ "mode": {
108+ "type": "string",
109+ "enum": ["steer", "queue"],
110+ "description": "How resume reaches a running child. steer (default) injects the prompt into its current turn at the next tool or model boundary, so the child sees it without waiting for the turn to end. queue runs the prompt as a separate turn after the current one. An idle child starts a new turn either way. Only valid when action is resume."
105111 }
106112 },
107113 "required": ["action"]
@@ -111,10 +117,11 @@ const subagentSchema = `{
111117 // the selected action, or by no action, is forwarded; a field owned by another
112118 // action is rejected before dispatch.
113119 var subagentActionFields = map[string][]string{
114- SubagentActionSpawn: {"task", "agent", "model", "provider", "reasoning", "fast_mode", "required", "wait", "isolation"},
115- SubagentActionStatus: {"agent_id", "include_result", "watch"},
116- SubagentActionStop: {"agent_id"},
117- SubagentActionResume: {"agent_id", "prompt", "wait"},
120+ SubagentActionSpawn: {"task", "agent", "model", "provider", "reasoning", "fast_mode", "required", "wait", "isolation"},
121+ SubagentActionStatus: {"agent_id", "include_result", "watch"},
122+ SubagentActionStop: {"agent_id"},
123+ SubagentActionResume: {"agent_id", "prompt", "mode", "wait"},
124+ SubagentActionInterrupt: {"agent_id"},
118125 }
119126
120127 var (
@@ -140,12 +147,12 @@ func (e *subagentForeignFieldError) Error() string {
140147
141148 func (t *SubagentTool) Name() string { return SubagentToolName }
142149 func (t *SubagentTool) Description() string {
143- return "Spawn, inspect, resume, and stop resident sub-agents. One action per call.\n\n" + subagentSpawnGuidance
150+ return "Spawn, inspect, resume, interrupt, and stop resident sub-agents. One action per call.\n\n" + subagentSpawnGuidance
144151 }
145152 func (t *SubagentTool) Schema() json.RawMessage { return json.RawMessage(subagentSchema) }
146153
147154 // ConfirmationKey implements core.ConfirmationKeyer. The facade multiplexes
148-// four actions under one tool name, so the session "always allow" grant is
155+// several actions under one tool name, so the session "always allow" grant is
149156 // scoped per action instead of per name. A missing, empty, or non-string
150157 // action returns the plain base name so a malformed call cannot widen the
151158 // remembered grant.
@@ -205,6 +212,11 @@ func (t *SubagentTool) Execute(ctx context.Context, raw json.RawMessage, progres
205212 return protocolToolError("subagent: resume action is unavailable in this mode")
206213 }
207214 return t.Resume.Execute(ctx, payload, progress)
215+ case SubagentActionInterrupt:
216+ if t.Interrupt == nil {
217+ return protocolToolError("subagent: interrupt action is unavailable in this mode")
218+ }
219+ return t.Interrupt.Execute(ctx, payload, progress)
208220 }
209221 return protocolToolError("subagent: unknown action")
210222 }
@@ -16,10 +16,11 @@ import (
16 // action; each action delegates to an internal implementation, and a nil16 // action; each action delegates to an internal implementation, and a nil
17 // implementation means that action is unavailable under the launch-time policy.17 // implementation means that action is unavailable under the launch-time policy.
18 type SubagentTool struct {18 type SubagentTool struct {
19- Spawn *SubagentSpawnTool19+ Spawn *SubagentSpawnTool
20- Status *SubagentStatusTool20+ Status *SubagentStatusTool
21- Stop *SubagentStopTool21+ Stop *SubagentStopTool
22- Resume *SubagentResumeTool22+ Resume *SubagentResumeTool
23+ Interrupt *SubagentInterruptTool
23 }24 }
24 25
25 // subagentSpawnGuidance is the operational contract for delegation. It is the26 // subagentSpawnGuidance is the operational contract for delegation. It is the
@@ -27,11 +28,11 @@ type SubagentTool struct {
27 // cannot drift between them.28 // cannot drift between them.
28 const subagentSpawnGuidance = "Delegate a concrete, bounded scope to a resident sub-agent. For proactive delegation, use an independent sidecar only when the parent has useful non-overlapping work; keep immediate blockers local. A worker owns its scope until completion, so never duplicate it in the parent. If delegation owns the blocking task, end or yield the parent turn. Omit wait to return immediately and receive completion through [auto-subagents update]; set wait to an explicit 1–300 second value only when this turn should wait for the initial task. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through the resume action. Never use bash sleep, watch, tail -f, polling loops, repeated status calls, dashboard, metadata, or file checks solely to wait."29 const subagentSpawnGuidance = "Delegate a concrete, bounded scope to a resident sub-agent. For proactive delegation, use an independent sidecar only when the parent has useful non-overlapping work; keep immediate blockers local. A worker owns its scope until completion, so never duplicate it in the parent. If delegation owns the blocking task, end or yield the parent turn. Omit wait to return immediately and receive completion through [auto-subagents update]; set wait to an explicit 1–300 second value only when this turn should wait for the initial task. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through the resume action. Never use bash sleep, watch, tail -f, polling loops, repeated status calls, dashboard, metadata, or file checks solely to wait."
29 30
30-// subagentSchema merges the four action schemas into the flat, model-facing31+// subagentSchema merges the action schemas into the flat, model-facing schema
31-// schema the project keeps for provider compatibility. Every property is32+// the project keeps for provider compatibility. Every property is annotated
32-// annotated with the action that owns it; agent_id carries one merged33+// with the action that owns it; agent_id carries one merged description because
33-// description because it means "omit to list" for status and "required" for34+// it means "omit to list" for status and "required" for stop, resume, and
34-// stop and resume. Because the schema advertises every action's arguments at35+// interrupt. Because the schema advertises every action's arguments at
35 // once, a field that belongs to a different action is reported instead of36 // once, a field that belongs to a different action is reported instead of
36 // ignored, so a mis-selected action cannot silently run as another one. Fields37 // ignored, so a mis-selected action cannot silently run as another one. Fields
37 // that no action owns still reach the sub-tool, whose strict decoder rejects38 // that no action owns still reach the sub-tool, whose strict decoder rejects
@@ -42,8 +43,8 @@ const subagentSchema = `{
42 "properties": {43 "properties": {
43 "action": {44 "action": {
44 "type": "string",45 "type": "string",
45- "enum": ["spawn", "status", "stop", "resume"],46+ "enum": ["spawn", "status", "stop", "resume", "interrupt"],
46- "description": "One action per call. spawn delegates a new resident sub-agent. status reads bounded state for one child, including its current live activity, or lists the current set. stop requests termination of a stuck child. resume continues an existing child with a new prompt, keeping its session context, and can wait for that follow-up turn."47+ "description": "One action per call. spawn delegates a new resident sub-agent. status reads bounded state for one child, including its current live activity and pending follow-ups, or lists the current set. resume sends an existing child a follow-up, keeping its session context; by default it steers a running child mid-turn, and it can wait for the answering turn. interrupt cancels only the child's running turn and keeps the child and its context, so a later resume can ask it to wrap up. stop terminates a stuck child."
47 },48 },
48 "task": {49 "task": {
49 "type": "string",50 "type": "string",
@@ -87,7 +88,7 @@ const subagentSchema = `{
87 },88 },
88 "agent_id": {89 "agent_id": {
89 "type": "string",90 "type": "string",
90- "description": "Child id or unique id prefix. With status, omit it to list all resident sub-agents. Required when action is stop or resume."91+ "description": "Child id or unique id prefix. With status, omit it to list all resident sub-agents. Required when action is stop, resume, or interrupt."
91 },92 },
92 "include_result": {93 "include_result": {
93 "type": "boolean",94 "type": "boolean",
@@ -101,7 +102,12 @@ const subagentSchema = `{
101 },102 },
102 "prompt": {103 "prompt": {
103 "type": "string",104 "type": "string",
104- "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session. Required when action is resume. After a terminal failure, inspect its saved result first; resume continues the retained session without discarding progress or satisfying required work. Combine with wait to block on that follow-up turn."105+ "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session. Required when action is resume. After a terminal failure, inspect its saved result first; resume continues the retained session without discarding progress or satisfying required work. Combine with wait to block on the turn that answers it."
106+ },
107+ "mode": {
108+ "type": "string",
109+ "enum": ["steer", "queue"],
110+ "description": "How resume reaches a running child. steer (default) injects the prompt into its current turn at the next tool or model boundary, so the child sees it without waiting for the turn to end. queue runs the prompt as a separate turn after the current one. An idle child starts a new turn either way. Only valid when action is resume."
105 }111 }
106 },112 },
107 "required": ["action"]113 "required": ["action"]
@@ -111,10 +117,11 @@ const subagentSchema = `{
111 // the selected action, or by no action, is forwarded; a field owned by another117 // the selected action, or by no action, is forwarded; a field owned by another
112 // action is rejected before dispatch.118 // action is rejected before dispatch.
113 var subagentActionFields = map[string][]string{119 var subagentActionFields = map[string][]string{
114- SubagentActionSpawn: {"task", "agent", "model", "provider", "reasoning", "fast_mode", "required", "wait", "isolation"},120+ SubagentActionSpawn: {"task", "agent", "model", "provider", "reasoning", "fast_mode", "required", "wait", "isolation"},
115- SubagentActionStatus: {"agent_id", "include_result", "watch"},121+ SubagentActionStatus: {"agent_id", "include_result", "watch"},
116- SubagentActionStop: {"agent_id"},122+ SubagentActionStop: {"agent_id"},
117- SubagentActionResume: {"agent_id", "prompt", "wait"},123+ SubagentActionResume: {"agent_id", "prompt", "mode", "wait"},
124+ SubagentActionInterrupt: {"agent_id"},
118 }125 }
119 126
120 var (127 var (
@@ -140,12 +147,12 @@ func (e *subagentForeignFieldError) Error() string {
140 147
141 func (t *SubagentTool) Name() string { return SubagentToolName }148 func (t *SubagentTool) Name() string { return SubagentToolName }
142 func (t *SubagentTool) Description() string {149 func (t *SubagentTool) Description() string {
143- return "Spawn, inspect, resume, and stop resident sub-agents. One action per call.\n\n" + subagentSpawnGuidance150+ return "Spawn, inspect, resume, interrupt, and stop resident sub-agents. One action per call.\n\n" + subagentSpawnGuidance
144 }151 }
145 func (t *SubagentTool) Schema() json.RawMessage { return json.RawMessage(subagentSchema) }152 func (t *SubagentTool) Schema() json.RawMessage { return json.RawMessage(subagentSchema) }
146 153
147 // ConfirmationKey implements core.ConfirmationKeyer. The facade multiplexes154 // ConfirmationKey implements core.ConfirmationKeyer. The facade multiplexes
148-// four actions under one tool name, so the session "always allow" grant is155+// several actions under one tool name, so the session "always allow" grant is
149 // scoped per action instead of per name. A missing, empty, or non-string156 // scoped per action instead of per name. A missing, empty, or non-string
150 // action returns the plain base name so a malformed call cannot widen the157 // action returns the plain base name so a malformed call cannot widen the
151 // remembered grant.158 // remembered grant.
@@ -205,6 +212,11 @@ func (t *SubagentTool) Execute(ctx context.Context, raw json.RawMessage, progres
205 return protocolToolError("subagent: resume action is unavailable in this mode")212 return protocolToolError("subagent: resume action is unavailable in this mode")
206 }213 }
207 return t.Resume.Execute(ctx, payload, progress)214 return t.Resume.Execute(ctx, payload, progress)
215+ case SubagentActionInterrupt:
216+ if t.Interrupt == nil {
217+ return protocolToolError("subagent: interrupt action is unavailable in this mode")
218+ }
219+ return t.Interrupt.Execute(ctx, payload, progress)
208 }220 }
209 return protocolToolError("subagent: unknown action")221 return protocolToolError("subagent: unknown action")
210 }222 }
A packages/agent/tools/subagent_interrupt.go +67 -0
@@ -0,0 +1,67 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/bnema/zut/packages/agent/subagents"
10+ "github.com/bnema/zut/packages/core"
11+)
12+
13+// SubagentInterruptTool cancels only a sub-agent's running turn. Unlike stop,
14+// the child stays alive with its transcript, so a later resume continues with
15+// full context.
16+type SubagentInterruptTool struct {
17+ ResidentManager *subagents.ResidentManager
18+ Enabled func() bool
19+}
20+
21+type subagentInterruptArgs struct {
22+ AgentID string `json:"agent_id"`
23+}
24+
25+// Name returns the shared facade name: this type is an internal
26+// implementation and is never registered on its own.
27+func (t *SubagentInterruptTool) Name() string { return SubagentToolName }
28+
29+func (t *SubagentInterruptTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
30+ if ctx != nil {
31+ select {
32+ case <-ctx.Done():
33+ return core.ToolResult{}, ctx.Err()
34+ default:
35+ }
36+ }
37+ prefix := t.Name()
38+ if t.ResidentManager == nil {
39+ return protocolToolError(prefix + ": subagent runtime not available in this mode")
40+ }
41+ if t.Enabled == nil || !t.Enabled() {
42+ return protocolToolError(prefix + ": subagent management is unavailable in this mode")
43+ }
44+ var args subagentInterruptArgs
45+ if err := decodeSubagentArgs(raw, &args); err != nil {
46+ return core.ToolResult{}, err
47+ }
48+ id := strings.TrimSpace(args.AgentID)
49+ if id == "" {
50+ return protocolToolError(prefix + ": agent_id is required")
51+ }
52+ snapshot, ok := findResidentStatusSnapshot(t.ResidentManager.Snapshot(), id)
53+ if !ok {
54+ return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
55+ }
56+ interrupted, err := t.ResidentManager.Interrupt(ctx, snapshot.ID)
57+ if err != nil {
58+ return protocolToolError(prefix + ": " + err.Error())
59+ }
60+ if !interrupted {
61+ return protocolToolError(fmt.Sprintf("%s: agent %q has no running turn to interrupt", prefix, snapshot.ID))
62+ }
63+ if updated, ok := t.ResidentManager.SnapshotFor(snapshot.ID); ok {
64+ snapshot = updated
65+ }
66+ return renderResidentAction("interrupt_requested", publicResidentStatus(snapshot))
67+}
@@ -0,0 +1,67 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/bnema/zut/packages/agent/subagents"
10+ "github.com/bnema/zut/packages/core"
11+)
12+
13+// SubagentInterruptTool cancels only a sub-agent's running turn. Unlike stop,
14+// the child stays alive with its transcript, so a later resume continues with
15+// full context.
16+type SubagentInterruptTool struct {
17+ ResidentManager *subagents.ResidentManager
18+ Enabled func() bool
19+}
20+
21+type subagentInterruptArgs struct {
22+ AgentID string `json:"agent_id"`
23+}
24+
25+// Name returns the shared facade name: this type is an internal
26+// implementation and is never registered on its own.
27+func (t *SubagentInterruptTool) Name() string { return SubagentToolName }
28+
29+func (t *SubagentInterruptTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
30+ if ctx != nil {
31+ select {
32+ case <-ctx.Done():
33+ return core.ToolResult{}, ctx.Err()
34+ default:
35+ }
36+ }
37+ prefix := t.Name()
38+ if t.ResidentManager == nil {
39+ return protocolToolError(prefix + ": subagent runtime not available in this mode")
40+ }
41+ if t.Enabled == nil || !t.Enabled() {
42+ return protocolToolError(prefix + ": subagent management is unavailable in this mode")
43+ }
44+ var args subagentInterruptArgs
45+ if err := decodeSubagentArgs(raw, &args); err != nil {
46+ return core.ToolResult{}, err
47+ }
48+ id := strings.TrimSpace(args.AgentID)
49+ if id == "" {
50+ return protocolToolError(prefix + ": agent_id is required")
51+ }
52+ snapshot, ok := findResidentStatusSnapshot(t.ResidentManager.Snapshot(), id)
53+ if !ok {
54+ return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
55+ }
56+ interrupted, err := t.ResidentManager.Interrupt(ctx, snapshot.ID)
57+ if err != nil {
58+ return protocolToolError(prefix + ": " + err.Error())
59+ }
60+ if !interrupted {
61+ return protocolToolError(fmt.Sprintf("%s: agent %q has no running turn to interrupt", prefix, snapshot.ID))
62+ }
63+ if updated, ok := t.ResidentManager.SnapshotFor(snapshot.ID); ok {
64+ snapshot = updated
65+ }
66+ return renderResidentAction("interrupt_requested", publicResidentStatus(snapshot))
67+}
M packages/agent/tools/subagent_resume.go +39 -9
@@ -11,10 +11,12 @@ import (
1111 "github.com/google/uuid"
1212 )
1313
14-// SubagentResumeTool gives a sub-agent a follow-up turn while preserving its
15-// existing session context. Every explicit follow-up is accepted durably and
16-// runs FIFO after any active resident turn. An explicit bounded wait may return
17-// that turn's completion or expire while the child stays active.
14+// SubagentResumeTool sends a sub-agent a follow-up while preserving its session
15+// context. In steer mode, the default, a follow-up to a running child joins its
16+// current turn at the next tool or model boundary; otherwise it is accepted
17+// durably as a new turn that runs FIFO after any active one. An explicit
18+// bounded wait may return the answering turn's completion or expire while the
19+// child stays active.
1820 type SubagentResumeTool struct {
1921 ResidentManager *subagents.ResidentManager
2022 Enabled func() bool
@@ -23,9 +25,18 @@ type SubagentResumeTool struct {
2325 type subagentResumeArgs struct {
2426 AgentID string `json:"agent_id"`
2527 Prompt string `json:"prompt"`
28+ Mode string `json:"mode,omitempty"`
2629 Wait *int `json:"wait,omitempty"`
2730 }
2831
32+// subagentResumeResponse reports how a follow-up was delivered.
33+type subagentResumeResponse struct {
34+ Action string `json:"action"`
35+ Delivery string `json:"delivery"`
36+ Agent subagentStatusEntry `json:"agent"`
37+ Wait *subagentWaitOutcome `json:"wait,omitempty"`
38+}
39+
2940 // Name returns the shared facade name: this type is an internal
3041 // implementation and is never registered on its own.
3142 func (t *SubagentResumeTool) Name() string { return SubagentToolName }
@@ -57,6 +68,13 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
5768 if strings.TrimSpace(args.Prompt) == "" {
5869 return protocolToolError(prefix + ": prompt is required")
5970 }
71+ mode := subagents.ResumeMode(strings.TrimSpace(args.Mode))
72+ if mode == "" {
73+ mode = subagents.ResumeSteer
74+ }
75+ if mode != subagents.ResumeSteer && mode != subagents.ResumeQueue {
76+ return protocolToolError(prefix + ": mode must be steer or queue")
77+ }
6078 if args.Wait != nil && (*args.Wait < 1 || *args.Wait > maxSubagentWaitSeconds) {
6179 return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))
6280 }
@@ -64,18 +82,29 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
6482 if !ok {
6583 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
6684 }
67- // The watch is registered before the follow-up is accepted, so a turn that
68- // finishes immediately cannot complete before this call subscribes.
85+ // Each watch is registered before its turn can finish: the queued turn's
86+ // before acceptance, and the steered turn's from the child's control loop
87+ // before that turn can report completion.
6988 turnID := uuid.NewString()
7089 var completionResult <-chan subagents.ResidentCompletion
7190 cancelWait := func() {}
91+ var onSteered func(string)
7292 if args.Wait != nil {
7393 completionResult, cancelWait = t.ResidentManager.WatchCompletion(snapshot.ID, turnID)
94+ onSteered = func(activeTurn string) {
95+ cancelWait()
96+ completionResult, cancelWait = t.ResidentManager.WatchCompletion(snapshot.ID, activeTurn)
97+ }
7498 }
75- defer cancelWait()
76- if err := t.ResidentManager.ResumeWithTurn(ctx, snapshot.ID, args.Prompt, turnID); err != nil {
99+ defer func() { cancelWait() }()
100+ delivered, err := t.ResidentManager.ResumeFollowUp(ctx, snapshot.ID, args.Prompt, mode, turnID, onSteered)
101+ if err != nil {
77102 return protocolToolError(prefix + ": " + err.Error())
78103 }
104+ delivery := "queued"
105+ if delivered.Steered {
106+ delivery = "steered"
107+ }
79108 var outcome *subagentWaitOutcome
80109 if args.Wait != nil {
81110 completion, timedOut, err := waitForResidentCompletion(ctx, completionResult, *args.Wait, prefix)
@@ -88,10 +117,11 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
88117 outcome.Status = result.Status
89118 outcome.Error = result.Error
90119 outcome.Summary = result.Summary
120+ outcome.Undelivered = result.Undelivered
91121 }
92122 }
93123 if updated, ok := t.ResidentManager.SnapshotFor(snapshot.ID); ok {
94124 snapshot = updated
95125 }
96- return renderResidentActionWait("resumed", publicResidentStatus(snapshot), outcome)
126+ return renderSubagentResponse(subagentResumeResponse{Action: "resumed", Delivery: delivery, Agent: publicResidentStatus(snapshot), Wait: outcome})
97127 }
@@ -11,10 +11,12 @@ import (
11 "github.com/google/uuid"11 "github.com/google/uuid"
12 )12 )
13 13
14-// SubagentResumeTool gives a sub-agent a follow-up turn while preserving its14+// SubagentResumeTool sends a sub-agent a follow-up while preserving its session
15-// existing session context. Every explicit follow-up is accepted durably and15+// context. In steer mode, the default, a follow-up to a running child joins its
16-// runs FIFO after any active resident turn. An explicit bounded wait may return16+// current turn at the next tool or model boundary; otherwise it is accepted
17-// that turn's completion or expire while the child stays active.17+// durably as a new turn that runs FIFO after any active one. An explicit
18+// bounded wait may return the answering turn's completion or expire while the
19+// child stays active.
18 type SubagentResumeTool struct {20 type SubagentResumeTool struct {
19 ResidentManager *subagents.ResidentManager21 ResidentManager *subagents.ResidentManager
20 Enabled func() bool22 Enabled func() bool
@@ -23,9 +25,18 @@ type SubagentResumeTool struct {
23 type subagentResumeArgs struct {25 type subagentResumeArgs struct {
24 AgentID string `json:"agent_id"`26 AgentID string `json:"agent_id"`
25 Prompt string `json:"prompt"`27 Prompt string `json:"prompt"`
28+ Mode string `json:"mode,omitempty"`
26 Wait *int `json:"wait,omitempty"`29 Wait *int `json:"wait,omitempty"`
27 }30 }
28 31
32+// subagentResumeResponse reports how a follow-up was delivered.
33+type subagentResumeResponse struct {
34+ Action string `json:"action"`
35+ Delivery string `json:"delivery"`
36+ Agent subagentStatusEntry `json:"agent"`
37+ Wait *subagentWaitOutcome `json:"wait,omitempty"`
38+}
39+
29 // Name returns the shared facade name: this type is an internal40 // Name returns the shared facade name: this type is an internal
30 // implementation and is never registered on its own.41 // implementation and is never registered on its own.
31 func (t *SubagentResumeTool) Name() string { return SubagentToolName }42 func (t *SubagentResumeTool) Name() string { return SubagentToolName }
@@ -57,6 +68,13 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
57 if strings.TrimSpace(args.Prompt) == "" {68 if strings.TrimSpace(args.Prompt) == "" {
58 return protocolToolError(prefix + ": prompt is required")69 return protocolToolError(prefix + ": prompt is required")
59 }70 }
71+ mode := subagents.ResumeMode(strings.TrimSpace(args.Mode))
72+ if mode == "" {
73+ mode = subagents.ResumeSteer
74+ }
75+ if mode != subagents.ResumeSteer && mode != subagents.ResumeQueue {
76+ return protocolToolError(prefix + ": mode must be steer or queue")
77+ }
60 if args.Wait != nil && (*args.Wait < 1 || *args.Wait > maxSubagentWaitSeconds) {78 if args.Wait != nil && (*args.Wait < 1 || *args.Wait > maxSubagentWaitSeconds) {
61 return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))79 return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))
62 }80 }
@@ -64,18 +82,29 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
64 if !ok {82 if !ok {
65 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))83 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
66 }84 }
67- // The watch is registered before the follow-up is accepted, so a turn that85+ // Each watch is registered before its turn can finish: the queued turn's
68- // finishes immediately cannot complete before this call subscribes.86+ // before acceptance, and the steered turn's from the child's control loop
87+ // before that turn can report completion.
69 turnID := uuid.NewString()88 turnID := uuid.NewString()
70 var completionResult <-chan subagents.ResidentCompletion89 var completionResult <-chan subagents.ResidentCompletion
71 cancelWait := func() {}90 cancelWait := func() {}
91+ var onSteered func(string)
72 if args.Wait != nil {92 if args.Wait != nil {
73 completionResult, cancelWait = t.ResidentManager.WatchCompletion(snapshot.ID, turnID)93 completionResult, cancelWait = t.ResidentManager.WatchCompletion(snapshot.ID, turnID)
94+ onSteered = func(activeTurn string) {
95+ cancelWait()
96+ completionResult, cancelWait = t.ResidentManager.WatchCompletion(snapshot.ID, activeTurn)
97+ }
74 }98 }
75- defer cancelWait()99+ defer func() { cancelWait() }()
76- if err := t.ResidentManager.ResumeWithTurn(ctx, snapshot.ID, args.Prompt, turnID); err != nil {100+ delivered, err := t.ResidentManager.ResumeFollowUp(ctx, snapshot.ID, args.Prompt, mode, turnID, onSteered)
101+ if err != nil {
77 return protocolToolError(prefix + ": " + err.Error())102 return protocolToolError(prefix + ": " + err.Error())
78 }103 }
104+ delivery := "queued"
105+ if delivered.Steered {
106+ delivery = "steered"
107+ }
79 var outcome *subagentWaitOutcome108 var outcome *subagentWaitOutcome
80 if args.Wait != nil {109 if args.Wait != nil {
81 completion, timedOut, err := waitForResidentCompletion(ctx, completionResult, *args.Wait, prefix)110 completion, timedOut, err := waitForResidentCompletion(ctx, completionResult, *args.Wait, prefix)
@@ -88,10 +117,11 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
88 outcome.Status = result.Status117 outcome.Status = result.Status
89 outcome.Error = result.Error118 outcome.Error = result.Error
90 outcome.Summary = result.Summary119 outcome.Summary = result.Summary
120+ outcome.Undelivered = result.Undelivered
91 }121 }
92 }122 }
93 if updated, ok := t.ResidentManager.SnapshotFor(snapshot.ID); ok {123 if updated, ok := t.ResidentManager.SnapshotFor(snapshot.ID); ok {
94 snapshot = updated124 snapshot = updated
95 }125 }
96- return renderResidentActionWait("resumed", publicResidentStatus(snapshot), outcome)126+ return renderSubagentResponse(subagentResumeResponse{Action: "resumed", Delivery: delivery, Agent: publicResidentStatus(snapshot), Wait: outcome})
97 }127 }
M packages/agent/tools/subagent_spawn.go +2 -0
@@ -33,6 +33,8 @@ const (
3333 SubagentActionStatus = "status"
3434 SubagentActionStop = "stop"
3535 SubagentActionResume = "resume"
36+ // SubagentActionInterrupt cancels only the running turn and keeps the child.
37+ SubagentActionInterrupt = "interrupt"
3638
3739 maxSubagentWaitSeconds = 5 * 60
3840 )
@@ -33,6 +33,8 @@ const (
33 SubagentActionStatus = "status"33 SubagentActionStatus = "status"
34 SubagentActionStop = "stop"34 SubagentActionStop = "stop"
35 SubagentActionResume = "resume"35 SubagentActionResume = "resume"
36+ // SubagentActionInterrupt cancels only the running turn and keeps the child.
37+ SubagentActionInterrupt = "interrupt"
36 38
37 maxSubagentWaitSeconds = 5 * 6039 maxSubagentWaitSeconds = 5 * 60
38 )40 )
M packages/agent/tools/subagent_status.go +3 -1
@@ -83,6 +83,8 @@ type subagentStatusEntry struct {
8383 Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"`
8484 Required bool `json:"required,omitempty"`
8585 OwnedElsewhere bool `json:"owned_elsewhere,omitempty"`
86+ // PendingFollowUps counts accepted follow-ups the child has not read yet.
87+ PendingFollowUps int `json:"pending_followups,omitempty"`
8688 }
8789
8890 // Name returns the shared facade name: this type is an internal
@@ -295,7 +297,7 @@ func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id strin
295297 }
296298
297299 func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry {
298- return subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere}
300+ return subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere, PendingFollowUps: snapshot.PendingFollowUps}
299301 }
300302
301303 func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {
@@ -83,6 +83,8 @@ type subagentStatusEntry struct {
83 Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"`83 Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"`
84 Required bool `json:"required,omitempty"`84 Required bool `json:"required,omitempty"`
85 OwnedElsewhere bool `json:"owned_elsewhere,omitempty"`85 OwnedElsewhere bool `json:"owned_elsewhere,omitempty"`
86+ // PendingFollowUps counts accepted follow-ups the child has not read yet.
87+ PendingFollowUps int `json:"pending_followups,omitempty"`
86 }88 }
87 89
88 // Name returns the shared facade name: this type is an internal90 // Name returns the shared facade name: this type is an internal
@@ -295,7 +297,7 @@ func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id strin
295 }297 }
296 298
297 func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry {299 func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry {
298- return subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere}300+ return subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere, PendingFollowUps: snapshot.PendingFollowUps}
299 }301 }
300 302
301 func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {303 func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {
M packages/agent/tools/subagent_status_test.go +6 -6
@@ -15,8 +15,8 @@ import (
1515 func TestResidentStatusWatchReportsLiveActivity(t *testing.T) {
1616 ready := make(chan struct{})
1717 release := make(chan struct{})
18- manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
19- return func(ctx context.Context, _ string) error {
18+ manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
19+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
2020 _ = journal.RecordAgentEvent(core.EvTextDelta{Delta: "Reading the parser"})
2121 _ = journal.RecordAgentEvent(core.EvToolExecutionStarted{ID: "call-1", Name: "read"})
2222 close(ready)
@@ -26,7 +26,7 @@ func TestResidentStatusWatchReportsLiveActivity(t *testing.T) {
2626 case <-ctx.Done():
2727 return ctx.Err()
2828 }
29- }, nil
29+ }), nil
3030 })
3131 t.Cleanup(func() { _ = manager.Close(context.Background()) })
3232 spec := subagents.ResidentChildSpec{ID: "watched", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
@@ -87,11 +87,11 @@ func TestResidentStatusResultReadErrors(t *testing.T) {
8787 } {
8888 t.Run(scenario.name, func(t *testing.T) {
8989 root := t.TempDir()
90- manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
91- return func(ctx context.Context, _ string) error {
90+ manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
91+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
9292 <-ctx.Done()
9393 return ctx.Err()
94- }, nil
94+ }), nil
9595 })
9696 t.Cleanup(func() { _ = manager.Close(context.Background()) })
9797 spec := subagents.ResidentChildSpec{ID: "status-error", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
@@ -15,8 +15,8 @@ import (
15 func TestResidentStatusWatchReportsLiveActivity(t *testing.T) {15 func TestResidentStatusWatchReportsLiveActivity(t *testing.T) {
16 ready := make(chan struct{})16 ready := make(chan struct{})
17 release := make(chan struct{})17 release := make(chan struct{})
18- manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {18+ manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
19- return func(ctx context.Context, _ string) error {19+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
20 _ = journal.RecordAgentEvent(core.EvTextDelta{Delta: "Reading the parser"})20 _ = journal.RecordAgentEvent(core.EvTextDelta{Delta: "Reading the parser"})
21 _ = journal.RecordAgentEvent(core.EvToolExecutionStarted{ID: "call-1", Name: "read"})21 _ = journal.RecordAgentEvent(core.EvToolExecutionStarted{ID: "call-1", Name: "read"})
22 close(ready)22 close(ready)
@@ -26,7 +26,7 @@ func TestResidentStatusWatchReportsLiveActivity(t *testing.T) {
26 case <-ctx.Done():26 case <-ctx.Done():
27 return ctx.Err()27 return ctx.Err()
28 }28 }
29- }, nil29+ }), nil
30 })30 })
31 t.Cleanup(func() { _ = manager.Close(context.Background()) })31 t.Cleanup(func() { _ = manager.Close(context.Background()) })
32 spec := subagents.ResidentChildSpec{ID: "watched", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}32 spec := subagents.ResidentChildSpec{ID: "watched", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
@@ -87,11 +87,11 @@ func TestResidentStatusResultReadErrors(t *testing.T) {
87 } {87 } {
88 t.Run(scenario.name, func(t *testing.T) {88 t.Run(scenario.name, func(t *testing.T) {
89 root := t.TempDir()89 root := t.TempDir()
90- manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {90+ manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
91- return func(ctx context.Context, _ string) error {91+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
92 <-ctx.Done()92 <-ctx.Done()
93 return ctx.Err()93 return ctx.Err()
94- }, nil94+ }), nil
95 })95 })
96 t.Cleanup(func() { _ = manager.Close(context.Background()) })96 t.Cleanup(func() { _ = manager.Close(context.Background()) })
97 spec := subagents.ResidentChildSpec{ID: "status-error", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}97 spec := subagents.ResidentChildSpec{ID: "status-error", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
A packages/agent/tools/subagent_steer_test.go +146 -0
@@ -0,0 +1,146 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/agent/subagents"
11+)
12+
13+// steerRuntime blocks its turn until the first steer arrives, then finishes.
14+type steerRuntime struct {
15+ started chan struct{}
16+ steered chan string
17+
18+ mu sync.Mutex
19+ running bool
20+}
21+
22+func (r *steerRuntime) Run(ctx context.Context, _ string) error {
23+ r.mu.Lock()
24+ r.running = true
25+ r.mu.Unlock()
26+ defer func() { r.mu.Lock(); r.running = false; r.mu.Unlock() }()
27+ r.started <- struct{}{}
28+ select {
29+ case <-ctx.Done():
30+ return ctx.Err()
31+ case <-r.steered:
32+ return nil
33+ }
34+}
35+
36+func (r *steerRuntime) Steer(prompt string) bool {
37+ r.mu.Lock()
38+ defer r.mu.Unlock()
39+ if !r.running {
40+ return false
41+ }
42+ r.steered <- prompt
43+ return true
44+}
45+
46+func (r *steerRuntime) PendingSteers() int { return 0 }
47+func (r *steerRuntime) DrainSteers() []string { return nil }
48+
49+// Resume steers a running child by default, and its wait resolves on the
50+// running turn that received the follow-up.
51+func TestResidentResumeSteersRunningChildAndWaitsOnItsTurn(t *testing.T) {
52+ runtime := &steerRuntime{started: make(chan struct{}, 1), steered: make(chan string, 1)}
53+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
54+ return runtime, nil
55+ })
56+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
57+ if _, err := manager.Spawn(t.Context(), subagents.ResidentChildSpec{ID: "steer-tool", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}, "investigate"); err != nil {
58+ t.Fatal(err)
59+ }
60+ select {
61+ case <-runtime.started:
62+ case <-time.After(5 * time.Second):
63+ t.Fatal("turn did not start")
64+ }
65+ facade := &SubagentTool{Resume: &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}}
66+ result, err := facade.Execute(t.Context(), json.RawMessage(`{"action":"resume","agent_id":"steer-tool","prompt":"report now","wait":5}`), nil)
67+ if err != nil || result.IsError {
68+ t.Fatalf("resume = (%#v, %v)", result, err)
69+ }
70+ response, ok := result.Details.(subagentResumeResponse)
71+ if !ok || response.Delivery != "steered" || response.Wait == nil || response.Wait.TimedOut || response.Wait.Status != string(subagents.ResidentCompleted) {
72+ t.Fatalf("response = %#v", result.Details)
73+ }
74+}
75+
76+func TestResidentResumeRejectsUnknownMode(t *testing.T) {
77+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
78+ return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
79+ })
80+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
81+ resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
82+ result, err := resume.Execute(t.Context(), json.RawMessage(`{"agent_id":"x","prompt":"p","mode":"now"}`), nil)
83+ if err != nil || !result.IsError {
84+ t.Fatalf("result = (%#v, %v), want mode error", result, err)
85+ }
86+}
87+
88+func TestResidentInterruptToolRequiresRunningTurn(t *testing.T) {
89+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
90+ return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
91+ })
92+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
93+ done, cancel := manager.WatchCompletion("idle-child", "initial")
94+ defer cancel()
95+ if _, err := manager.Spawn(t.Context(), subagents.ResidentChildSpec{ID: "idle-child", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}, "task"); err != nil {
96+ t.Fatal(err)
97+ }
98+ <-done
99+ interrupt := &SubagentInterruptTool{ResidentManager: manager, Enabled: func() bool { return true }}
100+ result, err := interrupt.Execute(t.Context(), json.RawMessage(`{"agent_id":"idle-child"}`), nil)
101+ if err != nil || !result.IsError {
102+ t.Fatalf("result = (%#v, %v), want no-running-turn error", result, err)
103+ }
104+}
105+
106+// The facade dispatches interrupt to a running child and keeps it live.
107+func TestSubagentFacadeInterruptsRunningTurn(t *testing.T) {
108+ started := make(chan struct{}, 1)
109+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
110+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
111+ started <- struct{}{}
112+ <-ctx.Done()
113+ return ctx.Err()
114+ }), nil
115+ })
116+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
117+ done, cancel := manager.WatchCompletion("running-child", "initial")
118+ defer cancel()
119+ if _, err := manager.Spawn(t.Context(), subagents.ResidentChildSpec{ID: "running-child", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}, "task"); err != nil {
120+ t.Fatal(err)
121+ }
122+ <-started
123+ facade := &SubagentTool{Interrupt: &SubagentInterruptTool{ResidentManager: manager, Enabled: func() bool { return true }}}
124+ result, err := facade.Execute(t.Context(), json.RawMessage(`{"action":"interrupt","agent_id":"running-child"}`), nil)
125+ if err != nil || result.IsError {
126+ t.Fatalf("interrupt = (%#v, %v)", result, err)
127+ }
128+ if response, ok := result.Details.(subagentActionResponse); !ok || response.Action != "interrupt_requested" {
129+ t.Fatalf("response = %#v", result.Details)
130+ }
131+ select {
132+ case completion := <-done:
133+ if completion.Completion().Status != string(subagents.ResidentInterrupted) {
134+ t.Fatalf("completion = %#v", completion)
135+ }
136+ case <-time.After(5 * time.Second):
137+ t.Fatal("interrupted turn did not complete")
138+ }
139+ if manager.Get("running-child") == nil {
140+ t.Fatal("interrupt removed the live child")
141+ }
142+ result, err = facade.Execute(t.Context(), json.RawMessage(`{"action":"interrupt","agent_id":"running-child","mode":"steer"}`), nil)
143+ if err != nil || !result.IsError {
144+ t.Fatalf("mode on interrupt = (%#v, %v), want foreign-field error", result, err)
145+ }
146+}
@@ -0,0 +1,146 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/agent/subagents"
11+)
12+
13+// steerRuntime blocks its turn until the first steer arrives, then finishes.
14+type steerRuntime struct {
15+ started chan struct{}
16+ steered chan string
17+
18+ mu sync.Mutex
19+ running bool
20+}
21+
22+func (r *steerRuntime) Run(ctx context.Context, _ string) error {
23+ r.mu.Lock()
24+ r.running = true
25+ r.mu.Unlock()
26+ defer func() { r.mu.Lock(); r.running = false; r.mu.Unlock() }()
27+ r.started <- struct{}{}
28+ select {
29+ case <-ctx.Done():
30+ return ctx.Err()
31+ case <-r.steered:
32+ return nil
33+ }
34+}
35+
36+func (r *steerRuntime) Steer(prompt string) bool {
37+ r.mu.Lock()
38+ defer r.mu.Unlock()
39+ if !r.running {
40+ return false
41+ }
42+ r.steered <- prompt
43+ return true
44+}
45+
46+func (r *steerRuntime) PendingSteers() int { return 0 }
47+func (r *steerRuntime) DrainSteers() []string { return nil }
48+
49+// Resume steers a running child by default, and its wait resolves on the
50+// running turn that received the follow-up.
51+func TestResidentResumeSteersRunningChildAndWaitsOnItsTurn(t *testing.T) {
52+ runtime := &steerRuntime{started: make(chan struct{}, 1), steered: make(chan string, 1)}
53+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
54+ return runtime, nil
55+ })
56+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
57+ if _, err := manager.Spawn(t.Context(), subagents.ResidentChildSpec{ID: "steer-tool", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}, "investigate"); err != nil {
58+ t.Fatal(err)
59+ }
60+ select {
61+ case <-runtime.started:
62+ case <-time.After(5 * time.Second):
63+ t.Fatal("turn did not start")
64+ }
65+ facade := &SubagentTool{Resume: &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}}
66+ result, err := facade.Execute(t.Context(), json.RawMessage(`{"action":"resume","agent_id":"steer-tool","prompt":"report now","wait":5}`), nil)
67+ if err != nil || result.IsError {
68+ t.Fatalf("resume = (%#v, %v)", result, err)
69+ }
70+ response, ok := result.Details.(subagentResumeResponse)
71+ if !ok || response.Delivery != "steered" || response.Wait == nil || response.Wait.TimedOut || response.Wait.Status != string(subagents.ResidentCompleted) {
72+ t.Fatalf("response = %#v", result.Details)
73+ }
74+}
75+
76+func TestResidentResumeRejectsUnknownMode(t *testing.T) {
77+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
78+ return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
79+ })
80+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
81+ resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
82+ result, err := resume.Execute(t.Context(), json.RawMessage(`{"agent_id":"x","prompt":"p","mode":"now"}`), nil)
83+ if err != nil || !result.IsError {
84+ t.Fatalf("result = (%#v, %v), want mode error", result, err)
85+ }
86+}
87+
88+func TestResidentInterruptToolRequiresRunningTurn(t *testing.T) {
89+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
90+ return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
91+ })
92+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
93+ done, cancel := manager.WatchCompletion("idle-child", "initial")
94+ defer cancel()
95+ if _, err := manager.Spawn(t.Context(), subagents.ResidentChildSpec{ID: "idle-child", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}, "task"); err != nil {
96+ t.Fatal(err)
97+ }
98+ <-done
99+ interrupt := &SubagentInterruptTool{ResidentManager: manager, Enabled: func() bool { return true }}
100+ result, err := interrupt.Execute(t.Context(), json.RawMessage(`{"agent_id":"idle-child"}`), nil)
101+ if err != nil || !result.IsError {
102+ t.Fatalf("result = (%#v, %v), want no-running-turn error", result, err)
103+ }
104+}
105+
106+// The facade dispatches interrupt to a running child and keeps it live.
107+func TestSubagentFacadeInterruptsRunningTurn(t *testing.T) {
108+ started := make(chan struct{}, 1)
109+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
110+ return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
111+ started <- struct{}{}
112+ <-ctx.Done()
113+ return ctx.Err()
114+ }), nil
115+ })
116+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
117+ done, cancel := manager.WatchCompletion("running-child", "initial")
118+ defer cancel()
119+ if _, err := manager.Spawn(t.Context(), subagents.ResidentChildSpec{ID: "running-child", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}, "task"); err != nil {
120+ t.Fatal(err)
121+ }
122+ <-started
123+ facade := &SubagentTool{Interrupt: &SubagentInterruptTool{ResidentManager: manager, Enabled: func() bool { return true }}}
124+ result, err := facade.Execute(t.Context(), json.RawMessage(`{"action":"interrupt","agent_id":"running-child"}`), nil)
125+ if err != nil || result.IsError {
126+ t.Fatalf("interrupt = (%#v, %v)", result, err)
127+ }
128+ if response, ok := result.Details.(subagentActionResponse); !ok || response.Action != "interrupt_requested" {
129+ t.Fatalf("response = %#v", result.Details)
130+ }
131+ select {
132+ case completion := <-done:
133+ if completion.Completion().Status != string(subagents.ResidentInterrupted) {
134+ t.Fatalf("completion = %#v", completion)
135+ }
136+ case <-time.After(5 * time.Second):
137+ t.Fatal("interrupted turn did not complete")
138+ }
139+ if manager.Get("running-child") == nil {
140+ t.Fatal("interrupt removed the live child")
141+ }
142+ result, err = facade.Execute(t.Context(), json.RawMessage(`{"action":"interrupt","agent_id":"running-child","mode":"steer"}`), nil)
143+ if err != nil || !result.IsError {
144+ t.Fatalf("mode on interrupt = (%#v, %v), want foreign-field error", result, err)
145+ }
146+}
M packages/agent/tools/subagent_stop.go +12 -14
@@ -23,19 +23,20 @@ type subagentStopArgs struct {
2323 }
2424
2525 // subagentWaitOutcome reports the result of an explicit bounded wait on one
26-// resident turn. An expired wait leaves the child active.
26+// resident turn. An expired wait leaves the child active. Undelivered lists
27+// steered follow-ups the child never read before that turn ended.
2728 type subagentWaitOutcome struct {
28- Seconds int `json:"seconds"`
29- TimedOut bool `json:"timed_out,omitempty"`
30- Status string `json:"status,omitempty"`
31- Error string `json:"error,omitempty"`
32- Summary string `json:"summary,omitempty"`
29+ Seconds int `json:"seconds"`
30+ TimedOut bool `json:"timed_out,omitempty"`
31+ Status string `json:"status,omitempty"`
32+ Error string `json:"error,omitempty"`
33+ Summary string `json:"summary,omitempty"`
34+ Undelivered []string `json:"undelivered,omitempty"`
3335 }
3436
3537 type subagentActionResponse struct {
36- Action string `json:"action"`
37- Agent subagentStatusEntry `json:"agent"`
38- Wait *subagentWaitOutcome `json:"wait,omitempty"`
38+ Action string `json:"action"`
39+ Agent subagentStatusEntry `json:"agent"`
3940 }
4041
4142 // Name returns the shared facade name: this type is an internal
@@ -80,13 +81,10 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f
8081 }
8182
8283 func renderResidentAction(action string, entry subagentStatusEntry) (core.ToolResult, error) {
83- return renderResidentActionWait(action, entry, nil)
84+ return renderSubagentResponse(subagentActionResponse{Action: action, Agent: entry})
8485 }
8586
86-// renderResidentActionWait renders an action response plus the outcome of an
87-// explicit bounded wait, if the caller requested one.
88-func renderResidentActionWait(action string, entry subagentStatusEntry, wait *subagentWaitOutcome) (core.ToolResult, error) {
89- response := subagentActionResponse{Action: action, Agent: entry, Wait: wait}
87+func renderSubagentResponse(response any) (core.ToolResult, error) {
9088 data, err := json.Marshal(response)
9189 if err != nil {
9290 return core.ToolResult{}, fmt.Errorf("subagent action: encode response: %w", err)
@@ -23,19 +23,20 @@ type subagentStopArgs struct {
23 }23 }
24 24
25 // subagentWaitOutcome reports the result of an explicit bounded wait on one25 // subagentWaitOutcome reports the result of an explicit bounded wait on one
26-// resident turn. An expired wait leaves the child active.26+// resident turn. An expired wait leaves the child active. Undelivered lists
27+// steered follow-ups the child never read before that turn ended.
27 type subagentWaitOutcome struct {28 type subagentWaitOutcome struct {
28- Seconds int `json:"seconds"`29+ Seconds int `json:"seconds"`
29- TimedOut bool `json:"timed_out,omitempty"`30+ TimedOut bool `json:"timed_out,omitempty"`
30- Status string `json:"status,omitempty"`31+ Status string `json:"status,omitempty"`
31- Error string `json:"error,omitempty"`32+ Error string `json:"error,omitempty"`
32- Summary string `json:"summary,omitempty"`33+ Summary string `json:"summary,omitempty"`
34+ Undelivered []string `json:"undelivered,omitempty"`
33 }35 }
34 36
35 type subagentActionResponse struct {37 type subagentActionResponse struct {
36- Action string `json:"action"`38+ Action string `json:"action"`
37- Agent subagentStatusEntry `json:"agent"`39+ Agent subagentStatusEntry `json:"agent"`
38- Wait *subagentWaitOutcome `json:"wait,omitempty"`
39 }40 }
40 41
41 // Name returns the shared facade name: this type is an internal42 // Name returns the shared facade name: this type is an internal
@@ -80,13 +81,10 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f
80 }81 }
81 82
82 func renderResidentAction(action string, entry subagentStatusEntry) (core.ToolResult, error) {83 func renderResidentAction(action string, entry subagentStatusEntry) (core.ToolResult, error) {
83- return renderResidentActionWait(action, entry, nil)84+ return renderSubagentResponse(subagentActionResponse{Action: action, Agent: entry})
84 }85 }
85 86
86-// renderResidentActionWait renders an action response plus the outcome of an87+func renderSubagentResponse(response any) (core.ToolResult, error) {
87-// explicit bounded wait, if the caller requested one.
88-func renderResidentActionWait(action string, entry subagentStatusEntry, wait *subagentWaitOutcome) (core.ToolResult, error) {
89- response := subagentActionResponse{Action: action, Agent: entry, Wait: wait}
90 data, err := json.Marshal(response)88 data, err := json.Marshal(response)
91 if err != nil {89 if err != nil {
92 return core.ToolResult{}, fmt.Errorf("subagent action: encode response: %w", err)90 return core.ToolResult{}, fmt.Errorf("subagent action: encode response: %w", err)
M packages/agent/tools/subagent_test.go +5 -4
@@ -63,13 +63,13 @@ func TestSubagentFacadeNameAndSchema(t *testing.T) {
6363 }
6464 for _, field := range []string{
6565 "action", "task", "agent", "model", "provider", "reasoning", "fast_mode",
66- "required", "wait", "isolation", "agent_id", "include_result", "watch", "prompt",
66+ "required", "wait", "isolation", "agent_id", "include_result", "watch", "prompt", "mode",
6767 } {
6868 if _, ok := schema.Properties[field]; !ok {
6969 t.Fatalf("schema is missing property %q", field)
7070 }
7171 }
72- if got := facade.Description(); got != "Spawn, inspect, resume, and stop resident sub-agents. One action per call.\n\n"+subagentSpawnGuidance {
72+ if got := facade.Description(); got != "Spawn, inspect, resume, interrupt, and stop resident sub-agents. One action per call.\n\n"+subagentSpawnGuidance {
7373 t.Fatalf("description = %q", got)
7474 }
7575 }
@@ -121,10 +121,11 @@ var subagentActionAnnotation = map[string]string{
121121 "required": "Only valid when action is spawn.",
122122 "wait": "Only valid when action is spawn or resume.",
123123 "isolation": "Only valid when action is spawn.",
124- "agent_id": "Required when action is stop or resume.",
124+ "agent_id": "Required when action is stop, resume, or interrupt.",
125125 "include_result": "Only valid when action is status.",
126126 "watch": "Only valid when action is status.",
127127 "prompt": "Required when action is resume.",
128+ "mode": "Only valid when action is resume.",
128129 }
129130
130131 func TestSubagentFacadeSchemaAnnotatesActionFields(t *testing.T) {
@@ -308,7 +309,7 @@ func TestSubagentFacadeRejectsForeignFieldsAndKeepsUnknownOnes(t *testing.T) {
308309 if err != nil {
309310 t.Fatalf("unexpected host error: %v", err)
310311 }
311- if !result.IsError || facadeResultText(t, result) != "subagent: agent_id is not valid for action spawn; it belongs to the resume, status, stop actions" {
312+ if !result.IsError || facadeResultText(t, result) != "subagent: agent_id is not valid for action spawn; it belongs to the interrupt, resume, status, stop actions" {
312313 t.Fatalf("multi-owner foreign field result = %#v", result)
313314 }
314315 _, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"stop","agent_id":"nope","bogus":1}`), nil)
@@ -63,13 +63,13 @@ func TestSubagentFacadeNameAndSchema(t *testing.T) {
63 }63 }
64 for _, field := range []string{64 for _, field := range []string{
65 "action", "task", "agent", "model", "provider", "reasoning", "fast_mode",65 "action", "task", "agent", "model", "provider", "reasoning", "fast_mode",
66- "required", "wait", "isolation", "agent_id", "include_result", "watch", "prompt",66+ "required", "wait", "isolation", "agent_id", "include_result", "watch", "prompt", "mode",
67 } {67 } {
68 if _, ok := schema.Properties[field]; !ok {68 if _, ok := schema.Properties[field]; !ok {
69 t.Fatalf("schema is missing property %q", field)69 t.Fatalf("schema is missing property %q", field)
70 }70 }
71 }71 }
72- if got := facade.Description(); got != "Spawn, inspect, resume, and stop resident sub-agents. One action per call.\n\n"+subagentSpawnGuidance {72+ if got := facade.Description(); got != "Spawn, inspect, resume, interrupt, and stop resident sub-agents. One action per call.\n\n"+subagentSpawnGuidance {
73 t.Fatalf("description = %q", got)73 t.Fatalf("description = %q", got)
74 }74 }
75 }75 }
@@ -121,10 +121,11 @@ var subagentActionAnnotation = map[string]string{
121 "required": "Only valid when action is spawn.",121 "required": "Only valid when action is spawn.",
122 "wait": "Only valid when action is spawn or resume.",122 "wait": "Only valid when action is spawn or resume.",
123 "isolation": "Only valid when action is spawn.",123 "isolation": "Only valid when action is spawn.",
124- "agent_id": "Required when action is stop or resume.",124+ "agent_id": "Required when action is stop, resume, or interrupt.",
125 "include_result": "Only valid when action is status.",125 "include_result": "Only valid when action is status.",
126 "watch": "Only valid when action is status.",126 "watch": "Only valid when action is status.",
127 "prompt": "Required when action is resume.",127 "prompt": "Required when action is resume.",
128+ "mode": "Only valid when action is resume.",
128 }129 }
129 130
130 func TestSubagentFacadeSchemaAnnotatesActionFields(t *testing.T) {131 func TestSubagentFacadeSchemaAnnotatesActionFields(t *testing.T) {
@@ -308,7 +309,7 @@ func TestSubagentFacadeRejectsForeignFieldsAndKeepsUnknownOnes(t *testing.T) {
308 if err != nil {309 if err != nil {
309 t.Fatalf("unexpected host error: %v", err)310 t.Fatalf("unexpected host error: %v", err)
310 }311 }
311- if !result.IsError || facadeResultText(t, result) != "subagent: agent_id is not valid for action spawn; it belongs to the resume, status, stop actions" {312+ if !result.IsError || facadeResultText(t, result) != "subagent: agent_id is not valid for action spawn; it belongs to the interrupt, resume, status, stop actions" {
312 t.Fatalf("multi-owner foreign field result = %#v", result)313 t.Fatalf("multi-owner foreign field result = %#v", result)
313 }314 }
314 _, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"stop","agent_id":"nope","bogus":1}`), nil)315 _, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"stop","agent_id":"nope","bogus":1}`), nil)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close