# Sub-agent live-streaming promotion

**Origin:** VeilCLI_Studio brief — sub-agent sessions show typing-dots → message pops in complete; no live thinking, no live content chunks, no live tool indicators.

**Status:** Design-only. Awaiting multi-criticizer review before any code change.

---

## 1. Goal

Make sub-agent (tool-driven) sessions emit the same `session.stream` envelope events on the eventBus that user-driven SSE-triggered sessions already emit, so Studio's WS bridge renders them identically.

**In scope:**
- `inference.chunk` (LLM content tokens, openai engine; varies on claude-cli)
- `thinking.chunk` (extended-thinking deltas; both engines via the existing wiring)
- `tool.chunk` (live stdout/stderr from streaming tools, e.g. `bash`)
- `done` (turn-end stats refresh signal)

**Out of scope:**
- New event types
- Studio-side changes (already listens to `session.stream`)
- Changes to the SSE-triggered chat path (it already works)
- Daemon ticks, task runs (different envelope; left as-is)

---

## 2. Verified facts (line citations)

| Claim | Site | Verdict |
|---|---|---|
| SSE handler bridges chunks to bus via `sendEvent` | [api/routes/chat.js:88-100](../api/routes/chat.js) | ✓ confirmed |
| `needsStreaming = !!onChunk \|\| !!onThinkingChunk \|\| audio` | [llm/client.js:53](../llm/client.js) | ✓ confirmed — without callbacks the openai LLM call is non-streaming, chunks never reach `runLoop` |
| `effectiveOnInferenceToolStart` default-emits `chat.inference_tool` to the bus | [core/router.js:446-448](../core/router.js), [570-572](../core/router.js) | ✓ this is the template pattern |
| Sub-agent invocations bypass the SSE handler | tools/agent_spawn.js:194, tools/agent_message.js:153/197, core/async-inform.js:60, core/wake.js:154 | ✓ all 6 sites verified (brief enumerated 5; **wake.js was missed**) |
| `runLoop` invokes onToolChunk with `(toolCallId, toolName, content)` | [core/loop.js:694](../core/loop.js) | ✓ — the brief's snippet had `(toolCallId, content, toolName)`; **brief is wrong**, real signature is the chat.js order |
| `chat.response` already fires on bus at runChat-end | router.js:481, 613 | ✓ — `done` emit must be gated to avoid double-fire |
| WS server is a passthrough fan-out from eventBus | [api/server.js:103-108](../api/server.js) | ✓ — any `eventBus.emit('event', ...)` reaches all WS clients |

---

## 3. Implementation

### 3.0 Round-1+2 criticizer-driven amendments

Five substantive amendments after the 5-criticizer review and the round-2 fix-verification pass:

- **Cancel-aware chunk emission** (concurrency criticizer): gate every chunk-emitting closure on `!cancelSignal?.aborted` so chunks can't appear after the cancel boundary.
- **`done` payload parity** (backward-compat criticizer): include `session`, `agentName`, `model` so the WS shape matches what the SSE handler emits. **Reuse the already-fetched session** instead of calling `db.getSession(sid)` a second time (round-2 concern C).
- **`responseModel` on claude-cli branch** (round-2 criticizer): the openai loop captures `responseModel = event.model` at router.js:468 but the claude-cli loop at line 598 does NOT. Without this, the new `done` emit ships `model: undefined` for claude-cli sub-agents. Fix at the same place the loop already reads `responseContent`.
- **Terminal-event-on-error** (failure-modes criticizer): emit a `session.stream/error` from the runChat catch-path AND wake.js's catch-path so Studio never sees "chunks but no terminal event."
- **Doc updates cover ALL contradicting passages** (round-2 criticizer): `docs/api/09-websocket.md` says `session.stream` is "SSE-only" in TWO places — line ~97 in the section header AND lines ~5-9 in the trigger-path-parity callout. Both must change.

**Dropped from round-1 design:** the `listenerCount('event') > 0` perf guard was over-engineered. With zero listeners, `eventBus.emit` is a single ~1μs EventEmitter dispatch and `JSON.stringify` only runs INSIDE listener bodies (so zero listeners = zero stringify). The guard saved nothing the EventEmitter already provides. Internal SSE listeners (per-session stream, task stream, trace forwarder) make `listenerCount > 0` almost always true anyway. Removed for simplicity.

