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:
Tianyi Cui
2026-06-30 21:52:16 +08:00
parent 7cc7b9cf7f
commit 93106b87b4
4 changed files with 39 additions and 3 deletions

View File

@@ -201,7 +201,15 @@ export class SubagentService extends Service {
// Per-listener containment also keeps a thrown `subagent/end` listener from
// becoming an unhandled rejection on this detached `.then`.
void run.result.then(
(result) => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, ...agentType, stopReason: result.stopReason, lastAssistantMessage: result.output }) },
(result) => {
// Deep-clone the output onto the event: this detached `.then` runs BEFORE
// the caller's own `await run.result` continuation, so handing listeners
// the SAME array reference the caller consumes would let a mutating
// `subagent/end` listener corrupt the caller's SubagentResult.output —
// breaking the observe-only contract. A snapshot makes the event a
// read-only view, not a shared handle.
this.emitLifecycle('subagent/end', { provider: name, id: run.id, ...agentType, stopReason: result.stopReason, lastAssistantMessage: structuredClone(result.output) })
},
() => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, ...agentType, stopReason: 'error' }) },
)
return run