fix(subagent): deep-clone lastAssistantMessage onto subagent/end (observe-only)
Codex review caught an observe-only violation: the subagent/end emit fires from a detached `.then` registered BEFORE start() returns — so before the caller's own `await run.result` continuation runs. Carrying `result.output` by reference let a mutating subagent/end listener corrupt the SubagentResult.output the caller/tool then consumes. structuredClone() makes the event a read-only snapshot. Added a regression test that mutates the event's array and asserts the caller's result is untouched; proven to fail red without the clone. Updated the RFC + READMEs to note the clone is load-bearing for the observe-only guarantee.
This commit is contained in:
@@ -16,7 +16,7 @@ Add two pieces of information to the subagent lifecycle surface:
|
||||
|
||||
1. **`agentType` — a caller-supplied subagent-kind label**, the harness analogue of CC's `subagent_type`. It is optional on `SubagentStartRequest`, carried VERBATIM onto both `subagent/start` (`SubagentRunInfo`) and `subagent/end` (`SubagentRunEndInfo`). The seam never interprets it. The model-facing `dsh-tool-subagent` tool threads it from a new optional `Config.agentType`, so a deployment that exposes multiple subagent kinds (one tool load per kind) labels each. Absent when the caller does not distinguish kinds (the spread omits the key — `exactOptionalPropertyTypes`-correct).
|
||||
|
||||
2. **`lastAssistantMessage` — the child's final output**, added to `SubagentRunEndInfo`. On the settle path it is `SubagentResult.output` (so an observer sees WHAT the subagent produced without holding the run). On the REJECT path (an infrastructure fault where no `SubagentResult` was produced — the seam only knows `stopReason: 'error'`) it is absent.
|
||||
2. **`lastAssistantMessage` — the child's final output**, added to `SubagentRunEndInfo`. On the settle path it is a DEEP CLONE of `SubagentResult.output` (so an observer sees WHAT the subagent produced without holding the run). On the REJECT path (an infrastructure fault where no `SubagentResult` was produced — the seam only knows `stopReason: 'error'`) it is absent. The clone is load-bearing for observe-only: the `subagent/end` emit fires from a detached `.then` registered *before* `start()` returns, i.e. before the caller's own `await run.result` continuation — handing listeners the same array reference would let a mutating listener corrupt the caller's `SubagentResult.output`. `structuredClone` makes the event a read-only view (a regression test mutates the event's array and asserts the caller's result is untouched).
|
||||
|
||||
Both events stay plain **`emit`s**. `subagent/end` fires from a detached `.then` on `run.result` and awaits no listener, so it is genuinely observe-only by construction — a `subagent/start` listener can still reach the live child via `ctx.agents.get(info.id)` and `inject()` into it; a `subagent/end` listener can only observe (the run has settled). Per-listener containment (already in place) keeps one bad subscriber from stranding a live run or surfacing as an unhandled rejection on the detached settle hook.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user