### 3.1 Where the closures live

**Decision:** install the streaming-callback defaults inside `_doRunChatBody` (openai) and `runClaudeChat` (claude-cli), after `sid` is resolved. NOT in `runChat` itself — at the runChat scope, `sessionId` is `undefined` for fresh-session runs (sid is created downstream).

This mirrors where `effectiveOnInferenceToolStart` already lives (router.js:446-448 and 570-572). The two functions already have `sid`, `agentName`, and the eventBus import in scope. One-block addition per function.

### 3.2 Patch — `_doRunChatBody` (openai)

**Location:** [core/router.js:446-448](../core/router.js), immediately after `effectiveOnInferenceToolStart`.

```js
const effectiveOnInferenceToolStart = onInferenceToolStart || ((toolName) => {
  eventBus.emit('event', { type: 'chat.inference_tool', sessionId: sid, agentName, event: { name: toolName, timestamp: Date.now() } });
});

// Promote streaming-chunk callbacks to bus emitters when the caller didn't
// supply their own. SSE route at api/routes/chat.js:88-100 supplies its own
// (which both write the SSE frame AND emit the bus event), so its callbacks
// win and these defaults are never installed for SSE-triggered turns.
// Tool-driven invocations (agent_spawn, agent_message, async-inform, wake)
// reach this path with all callbacks undefined → defaults install → the WS
// bridge sees the same session.stream/inference.chunk + thinking.chunk +
// tool.chunk envelopes the SSE handler emits.
//
// Cancel gate on every default closure (round-1 concurrency criticizer #4):
// chunks fired AFTER cancelSignal.aborted=true would arrive on the bus AFTER
// the error/CANCELLED emit and confuse consumers. Each closure re-evaluates
// cancelSignal.aborted on every call (NOT at definition time) — `cancelSignal`
// is captured by reference from runChat's outer scope (declared once at
// router.js:451, never reassigned — defensive comment added there in this
// patch).
const effectiveOnStreamChunk = onStreamChunk || ((content) => {
  if (cancelSignal?.aborted) return;
  eventBus.emit('event', { type: 'session.stream', sessionId: sid, agentName, eventType: 'inference.chunk', data: { content } });
});
const effectiveOnThinkingChunk = onThinkingChunk || ((content) => {
  if (cancelSignal?.aborted) return;
  eventBus.emit('event', { type: 'session.stream', sessionId: sid, agentName, eventType: 'thinking.chunk', data: { content } });
});
// Real callsite signature is (toolCallId, toolName, content) — see core/loop.js:694.
const effectiveOnToolChunk = onToolChunk || ((toolCallId, toolName, content) => {
  if (cancelSignal?.aborted) return;
  eventBus.emit('event', { type: 'session.stream', sessionId: sid, agentName, eventType: 'tool.chunk', data: { toolCallId, toolName, content } });
});
```

**Defensive comment to add at [core/router.js:451](../core/router.js)** where cancelSignal is declared:

```js
// cancelSignal is captured by reference into the streaming default closures
// below. Do NOT reassign — re-binding the variable would leave the closures
// pointing at the old AbortSignal and the cancel-gate would silently break.
const cancelSignal = cancelRegistry.register(`session:${sid}`);
```

Then change the `runLoop` call at [core/router.js:460](../core/router.js):

```js
for await (const event of runLoop({
  agent, messages, settings, mode: 'chat', sessionId: sid, cwd, modeConfig,
  thinking, overrides,
  onStreamChunk:        effectiveOnStreamChunk,        // was: onStreamChunk
  onInferenceToolStart: effectiveOnInferenceToolStart,
  onToolChunk:          effectiveOnToolChunk,          // was: onToolChunk
  onThinkingChunk:      effectiveOnThinkingChunk,      // was: onThinkingChunk
  cancelSignal, budgetOverride,
})) {
```

### 3.3 Patch — `runClaudeChat` (claude-cli)

**Round-3 fix — declare `responseModel` in the outer scope (line ~568).** The openai branch declares `let responseModel = null` at router.js:442; the claude-cli branch is missing the same declaration. Without it, `responseModel = event.model` inside the for-await loop assigns to an undeclared identifier (strict-mode error / implicit global). Add it alongside the existing variable declarations:

```js
// At router.js around line 564-568, add the missing declaration:
const runStartTime = Date.now();
let responseContent = null;
let responseModel   = null;          // ROUND-3 FIX — was missing on claude-cli
let tokenUsage      = { input: 0, output: 0, cache: 0, cost: 0 };
let totalIterations = 0;
const toolCallLog   = [];
```

**Location:** [core/router.js:570-572](../core/router.js), immediately after the existing `effectiveOnInferenceToolStart`.

Same three closures (identical bodies). Then update the `runClaudeSession` call at [core/router.js:585-589](../core/router.js):

```js
for await (const event of runClaudeSession({
  agent, settings, mode: 'chat', sessionId: sid, cwd, modeConfig,
  providerConfig: provider, userMessage: message || '', cancelSignal,
  thinking, overrides,
  onStreamChunk:        effectiveOnStreamChunk,
  onInferenceToolStart: effectiveOnInferenceToolStart,
  onThinkingChunk:      effectiveOnThinkingChunk,
  // Note: claude-cli engine does not currently consume onToolChunk (the SDK
  // surfaces tool stdout/stderr differently). Keep the local `effective*`
  // closure built for symmetry with the openai branch but don't pass it.
  claudeSessionId,
})) {
  if (onEvent) onEvent(event);
  else if (event.type === 'message') {
    eventBus.emit('event', { type: 'chat.message', sessionId: sid, agentName, event: { ...event.message, iteration: event.iteration, timestamp: Date.now() } });
  }
  if (event.type === 'chat.response') {
    responseContent = event.content;
    tokenUsage      = event.totalTokens;
    totalIterations = event.iteration;
    responseModel   = event.model;        // ROUND-2 FIX — was missing on this branch
  }
}
```

**Round-2 fix:** `responseModel = event.model` is captured here (mirroring the openai branch at router.js:468) so the `done` emit's `model` field is populated. `engines/claude-engine.js:644` already supplies `model: modelKey` on the `chat.response` event — the router just wasn't reading it.

(Confirm by reading `engines/claude-engine.js` whether `onToolChunk` is consumed; if added later it will already have the default available.)

### 3.4 Patch — `done` event emit

**Location:** runChat-end. Two sites: [router.js:481](../core/router.js) (openai) and [router.js:613](../core/router.js) (claude-cli), where `chat.response` is already emitted.

**Payload parity (round-1 backward-compat criticizer):** the SSE handler emits `done` with `{ session, agentName, model, iterations, durationMs, tokenUsage, toolCalls, cancelled }` ([chat.js:121-127](../api/routes/chat.js)). Match that shape exactly so Studio's `done` consumer doesn't break when it expects `data.session.id`.

**Reuse the existing session fetch (round-2 concern C):** runChat-end already calls `db.getSession(sid)` to build the return shape (router.js:483 openai / line 622 claude-cli). The done emit must reuse that variable, not re-fetch.

```js
// Existing code path at runChat-end:
const sessionRow = db.getSession(sid);   // already-fetched for the return shape
eventBus.emit('event', { type: 'chat.response', sessionId: sid, agentName, event: { content: responseContent || null, toolCount: toolCallLog.length, timestamp: Date.now() } });

// Studio's session.stream/done event for tool-driven calls. The SSE handler
// (api/routes/chat.js:121) emits its own done frame after runChat returns —
// gate on !onEvent so we don't double-emit when SSE is the trigger.
if (!onEvent) {
  eventBus.emit('event', {
    type: 'session.stream', sessionId: sid, agentName,
    eventType: 'done',
    data: {
      session:    sessionRow,            // reuse, not a second db.getSession()
      agentName,
      model:      responseModel,         // captured in the loop (openai line 468; claude-cli line ~600 — round-2 fix)
      iterations: totalIterations,
      durationMs: Date.now() - runStartTime,
      tokenUsage,
      toolCalls:  toolCallLog,
      cancelled,
      ...(cancelReason !== undefined && { cancelReason }),
    },
  });
}

// Use sessionRow for the runChat return shape too — saves a second db.getSession.
return { sessionId: sid, content: responseContent, ..., session: sessionRow, ... };
```

### 3.5 Patch — terminal `error` event on cancel, budget breach, OR exception

When the loop is cancelled (user_cancel, budget_exceeded), the SSE handler currently emits `error { code: 'CANCELLED' }`. Tool-driven calls receive `cancelled: true` on the runChat return shape but no bus event mirrors the SSE error.

**Round-1 failure-modes criticizer #4 + #7:** if runLoop throws (TOOL_NOT_FOUND, network error, etc.), chunks may have already fired but no terminal `error` or `done` event reaches the bus → Studio is stuck in "streaming" state. So the error emit must fire on **two** code paths: the normal cancel path AND the catch-all exception path.

**Round-3 structural change — existing try/finally becomes try/catch/finally.** Today router.js has only a `try { for await... } finally { /* cleanup */ }` block at lines 459-479 (openai) and 584-611 (claude-cli) — there is NO catch. The patch ADDS a catch between the try and finally on both branches, before the existing finally runs:

```js
// BEFORE (router.js:459-479 / 584-611):
try {
  for await (const event of runLoop({ ... })) { ... }
} finally {
  // existing cleanup
}

// AFTER:
try {
  for await (const event of runLoop({ ... })) { ... }
} catch (loopErr) {
  // ROUND-1 fix — terminal error emit on exception
  if (!onEvent) {
    eventBus.emit('event', {
      type: 'session.stream', sessionId: sid, agentName,
      eventType: 'error',
      data: { code: loopErr.code || 'INTERNAL_ERROR', message: loopErr.message || String(loopErr) },
    });
  }
  throw loopErr;   // re-throw so existing callers (triggerChatTurn, dispatchAsync, wake) see the same error
} finally {
  // existing cleanup unchanged — catch-then-finally semantics: catch runs first, finally runs whether catch threw or not
}
```

The re-throw preserves the runChat caller contract (sync error propagation) while adding the additive bus-emit side-effect. The existing `finally` block (cancelSignal cleanup, recordSessionRunContext clear, etc.) runs after the catch's re-throw — JS guarantees finally runs whether the try, catch, or neither threw.

Two emits, one for each failure mode:

```js
// Path A — normal cancel exit (just before the chat.response emit):
if (cancelled && !onEvent) {
  const details = cancelReason ? { reason: cancelReason } : undefined;
  eventBus.emit('event', {
    type: 'session.stream', sessionId: sid, agentName,
    eventType: 'error',
    data: { code: 'CANCELLED', message: 'Session cancelled', ...(details && { details }) },
  });
}

// Path B — runLoop threw mid-iteration (in the existing catch block at the
// runChat level OR in a new try/catch wrapping the for-await loop):
try {
  for await (const event of runLoop({ ... })) { ... }
} catch (loopErr) {
  if (!onEvent) {
    eventBus.emit('event', {
      type: 'session.stream', sessionId: sid, agentName,
      eventType: 'error',
      data: { code: loopErr.code || 'INTERNAL_ERROR', message: loopErr.message || String(loopErr) },
    });
  }
  throw loopErr;   // preserve the throw — runChat callers depend on it for sync error handling
}
```

The throw is preserved so `triggerChatTurn` / `dispatchAsyncInformTurn` / wake.js see the same error shape they see today. The bus emit is purely additive — Studio gains a terminal event; sync callers see no behavior change.

### 3.5b Patch — wake.js terminal event on error

[core/wake.js:154-176](../core/wake.js) — wake's `runChat` call is in a try/catch that **swallows** the error (`console.error` + return). This is intentional for runtime stability, but after the fix, the chunks will have fanned out to Studio with no terminal event when the wake-driven runChat throws.

Add a terminal-error emit in wake's catch:

```js
try {
  await runChat({ ... });
} catch (err) {
  console.error(`[wake] failed for ${targetSessionId}:`, err && err.message ? err.message : err);
  // Terminal event so Studio doesn't see "chunks but no done"
  eventBus.emit('event', {
    type: 'session.stream', sessionId: targetSessionId, agentName: session.agent_name,
    eventType: 'error',
    data: { code: err.code || 'INTERNAL_ERROR', message: err.message || String(err) },
  });
} finally { ... }
```

### 3.6 Doc updates required (round-1 + round-2 criticizer findings)

The plan changes a documented contract in two docs. **Three** passages need updates (round-2 criticizer caught a missed one):

**`docs/api/09-websocket.md` — passage 1 (Trigger-path parity callout, lines ~5-9)** — currently says:
> **Trigger-path parity**: Message content and tool activity are emitted to WS regardless of how a session was triggered:
> - **SSE-triggered sessions** → `session.stream` events (mirrors the SSE stream exactly, including live token chunks)
> - **All other triggers** (HTTP JSON, `agent_message`, `agent_spawn`) → `chat.message` and `chat.inference_tool` events
>
> The only WS-exclusive gap vs SSE is `inference.chunk` (live streaming tokens), which is SSE-only by design.

Change to:
> **Trigger-path parity**: Message content, tool activity, and live streaming chunks are emitted to WS regardless of how a session was triggered:
> - **SSE-triggered sessions** → `session.stream` events (full set, including `inference.chunk`, `thinking.chunk`, `tool.chunk`, `done`, `error`)
> - **Tool-driven sessions** (sub-agent invocations via `agent_spawn` / `agent_message` / `async_inform` / wake) → the **same** `session.stream` envelope set, plus `chat.message` and `chat.inference_tool` for stable backward compatibility
> - **JSON-mode HTTP chats** → both shapes (the `session.stream` envelopes are emitted to the bus even though the HTTP response is plain JSON)
>
> The bus event shape is identical regardless of trigger; consumers don't need to differentiate.

**`docs/api/09-websocket.md` — passage 2 (the `Chat — Streaming (session.stream)` section header, line ~95)** — currently says:
> `session.stream` mirrors every event that the SSE endpoint (`POST /agents/:name/chat` with `sse: true`) sends to a directly connected SSE client. **Only emitted when the session was triggered via SSE.** For sessions triggered by other means, see `chat.message` and `chat.inference_tool` below.

Change to:
> `session.stream` mirrors every event that the SSE endpoint (`POST /agents/:name/chat` with `sse: true`) sends to a directly connected SSE client. **Emitted for SSE-triggered chat turns AND for tool-driven sub-agent turns** (`agent_spawn`, `agent_message`, `async_inform`, wake-driven drains). `chat.message` and `chat.inference_tool` continue to fire on top-level types for backward compatibility.

**`docs/api/03-chat.md`** — add a note under "SSE Streaming Mode":
> Even when `sse: false` (the default JSON-mode response), live `session.stream` events are still fanned out on the WebSocket bus (`/ws`) for any subscriber. JSON-mode callers that want zero WS broadcast for their turn cannot opt out at the HTTP layer — but in practice JSON-mode callers don't subscribe to the WS, so this is invisible to them.

### 3.7 Sites NOT changed

- `tools/agent_spawn.js`, `tools/agent_message.js`, `core/async-inform.js` — no edits. They benefit from approach B's centralization for free.
- `core/wake.js` — minor catch-emit added in §3.5b for terminal-event guarantee.
- `api/routes/chat.js` — no edits. Its callbacks win over the defaults, so SSE behavior is unchanged.
- `engines/claude-engine.js` — no edits. Current callbacks are already plumbed.
- `llm/client.js` — no edits. `needsStreaming` flips to `true` automatically once `onStreamChunk` is supplied (a default).

---

## 4. Behavior changes

### 4.1 Openai engine: tool-driven LLM calls become streaming

Today: `tools/agent_message.js` → `runChat` → `_doRunChatBody` → `runLoop` → `callLLM({ onChunk: undefined })` → `needsStreaming=false` → bulk POST → response.

After: `runChat` → `_doRunChatBody` → installs default `onStreamChunk` → `runLoop` → `callLLM({ onChunk: defaultEmitter })` → `needsStreaming=true` → SSE-streamed POST → chunks fanned to bus.

**Implications:**
- Same total token cost.
- Slightly different latency profile — chunks parsed incrementally.
- Different network error semantics (mid-stream truncation differs from bulk-call HTTP errors). Existing `callLLMStreaming` handles both paths, no new error code.
- `delta.reasoning` / `delta.reasoning_content` thinking now streams to the bus on every sub-agent call (was already wired client-side, just no consumer).

### 4.2 Claude-cli engine: chunks no longer dropped

Today: `runClaudeSession` already streams via SDK; `onStreamChunk`/`onThinkingChunk` are forwarded but `undefined` for tool-driven calls → callbacks no-op.

After: defaults emit to bus on every chunk.

### 4.3 Volume

For an active sub-agent emitting ~500 tokens per turn with no thinking and one bash call:
- ~1 `session.created`, 1 `chat.user_message`, ~10 `inference.chunk` (depending on chunk granularity), 1 `inference.tool`, ~50 `tool.chunk` (bash stdout), 1 `chat.message`, 1 `chat.response`, 1 `done` → **~65 events/turn** to bus.

10 parallel sub-agents → ~650 events/turn fan-out. WS server already passes through everything; same fan-out load as user-driven SSE chats. Acceptable.

---

## 5. Test plan

### 5.1 Smoke: openai engine sub-agent streaming

```js
// scenario-substream-openai.js
// Subscribe to /ws, fire agent_message with async_inform=false on assistant agent.
// Assert: ≥1 session.stream/inference.chunk events arrive.
```

### 5.2 Smoke: claude-cli engine sub-agent streaming

```js
// scenario-substream-claude.js
// Subscribe to /ws, agent_spawn cc-sonnet with budget_tokens:5000 thinking enabled.
// Assert: ≥1 session.stream/thinking.chunk events arrive AND ≥1 inference.chunk.
```

### 5.3 Regression: SSE-triggered user chat

```js
// Reuse scenario-thinking-stream.js (already passes 2/2).
// Confirm: SSE chunk count unchanged, no double-emission on /ws.
```

### 5.4 Async-inform + wake path

```js
// scenario-substream-asyncinform.js
// Spawn target idle. Fire agent_message async_inform=true. Subscribe to /ws.
// Assert: chunks arrive from the dispatcher's runChat, not just the final delivery.
```

### 5.5 Cancel mid-stream — no chunks emit AFTER cancel boundary

```js
// scenario-substream-cancel.js
//
// Methodology: rely on the cancel-gate's MONOTONIC property, not on
// timestamps. The gate is `if (cancelSignal?.aborted) return;` evaluated
// per-emit, and AbortSignal.aborted is monotonic (never flips back to
// false). So once the cancel HTTP call returns 200, every subsequent
// chunk-emit attempt MUST short-circuit. We assert by ordering, not by
// wall-clock comparison.
//
// Steps:
// 1. Subscribe to /ws. Track all incoming session.stream events into an
//    array IN ARRIVAL ORDER (do NOT capture timestamps — Node's
//    EventEmitter dispatch is synchronous, arrival order = emit order).
// 2. POST /agents/<sub>/chat (no SSE) with a long-running prompt + small
//    streaming model so we observe several inference.chunk events.
// 3. After ≥3 inference.chunk events have arrived, call
//    POST /sessions/:id/cancel and AWAIT its 200 response.
// 4. Continue collecting events for 2 more seconds.
// 5. Find the index of the FIRST session.stream/error{code:'CANCELLED'}.
//    Assert: NO session.stream/inference.chunk has an index ≥ that index.
//    (i.e. error must be the last streaming event for this session.)
//
// Why this is robust: AbortSignal.aborted goes true synchronously inside
// cancelRegistry.cancel() (called by the HTTP route handler). The HTTP
// 200 response only returns AFTER that synchronous call. So when the
// test's await POST /cancel resolves, the gate is already armed; any
// chunk that emits later MUST short-circuit. EventEmitter's synchronous
// dispatch guarantees ordering on the bus side too.
```

### 5.5b Exception path emits terminal error (round-1 failure-modes #4)

```js
// scenario-substream-exception.js
// Fire a sub-agent message that triggers a TOOL_NOT_FOUND or similar throw.
// Subscribe to /ws. Assert: session.stream/error { code: 'INTERNAL_ERROR' or specific }
// arrives even though the loop didn't reach the normal done-emit point.
```

### 5.5c wake.js exception path emits terminal error (round-1 failure-modes #7)

```js
// Force a wake-driven runChat to throw (e.g. corrupt session state).
// Subscribe to /ws. Assert: session.stream/error fires from wake's catch.
```

### 5.6 Done event ordering

```js
// Subscribe to /ws. Fire any sub-agent call. Assert event order:
//   session.created → chat.user_message → (chunks) → chat.message → chat.response → session.stream/done
// Verify done.data.iterations and tokenUsage match the chat.response payload.
```

### 5.7 No-double-done on SSE

```js
// Fire POST /agents/X/chat?sse=true. Subscribe to /ws.
// Assert: exactly one session.stream/done event on the WS (the one from chat.js's sendEvent),
//         not two (i.e., the !onEvent gate works).
```

---

## 6. Risk register

| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Double-emit `done` when SSE | Low if gate works | Studio shows turn end twice | Gate `!onEvent`; add 5.7 regression |
| sid is null when default closures fire (orphan emit) | Low — `sid` is resolved by the time defaults exist in scope | Bus events with `sessionId: null` | Ensure closures live AFTER sid resolution; verify in code review |
| openai non-streaming → streaming flip breaks an obscure provider | Very low — same path as user-driven chats today | Sub-agent call fails | All providers in `auth.json` already work in streaming mode (user chats use streaming); defer-to-non-streaming was an artifact of `needsStreaming=false`, not a deliberate choice |
| Mid-stream errors fan out differently | Low | Studio sees different error envelope | The error code remains the same; only delivery path changes |
| Volume overload on /ws under 10+ parallel sub-agents | Low | WS clients see lag | Existing user-driven SSE produces equivalent volume; no new bottleneck. With zero WS clients, EventEmitter dispatch is a ~1μs no-op and `JSON.stringify` only runs inside listener bodies — no extra cost. |
| `injectIntoActiveOpenaiSession` path skipped — chunks via injection don't get default callbacks | Low | Injected messages stream silently | `injectIntoActiveOpenaiSession` (router.js:329) reuses the ACTIVE session's already-running loop, which has its OWN callbacks installed by whoever started it (could be the SSE handler OR the default tool-driven path). No regression — chunks already follow that loop's callbacks. |
| Chunks fire after cancel (concurrency #4) | HIGH if not gated | Studio sees ghost chunks after CANCELLED error | `_streamGate()` checks `!cancelSignal?.aborted` on every emit (§3.2/3.3) |
| Exception in runLoop OR wake.js leaves Studio "streaming" with no terminal event (failure-modes #4/#7) | Medium | UI stuck rendering loading spinner | Catch-path emits `session.stream/error` (§3.5 path B + §3.5b) |
| `done` payload missing `session`/`agentName`/`model` breaks Studio consumers (backward-compat #3) | Medium | Studio's `data.session?.id` access fails | Match SSE handler shape exactly (§3.4) |
| Pre-existing: provider 5xx mid-stream silent partial completion (failure-modes #3) | Out of scope | Already a bug in `callLLMStreaming` | Not introduced by this plan; flagged for separate fix |
| Pre-existing: ws.send backpressure on stale clients (perf #3) | Out of scope | Per-client buffer growth | Not introduced by this plan; future improvement |

---

## 7. Rollback

Single-file change scope: `core/router.js` only. Revert is `git revert <commit>` — no schema changes, no API contract changes (additive bus events that pre-existing consumers were already prepared for via the `session.stream` envelope).

---

## 8. Open questions for criticizer agents

1. Are there other `runChat` invocation sites I missed beyond the 6 enumerated?
2. Does the openai engine's `injectIntoActiveOpenaiSession` path correctly inherit streaming defaults from the loop that's already running?
3. Are there any race conditions between `onSessionStart` firing (sets sid for SSE) and the closure binding to `sid` (uses local var resolved before the loop starts)?
4. Will the `error/CANCELLED` emit on tool-driven cancel cause Studio to render an error toast it shouldn't have rendered before (since tool-driven cancels were silent)?
5. Volume: what's the actual chunk granularity per provider (DeepSeek vs OpenRouter vs Claude)? Could a chatty provider produce thousands of events on a single call?
6. The `chat.response` and `session.stream/done` events carry overlapping data. Is that acceptable parallel emission, or should we deprecate one?
