Files
CherryHQ-cherry-studio/docs/references/ai/stream-manager.md
fullex d9475b2562 refactor(stream-manager): make root barrel the public surface, drop internal sub-barrels
Rewrite streamManager/index.ts to export the full consumed surface (all
listeners, startAgentSessionRun, PersistenceBackend contract, TranslationBackend,
stream types); route all 15 external deep imports through it. Remove the
internal-only context/ and lifecycle/ sub-barrels so internal siblings import
directly. Expose AiStreamRequest/CallOverrides on the ai/types barrel and route
ChannelAdapter through the channels barrel.

Two deep imports remain, blocked by nested sub-barrels in sibling dirs
(runtime/claudeCode, channels/security) — deferred to those dirs' cleanup.
2026-07-05 10:59:09 -07:00

867 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AiStreamManager
## What it is
`AiStreamManager` is the Main-process **active-stream registry** and the
broker for every stream event. It owns the full life cycle of an AI
streaming reply — from `sendMessages` until the assistant turn finishes
persisting — including multicast fan-out, reconnect, abort, steering
(queue + yield + continuation), and persistence triggering.
The renderer no longer holds a direct reference to the stream. Closing a
window does not abort the stream; it continues on Main and persists
normally. When the user returns, `attach` re-subscribes and the
manager replays any chunks that landed in between.
**Key: `topicId`.** A topic has at most one active stream at a time;
"streaming" is one phase of a topic, and every subscriber on a topic is
equal — there is no "owner" window.
## Why it exists
v1 ran the stream lifecycle, fan-out, and persistence on the **renderer**,
which produced three structural bug classes:
- **Window-bound lifecycle** — unmounting the chat (topic switch, window
close, route change) cancelled the transport stream, which aborted the
upstream request and dropped the in-flight reply.
- **No reconnect** — `reconnectToStream()` always returned `null`, so
returning to a topic lost live progress until the row hit the DB.
- **Renderer-owned persistence** — the DB write lived in the renderer, so a
crash/close between stream-end and commit lost the reply.
**Goal:** move stream lifecycle, multicast fan-out, and persistence to Main;
the renderer's only job is rendering chunks. The sections below are the
reference for that Main-side design.
## Architecture
```
┌──────────────── Renderer ────────────────────────────────────┐
│ │
│ useChat({ id: topicId, transport: IpcChatTransport }) │
│ ├─ sendMessages → Ai_Stream_Open (topicId, trigger, userMessageParts, …)
│ ├─ reconnectToStream → Ai_Stream_Attach ({ topicId }) │
│ └─ abort signal → Ai_Stream_Abort ({ topicId }) │
│ │
│ History: useQuery('/topics/:id/messages') │
│ Topic-level state: useTopicStreamStatus → shared cache │
└──────────────────────────────────────────────────────────────┘
↕ IPC (all keyed by topicId)
┌──────────────── Main ────────────────────────────────────────┐
│ │
│ dispatchStreamRequest(manager, subscriber, req) │
│ │ pick first ChatContextProvider whose canHandle matches │
│ │ provider.prepareDispatch(subscriber, req, ctx) │
│ └ manager.send(prepared) │
│ │
│ AiStreamManager │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ activeStreams: Map<topicId, ActiveStream> │ │
│ │ listeners: Map<listenerId, StreamListener> │ │
│ │ executions: Map<modelId, StreamExecution> │ │
│ │ ├─ abortController / status │ │
│ │ └─ buffer (ring) + droppedChunks │ │
│ │ lifecycle: StreamLifecycle (chat or prompt) │ │
│ └────────────────────────────────────────────────────────┘ │
│ ↓ createAndLaunchExecution → runExecutionLoop │
│ AiService.streamText(request) → ReadableStream<UIMessageChunk> │
│ ↓ pipeStreamLoop (tees: broadcast + readUIMessageStream) │
│ │
│ terminal → dispatchToListeners → every StreamListener: │
│ WebContentsListener → wc.send(Ai_StreamDone) │
│ PersistenceListener → PersistenceBackend.persistAssistant
│ • MessageServiceBackend (SQLite tree) │
│ • TemporaryChatBackend (in-memory) │
│ • AgentSessionMessageBackend (agent-session DB) │
│ • TranslationBackend (translate row) │
│ TraceFlushListener → TraceStorageService.saveSpans(topicId)
│ ChannelAdapterListener → adapter.onStreamComplete │
│ SseListener → res.write('[DONE]') │
└──────────────────────────────────────────────────────────────┘
```
## Pub/sub model
The manager is a broker: one set of producers feeds it, one set of
consumers subscribes. The system uses the observer pattern, and splits
dispatch into two semantically distinct channels based on **payload
volume × audience width**.
### Producers
| Producer | Events | Source |
|---|---|---|
| `StreamExecution` loop | `UIMessageChunk` (per-chunk delta) | `AiService.streamText`'s `ReadableStream` |
| `AiStreamManager` (state machine) | topic-level status transitions | `send()``pending`, first chunk → `streaming`, three terminal handlers → `done` / `error` / `aborted`, `awaiting-approval` on `tool-approval-request` |
### Consumers
| Consumer | Events | Subscription |
|---|---|---|
| `WebContentsListener` | chunk + terminal | explicit `attach``ActiveStream.listeners` |
| `PersistenceListener` | terminal | built by the provider and added in `send()` |
| `TraceFlushListener` | terminal | built by chat / agent-session turn owners and added in `send()` |
| `ChannelAdapterListener` / `SseListener` | chunk + terminal | caller injects into `send()`'s `listeners` |
| UI indirect consumers (sidebar indicators, …) | topic status | `useSharedCache('topic.stream.statuses.${topicId}')` |
### Two channels: targeted listener dispatch vs SharedCache mirror
| | Targeted listener dispatch | SharedCache mirror |
|---|---|---|
| Transport | `Ai_StreamChunk` / `Ai_StreamDone` / `Ai_StreamError` | `cacheService.setShared('topic.stream.statuses.${topicId}', …)` → built-in `Cache_Sync` broadcast |
| Main-side registry | `ActiveStream.listeners: Map<listenerId, StreamListener>` | none — uses the generic `CacheService` infra |
| Subscriber API | `attach` to register, explicit `detach` | `useSharedCache('topic.stream.statuses.${topicId}')` by topicId |
| Per-event size | tens of bytes to KBs (10s/s) | tens of bytes (≤ 5 transitions per stream) |
| Audience | narrow (one window per listener typically) | wide (every sidebar / indicator across all windows) |
| Cost of irrelevant pushes | high (bandwidth + deserialization) | negligible |
### Channel selection rule
Choose by **consumer / producer fanout**:
- chunk stream: one execution produces it, only the window rendering
that topic needs it → **targeted listener dispatch**, no irrelevant
pushes.
- topic status: one transition, every UI mirror wants it → **SharedCache**,
reuse generic cache sync, no bespoke IPC.
### Rules that follow from the channel split
- **`Ai_Stream_Attach` is required.** The listener channel requires
explicit consumer registration; `attach` is the entry point and also
returns a compact replay to fill the "before I subscribed" gap.
- **Bootstrap needs no extra IPC.** A new window pulls all shared cache
entries via `Cache_GetAllShared` on mount; every
`topic.stream.statuses.${topicId}` entry comes through without a
bespoke snapshot IPC.
- **Snapshot vs delta race.** Handled by the shared cache sync layer
itself — initial pull and `Cache_Sync` delta share the Main-side
source of truth; late arrivals overwrite stale state.
- **Grace-period cleanup does NOT clear the SharedCache entry.** Terminal
values (`done` / `aborted` / `error`) stay so renderer-side consumers
(`useTopicDbRefreshOnTerminal`, `useChatWithHistory`, awaiting-approval
indicators, sidebar badges) can observe them. The fulfilled-badge gate
is a read-receipt: the entry's `lastCompletedAt` (bumped only on
`done`) compared against `topic.stream.last_seen_completion.${topicId}`
(cross-window shared cache, written when the user acknowledges).
Memory tier — both reset on app restart.
- **`PersistenceListener` placement.** Terminal-only consumer — doesn't
need chunk bandwidth → not added via `attach`; the provider includes
it in the `listeners` array passed to `send()`.
- **`TraceFlushListener` placement.** Terminal-only consumer that flushes
`TraceStorageService.saveSpans(topicId)` after a chat / agent turn completes.
It belongs with the turn owner (`PersistentChatContextProvider` or
`AgentSessionRuntimeService`), not inside `AiStreamManager` and not in
trace viewer UI.
## File layout
```
src/main/ai/
├── AiService.ts lifecycle service: streamText + non-streaming IPC gateway
└── runtime/aiSdk/
└── Agent.ts single-pass `Agent.stream` wrapper (see Agent Loop)
src/main/ai/streamManager/
├── AiStreamManager.ts the registry + execution loop + multicast
├── pipeStreamLoop.ts shared chunk-pipe primitive (used by AiStreamManager.runExecutionLoop)
├── buildCompactReplay.ts attach-time chunk compaction (merge text-delta / reasoning-delta)
├── types.ts ActiveStream / StreamExecution / StreamListener / timings
├── index.ts barrel
├── context/ per-topicId namespace dispatch
│ ├── ChatContextProvider.ts interface + PreparedDispatch
│ ├── dispatch.ts single manager.send entry; MainContinueConversationRequest
│ ├── PersistentChatContextProvider.ts uuid topics → SQLite tree
│ ├── TemporaryChatContextProvider.ts in-memory (TemporaryChatService)
│ ├── AgentChatContextProvider.ts `agent-session:` → agents DB
│ └── modelResolution.ts resolveModels / siblingsGroupId
├── lifecycle/ strategy: chat vs ad-hoc prompt
│ ├── StreamLifecycle.ts interface
│ ├── ChatStreamLifecycle.ts cross-window broadcast + 30 s grace period + attach
│ └── PromptStreamLifecycle.ts silent, no attach, immediate eviction
├── listeners/
│ ├── WebContentsListener.ts chunks → renderer windows
│ ├── PersistenceListener.ts observer protocol + delegates to PersistenceBackend
│ ├── TraceFlushListener.ts terminal trace-cache flush to local history
│ ├── ChannelAdapterListener.ts text → Discord / Slack / Feishu
│ └── SseListener.ts UIMessageChunk → SSE response (API server)
└── persistence/
├── PersistenceBackend.ts strategy interface + statsFromTerminal projection
└── backends/
├── MessageServiceBackend.ts finalize a SQLite pending placeholder
├── TemporaryChatBackend.ts append to in-memory topic
└── TranslationBackend.ts attach `data-translation` part to a target message
```
Agent session persistence is implemented under `agentSession/persistence`
because it writes the agent-session domain tables.
## StreamListener interface
The manager treats every consumer through one interface; it dispatches
each event by calling these methods uniformly:
```typescript
interface StreamListener {
readonly id: string
onChunk(chunk: UIMessageChunk, sourceModelId?: UniqueModelId, anchorMessageId?: string): void
onDone(result: StreamDoneResult): void | Promise<void> // { finalMessage?, status: 'success', ... }
onPaused(result: StreamPausedResult): void | Promise<void> // { finalMessage?, status: 'paused', ... }
onError(result: StreamErrorResult): void | Promise<void> // { finalMessage?, error, status: 'error', ... }
isAlive(): boolean
}
```
All three terminal shapes share the same `finalMessage?` field — the
`UIMessage` accumulated by `readUIMessageStream` in the execution loop.
Whether the stream ended naturally, was aborted, or errored, it's the
same variable, only the stop point differs. Earlier designs called the
error-path partial a `partialMessage`; this turned out to be just a
`finalMessage` that ended early. Unifying the shape means
`PersistenceBackend` needs one `persistAssistant` method, not separate
write paths per status.
Renderer-facing listeners also receive `anchorMessageId`, the assistant
row the execution writes to. This is part of the stream branch identity:
`sourceModelId` distinguishes parallel model executions, while
`anchorMessageId` distinguishes same-model chained turns such as steer
continuations.
### Built-in implementations
| Listener | Role | id | isAlive |
|---|---|---|---|
| **WebContentsListener** | chunks → renderer window | `wc:${wc.id}:${topicId}` | `!wc.isDestroyed()` |
| **PersistenceListener** | terminal write via strategy | `persistence:${backendKind}:${topicId}:${modelId ?? 'default'}` | always `true` |
| **TraceFlushListener** | terminal trace-cache flush | `persistence:trace:${topicId}` | always `true` |
| **ChannelAdapterListener** | text → IM platform | `channel:${channelId}:${chatId}` | `adapter.connected` |
| **SseListener** | API-server SSE passthrough | `sse:${uuid}` | `!res.writableEnded` |
### Unified liveness policy
`AiStreamManager.dispatchToListeners` is the single funnel for terminal
events (`onDone` / `onPaused` / `onError`). Per listener it:
- Calls `listener.isAlive()` before each broadcast — `false` removes the
listener from `stream.listeners` (cleans up dead consumers).
- Wraps each call in try/catch — one bad listener can't starve the rest.
- Logs by event name + listener id for easy triage.
`onChunk` keeps a synchronous contract (the execution loop can't `await`
a listener) so it inlines the loop instead of going through
`dispatchToListeners`, but the dead-listener cleanup is the same.
### PersistenceListener — strategy pattern
One listener + four backends:
```typescript
interface PersistenceBackend {
readonly kind: string // "sqlite" | "temp" | "agents-db" | "translation"
persistAssistant(input: {
finalMessage?: CherryUIMessage
status: 'success' | 'paused' | 'error'
modelId?: UniqueModelId
stats?: MessageStats
}): Promise<void>
afterPersist?(finalMessage: CherryUIMessage): Promise<void>
}
```
Backends expose **one** write method; the three statuses share its
shape. On the `error` branch, `PersistenceListener` folds the
`SerializedError` into a trailing `data-error` part on `finalMessage.parts`
and then calls `persistAssistant({ status: 'error' })`, so backends never
have to know how to encode an error into a UIMessage — they just write.
The listener owns the observer protocol: filter by `modelId`
(multi-model topics have one listener per execution), merge the error
part exactly once, swallow exceptions so they don't break downstream
dispatch, fire `afterPersist` only when `status === 'success'` and
`finalMessage` is present (best-effort). Adding a fifth storage path
(e.g. an outbox) is a 60-line backend, no listener boilerplate to copy.
## ActiveStream & StreamExecution
```typescript
interface ActiveStream {
topicId: string
executions: Map<UniqueModelId, StreamExecution> // 1 entry single-model, N multi-model
listeners: Map<string, StreamListener> // shared across executions
// 'pending' on creation; flips to 'streaming' on first chunk; derived
// from executions on terminal (done / aborted / error /
// awaiting-approval).
status: TopicStreamStatus
isMultiModel: boolean // fixed at create; tags onChunk's sourceModelId
lifecycle: StreamLifecycle // chat or prompt strategy
expiresAt?: number
cleanupTimer?: ReturnType<typeof setTimeout>
}
interface StreamExecution {
modelId: UniqueModelId
anchorMessageId?: string // placeholder id for submit/regen, anchor id for continue
abortController: AbortController
status: 'streaming' | 'done' | 'error' | 'aborted'
// Per-execution ring buffer for reconnect replay. Hitting
// `maxBufferChunks` drops the oldest entry and bumps `droppedChunks`.
// Independent buffers prevent a chatty model from evicting a slower
// model's replay (a shared buffer would).
buffer: StreamChunkPayload[]
droppedChunks: number
finalMessage?: CherryUIMessage
// Set the moment a `tool-approval-request` chunk arrives, cleared on
// response. Read by `resolveTerminalStatus` to surface
// `awaiting-approval` on the topic.
awaitingApproval?: boolean
error?: SerializedError
siblingsGroupId?: number
loopPromise: Promise<void> // awaited by onStop for graceful shutdown
// Transport-side timings owned by the execution loop — chunk-shape-agnostic.
// Semantic timings (firstTextAt / reasoning*) live on the listener
// that cares; see "Stats composition" below.
timings: TransportTimings
// OTel root span set as active context around runExecutionLoop so
// AI SDK spans become children. Created by the context provider.
rootSpan?: Span
}
interface TransportTimings {
readonly startedAt: number // execution loop entry
completedAt?: number // execution loop exit (both try and catch paths)
}
interface SemanticTimings {
firstTextAt?: number // first text-delta chunk (TTFT endpoint)
reasoningStartedAt?: number // first reasoning-* chunk
reasoningEndedAt?: number // first non-reasoning chunk after reasoning
}
```
Topic-level status is derived from executions, with `'pending'` as the
initial pre-first-chunk window:
- Created (`send()` returned) → `'pending'`
- Any execution emits its first chunk → `'streaming'`
- All terminal, all `done``'done'`
- All terminal, all `aborted``'aborted'`
- Has `error`, none `streaming``'error'`
- Any execution still has `awaitingApproval` true on a terminal topic → `'awaiting-approval'`
`pending → streaming` is a one-time transition (first chunk anywhere).
The terminal status is derived once when the last execution terminates.
### Stats composition — tokens + timings → MessageStats
**Ownership** (key invariant: manager does not peek at chunk payloads):
| Source field | Owner | Collected at |
|---|---|---|
| `TransportTimings.startedAt` | `AiStreamManager` | `createAndLaunchExecution` |
| `TransportTimings.completedAt` | `AiStreamManager` | `pipeStreamLoop`'s `broadcastCompletedAt` |
| `SemanticTimings.firstTextAt` | `PersistenceListener` | own `onChunk`, first `text-delta` |
| `SemanticTimings.reasoning*` | `PersistenceListener` | own `onChunk`, observing `reasoning-*` boundaries |
| Token metadata | `agentLoop` usage observer | `finish` chunk projects AI SDK `LanguageModelUsage``CherryUIMessageMetadata` |
The manager is chunk-shape-agnostic — multicast, reconnect, abort,
steer queue/continuation, persistence-triggering, never "what is text /
what is reasoning". AI SDK chunk type changes (vNext renames) only touch
`PersistenceListener`; the manager stays stable.
**Final projection.** `statsFromTerminal(finalMessage, mergedTimings)`
is one function; the listener merges its `SemanticTimings` with
`result.timings` (transport) before calling it:
```typescript
// inside PersistenceListener
const mergedTimings = { ...result.timings, ...this.semanticTimings }
const stats = statsFromTerminal(finalMessage, mergedTimings)
await this.opts.backend.persistAssistant({ finalMessage, status, modelId, stats })
```
Projected `MessageStats` fields:
| Field | Source |
|---|---|
| `totalTokens / promptTokens / completionTokens / thoughtsTokens` | `finalMessage.metadata.*` |
| `timeFirstTokenMs` | `round(firstTextAt - startedAt)` |
| `timeCompletionMs` | `round(completedAt - startedAt)` |
| `timeThinkingMs` | **not projected** — wall-clock `reasoningEndedAt - reasoningStartedAt` can include interleaved tool exec; see the `TODO(message-stats-redesign)` note in `PersistenceBackend.ts` |
Backends never derive stats themselves; they just write `input.stats`.
One projection path, four backends, no duplication.
## Public API
```typescript
class AiStreamManager {
// Lifecycle container invokes with no args (DEFAULT_CONFIG); tests can
// override `gracePeriodMs`, `backgroundMode`, `maxBufferChunks`.
constructor(config?: Partial<AiStreamManagerConfig>)
readonly chatLifecycle: StreamLifecycle
// ── Single dispatch entry ─────────────────────────────────────────
// Live topic → inject (upsert listeners onto the running stream, models
// ignored — reached by chat steers and agent-session follow-ups whose user
// row was already persisted/enqueued by their provider). Otherwise → start
// (evict any grace-period stream, launch one execution per `models` entry).
// Multi-model is detected from `models.length > 1`.
send(input: SendInput): SendResult
// ── Ad-hoc prompt stream (translate / topic-naming / model probes)
// Bypasses the chat dispatcher; uses promptStreamLifecycle (silent, no
// attach, immediate eviction).
streamPrompt(input: {
streamId: string // doubles as topicId
uniqueModelId: UniqueModelId
prompt?: string
messages?: CherryUIMessage[]
listener: StreamListener | StreamListener[]
}): SendResult
// ── Subscription management ───────────────────────────────────────
attach(sender: WebContents, req: { topicId }): AiStreamAttachResponse
detach(sender: WebContents, req: { topicId }): void
addListener(topicId: string, listener: StreamListener): boolean
removeListener(topicId: string, listenerId: string): void
// ── Control ───────────────────────────────────────────────────────
abort(topicId: string, reason: string): void
hasLiveStream(topicId: string): boolean
// Queue a steer user row persisted while a turn was live; the running turn
// yields and `onExecutionDone` chains a `steer-continuation` to answer it.
enqueuePendingSteer(topicId: string, userMessageId: string): void
hasPendingSteer(topicId: string): boolean
// ── Execution-loop callbacks (driven internally; public for tests) ─
onChunk(topicId, modelId, chunk): void
onExecutionDone(topicId, modelId): Promise<void>
onExecutionPaused(topicId, modelId): Promise<void>
onExecutionError(topicId, modelId, error): Promise<void>
// ── Inspection (read-only snapshot) ───────────────────────────────
inspect(topicId: string): TopicSnapshot | undefined
}
```
### `send` contract
```typescript
interface SendInput {
topicId: string
models: ReadonlyArray<{ modelId: UniqueModelId; request: AiStreamRequest; rootSpan?: Span }>
listeners: StreamListener[]
siblingsGroupId?: number
lifecycle?: StreamLifecycle // omit → chatLifecycle; streamPrompt passes promptStreamLifecycle
}
interface SendResult {
mode: 'started' | 'injected'
executionIds: UniqueModelId[] // started → fresh ids; injected → already running
}
```
- **injected**: topic has a live stream (`pending` or `streaming`) →
`models` is ignored and `listeners` upsert by id; **no models are
launched**. Reached by (a) a chat steer — the provider already persisted the
steer user row and `dispatch` enqueued it on `pendingSteers`; and (b) an
agent-session follow-up already enqueued on the session's `pendingTurns`. An
empty-`models` send with no live stream is likewise a no-op (the row is
already enqueued) — `send()` never throws on empty models.
- **started**: topic is idle or grace-period (terminal) → any leftover
grace-period stream is evicted, a new `ActiveStream` is created with
`isMultiModel = models.length > 1`, one execution launched per model.
`isMultiModel` is not an input — it's derived from `models.length`.
### Execution loop — `runExecutionLoop` + `pipeStreamLoop`
Each execution runs an independent loop that bridges "the single
`ReadableStream` from AI SDK" to "what the manager has to do":
broadcast to listeners, buffer for reconnect, and accumulate a
persistable `finalMessage`.
**Step 1 — get the raw chunk stream.**
```typescript
const stream: ReadableStream<UIMessageChunk> = await aiService.streamText({
...request,
requestOptions: { ...request.requestOptions, signal }
})
```
`streamText` returns AI SDK's raw chunk stream. `signal` comes from
`StreamExecution.abortController`; `abort()` triggers it.
**Step 2 — wrap with `withIdleTimeout`.** Resets per chunk; on idle
timeout it aborts `exec.abortController`, which the upstream request is
already wired to.
**Step 3 — `pipeStreamLoop` tees the chunk stream.**
`pipeStreamLoop` is the shared chunk-pipe primitive (the one
`AiStreamManager.runExecutionLoop` uses). It `tee()`s the stream into two
independent branches:
| Branch | Consumer | Purpose |
|---|---|---|
| Broadcast | `onChunk(topicId, modelId, chunk)` per chunk | Buffer into `exec.buffer` (ring), fan out to every listener |
| Accumulator | `readUIMessageStream` | Each yielded snapshot is written to `exec.finalMessage`; at stream end it's the final message |
The accumulator reader is **not** cancelled directly on abort —
`Agent.stream` honours the same signal upstream and propagates `done`
through `tee()`, so the accumulator drains naturally. Cancelling the
accumulator reader directly would race AI SDK's internal
`controller.close()` and produce an `ERR_INVALID_STATE`
unhandledRejection.
**Step 4 — terminal dispatch.**
| Exit path | Handler | Behaviour |
|---|---|---|
| Normal end | `onExecutionDone` | `exec.status = 'done'`, finalMessage persisted as `success` |
| `signal.aborted` + `exec.status === 'aborted'` | `onExecutionPaused` | (Possibly partial) finalMessage persisted as `paused` |
| `streamErrorText` (in-stream `error` chunk) | `onExecutionError` | Error part folded into finalMessage, persisted as `error` |
| Pre-stream or broadcast throw | `onExecutionError` | Same — error part folded, persisted |
## Lifecycle strategy — chat vs prompt
The manager stays policy-free. Behaviour that differs between chat
streams and one-shot ad-hoc prompts (translate, topic-naming, model
probes) lives in `StreamLifecycle`:
```typescript
interface StreamLifecycle {
readonly name: string
onCreated(stream): void // freshly registered
onPromotedToStreaming(stream): void // first chunk
onTerminal(stream): void // every isTopicDone
canAttach(stream): boolean // gate for `attach`
cleanup(stream, evict: () => void): void // when to remove from activeStreams
}
```
| | `ChatStreamLifecycle` | `PromptStreamLifecycle` |
|---|---|---|
| Status broadcast | writes `topic.stream.statuses.<topicId>` on `pending → streaming → terminal` (with `awaitingApprovalAnchors` derived from `exec.awaitingApproval`) | none |
| `canAttach` | `true` | `false` |
| `cleanup` | sets a `setTimeout(evict, gracePeriodMs)`; chat reconnects within 30 s | calls `evict()` immediately |
`send()` defaults to `chatLifecycle`; `streamPrompt()` passes
`promptStreamLifecycle`.
## Multi-model
User mentions multiple models for one turn:
```
User: "Explain quantum mechanics" @gpt-4o @claude-sonnet
PersistentChatContextProvider.prepareDispatch
├─ persist user message (tree node)
├─ resolveModels → [gpt-4o, claude-sonnet]
├─ siblingsGroupId = (monotonic counter)
├─ create one pending assistant placeholder per model (SQLite)
├─ build listeners: subscriber + 2 PersistenceListener (one per backend)
├─ build models: 2 × { modelId, request, rootSpan }
└─ return PreparedDispatch
dispatchStreamRequest → manager.send({ models, listeners, siblingsGroupId })
├─ create ActiveStream (isMultiModel = true, 2 executions)
├─ launch one execution loop per model, each with its own
│ ring buffer
└─ return { mode: 'started', executionIds: [gpt-4o, claude-sonnet] }
```
## Steering
Steering a chat turn is **enqueue + yield + chain**, not abort-and-restart and
not mid-turn injection. When a new `Ai_Stream_Open` arrives for a chat topic that
is still streaming:
1. `PersistentChatContextProvider` (its `hasLiveStream` branch) persists the
steer message as a normal user row and returns an enqueue-only
`PreparedDispatch` — no models, `pendingSteerUserMessageId` set.
2. `dispatchStreamRequest` calls `manager.enqueuePendingSteer(topicId, id)`,
pushing the row onto the topic's `pendingSteers` FIFO, then `send()` — which,
seeing the live stream, just upserts the subscriber (inject).
3. The running turn's `steerYield` stop condition (OR'd into `stopWhen`) sees
`hasPendingSteer` and stops the turn cleanly at the next step boundary
(persisted as **`success`**, not `paused`).
4. `onExecutionDone` sees the queued steer and, instead of finalizing the topic,
chains a `steer-continuation` dispatch (`startNextChatTurn`) that answers the
head of the queue, carrying the prior turn's renderer listeners forward. The
FIFO drains one continuation per completed turn.
**Drop-on-abort:** a steer chains only after a clean `done`. If the turn is
aborted (Stop) or errors, the queue is dropped and its persisted user rows stay
in history as dangling messages the user can resend (`onExecutionPaused` /
`onExecutionError` clear `pendingSteers`; a late steer landing after a non-clean
terminal is dropped by `enqueuePendingSteer`). A steer queued while a turn ends
`awaiting-approval` does **not** chain until the approval's `continue-conversation`
turn completes — chaining earlier would let the approval response be swallowed by
the inject branch. If the continuation itself fails to launch, the topic is driven
to a terminal `error` rather than sticking at `streaming`.
Agent-session topics use a parallel, queue-based mechanism — never an interrupt.
A live follow-up is steered into the running turn via `connection.redirect()`
(no abort); if there is no live turn, or the steer is never injected, it is
enqueued on the session's `pendingTurns` for the next turn. `send()` only upserts
the new subscriber. See
[Agent Session Runtime → Live follow-up](./agent-session-runtime.md#live-follow-up).
## End-to-end flows
One row per flow. The two with dedicated docs are cross-linked rather than
duplicated; the rest are stream-manager-specific.
| Flow | Trigger | Mechanism | Terminal / result |
|---|---|---|---|
| Submit (standard) | `Ai_Stream_Open` | `dispatchStreamRequest``prepareDispatch` (persist user msg, reserve placeholders, build listeners + models) → `manager.send` → N × `runExecutionLoop` | `Ai_StreamDone`; `PersistenceListener.persistAssistant`; chat lifecycle `scheduleCleanup(30 s)` |
| Steering — chat resubmit | `Ai_Stream_Open` on a live chat topic | provider persists the steer user row + `enqueuePendingSteer``pendingSteers`; `steerYield` stops the running turn cleanly; `onExecutionDone` chains a `steer-continuation` | prior turn persisted as **`success`**; the continuation answers the steer — see [Steering](#steering) |
| Agent-session follow-up | `Ai_Stream_Open` on a live `agent-session:*` topic | provider persists the user row, `enqueueUserMessage` steers via `connection.redirect()` (no abort) or queues on `pendingTurns`; `manager.send` upserts the subscriber → `{ mode: 'injected' }` | steer folds into the current turn (rolled at a `steer-boundary`), else the next turn starts from `pendingTurns` — see [Agent Session Runtime](./agent-session-runtime.md#live-follow-up) |
| Tool-approval pause+resume | approval-request chunk → `awaiting-approval` | decision via `Ai_ToolApproval_Respond`; Claude-Agent unblocks `canUseTool`, MCP dispatches `continue-conversation` | card clears when the resumed stream broadcasts `pending` — see [Tool Approval](./tool-approval.md) |
| Reconnect | `Ai_Stream_Attach` on mount | `manager.attach`: `not-found` / streaming (register listener + compact replay) / done-paused (`finalMessage(s)`) / error | live chunks resume, or the final row is returned |
| Abort — user stop | `Ai_Stream_Abort` | per exec: `abortController.abort` → loop `signal` aborts → broadcast reader `cancel` → read loop `done` | partial persisted as **`paused`**; topic status → `aborted` (or `awaiting-approval` if an exec had it set) |
| Abort — no subscribers | last `WebContentsListener` dies + `backgroundMode === 'abort'` | `onChunk` prunes dead listeners; `listeners.size === 0` → auto `abort(topicId, 'no-subscribers')` | partial persisted as **`paused`** — never silently `success` or leaked |
| Multi-window | window B opens a live topic | B sends `Ai_Stream_Attach` → compact replay + its own `WebContentsListener`; each chunk fans out to A and B | both windows render the same chunks in sync |
| Channel / Agent | `AiStreamManager.send` in-process (no IPC) | scenario differs only by listener composition (table below) | per-listener effect |
**Topic status needs no `attach`.** Observers that only care "is this topic
live?" (sidebar loading indicators, topic-list status dots) don't register a
`WebContentsListener`. Every status transition writes the SharedCache key
`topic.stream.statuses.${topicId}`; observers read it via `useSharedCache`
directly. `Ai_Stream_Attach` is only needed when a window wants live chunks.
### Channel / Agent listener composition
Channel adapters and the agent scheduler call `AiStreamManager.send`
directly inside Main — no IPC. The scenario differences are entirely in the
listener composition:
| Scenario | Listeners | Effect |
|---|---|---|
| Renderer user message | `WebContentsListener` + `PersistenceListener` | live UI + persist |
| Channel bot reply | `ChannelAdapterListener` + agent-session persistence listener | IM send + agents DB |
| Channel + user both watching | above + `WebContentsListener(B)` | parallel fan-out |
| API server SSE | `SseListener` + `PersistenceListener` | SSE push + persist |
| Translate | `WebContentsListener` + `PersistenceListener(TranslationBackend)` | live overlay + writes `data-translation` part on success |
## IPC contract
### Request channels (Renderer → Main)
| Channel | Payload | Response | Semantics |
|---|---|---|---|
| `Ai_Stream_Open` | `AiStreamOpenRequest` (`submit-message` \| `regenerate-message`) | `{ mode, executionIds?, userMessageId?, placeholderIds? }` | Open / inject; provider routes by topicId |
| `Ai_Stream_Attach` | `{ topicId }` | `AiStreamAttachResponse` | Subscribe; returns compact replay when streaming |
| `Ai_Stream_Detach` | `{ topicId }` | void | Unsubscribe (stream continues) |
| `Ai_Stream_Abort` | `{ topicId }` | void | Stop current generation |
> Topic status snapshots need no dedicated IPC: a new window pulls every
> `topic.stream.statuses.${topicId}` entry via `Cache_GetAllShared` on
> mount, and `useSharedCache` subscribes by topicId.
### Push channels (Main → Renderer)
| Channel | Payload | Notes |
|---|---|---|
| `Ai_StreamChunk` | `{ topicId, executionId?, chunk }` | Multi-model carries `executionId`; **only sent to attached windows** |
| `Ai_StreamDone` | `{ topicId, executionId?, status, isTopicDone }` | `status ∈ { 'success', 'paused' }` — natural completion vs user abort; **only sent to attached windows** |
| `Ai_StreamError` | `{ topicId, executionId?, isTopicDone, error }` | `SerializedError`; **only sent to attached windows** |
Topic-level status transitions are NOT a bespoke IPC — they live in the
SharedCache key `topic.stream.statuses.${topicId}` (Main `setShared`
built-in `Cache_Sync` broadcast). The entry shape is
`TopicStatusSnapshotEntry`:
```typescript
{
status: 'pending' | 'streaming' | 'done' | 'aborted' | 'awaiting-approval' | 'error'
activeExecutions: ActiveExecution[] // execs currently `streaming`
awaitingApprovalAnchors: ActiveExecution[] // execs with awaitingApproval = true
lastCompletedAt?: number // bumped only on `done`; the fulfilled-badge read-receipt gate
}
```
`pending` doubles as the "new stream just created" signal — the old
`Ai_StreamStarted` IPC is gone. Grace-period cleanup does NOT clear the
entry — terminal values (`done` / `aborted` / `error`) stay so renderer
consumers (DB-refresh trigger, awaiting-approval indicators, sidebar
badges) can observe them. The badge "should I show this?" gate is a
read-receipt: `entry.lastCompletedAt` (authoritative, bumped only on
`done`) compared against `topic.stream.last_seen_completion.${topicId}`
(cross-window shared cache, written by the renderer when the user
acknowledges).
**All traffic is keyed by topicId**; multi-model uses `executionId` to
demux chunks per model.
**Topic status vs message status.** Don't conflate:
- **Topic stream status** (SharedCache `topic.stream.statuses.${topicId}`):
one entry per topic, source of truth is `ActiveStream.status`, valid
only while the `ActiveStream` exists (+ grace period).
- **Assistant message status** (`AssistantMessageStatus`: `PENDING` /
`PROCESSING` / `SUCCESS` / `ERROR`): one per assistant message,
persisted in SQLite, written by `PersistenceListener.onDone/onError`.
In multi-model, a single topic-level transition corresponds to N
separate message rows.
## ChatContextProvider — per-topicId namespace dispatch
`Ai_Stream_Open` is handled in Main by `dispatchStreamRequest`
(`context/dispatch.ts`):
```
dispatchStreamRequest(manager, subscriber, req)
→ provider = providers.find(p => p.canHandle(req.topicId))
→ prepared = await provider.prepareDispatch(subscriber, req, { hasLiveStream })
→ result = manager.send(prepared) // ← the only manager.send call
→ return { mode, executionIds?, userMessageId?, placeholderIds? }
```
Providers only "prepare" — they never call `manager.send` directly. Two
benefits:
- Provider unit tests assert on `PreparedDispatch` shape without mocking
the manager.
- The restart / start / multi-model fan-out routing lives in exactly one
place.
### Provider interface
```typescript
interface ChatContextProvider {
readonly name: string
canHandle(topicId: string): boolean
prepareDispatch(
subscriber: StreamListener,
req: MainDispatchRequest,
ctx: { hasLiveStream: boolean }
): Promise<PreparedDispatch>
}
interface PreparedDispatch {
topicId: string
models: ReadonlyArray<{ modelId: UniqueModelId; request: AiStreamRequest; rootSpan?: Span }>
listeners: StreamListener[] // subscriber + per-execution PersistenceListener(s)
userMessageId?: string
pendingSteerUserMessageId?: string // persistent steer branch only; marks the dispatch enqueue-only
reservedMessages?: CherryUIMessage[] // user/assistant skeletons created for this dispatch
siblingsGroupId?: number
isMultiModel: boolean
lifecycle?: StreamLifecycle
}
// dispatch.ts also accepts two Main-internal variants synthesised internally —
// `continue-conversation` (tool-approval IPC handler) and `steer-continuation`
// (chat steer drain) — neither exposed over the renderer ↔ main contract.
type MainDispatchRequest = AiStreamOpenRequest | MainContinueConversationRequest | MainSteerContinuationRequest
```
### Built-in providers
| Provider | `canHandle` | Data layer | User message | Assistant message |
|---|---|---|---|---|
| **AgentChatContextProvider** | `topicId.startsWith('agent-session:')` | `agentMessageRepository` | written upfront | runtime provides `PersistenceListener(AgentSessionMessageBackend)` |
| **TemporaryChatContextProvider** | `temporaryChatService.hasTopic(topicId)` | `TemporaryChatService` (in-memory) | appended upfront | `PersistenceListener(TemporaryChatBackend)` appends on done |
| **PersistentChatContextProvider** | `true` (catch-all) | `messageService` + SQLite | transactional create | `PersistenceListener(MessageServiceBackend)` updates pending on done |
Order: Agent → Temporary → Persistent (first `canHandle === true`
wins).
### Persistence path comparison
| | Persistent | Temporary | Agent |
|---|---|---|---|
| User message timing | before stream (tree node) | before stream (append) | before stream (agents DB) |
| Assistant placeholder | created pending before stream | none | created pending before stream (atomic with user msg) |
| Terminal write | `update` placeholder | `append` new row | `update` placeholder (`persistAssistant`) |
| Backend | `MessageServiceBackend` | `TemporaryChatBackend` | `AgentSessionMessageBackend` |
| Multi-model | ✓ | ✗ (single-model) | ✗ (single-model) |
| Regenerate | ✓ | ✗ | ✗ |
### One PersistenceListener across all topic kinds
Persistent / Temporary / Agent / Translation all share the same
`PersistenceListener` class — only the injected `PersistenceBackend`
differs. The observer protocol (`modelId` filter, error part folding,
skip-when-no-finalMessage, swallow errors) is implemented once.
## AiService integration
`AiService` is a lifecycle service:
- **Streaming.** `streamText(request)` returns
`Promise<ReadableStream<UIMessageChunk>>`, consumed by
`AiStreamManager.runExecutionLoop`.
- **Non-streaming IPC gateway.** `generateText` / `checkModel` /
`embedMany` / `generateImage` / `listModels`, registered as IPC
handlers in `onInit`.
`AiStreamManager` calls `await application.get('AiService').streamText(...)`.
Pre-stream errors (provider / model resolution, agent param build)
reject the returned Promise; mid-stream errors come through the returned
stream's error path — the two error paths never overlap.
## Grace period & reconnect
After a stream terminates, `ActiveStream` stays in memory for 30 s
(`config.gracePeriodMs`). During that window a returning user can
`attach` and pull `finalMessage` without a DB read. After expiry the
entry is evicted; subsequent `attach` returns `not-found` and the
renderer reads from the DB through `useQuery` (PersistenceListener has
already written by then).
If the user stops and immediately retries on the same topic, `send`
takes the start branch: `evictStream` first clears the grace-period
remnant (cancels the cleanup timer and drops the entry from
`activeStreams`), then the new stream is created — the old never blocks
the new.
## Edge case cheat sheet
| Case | Handling |
|---|---|
| User sends again on the same topic mid-stream (chat) | provider persists the steer row + `enqueuePendingSteer`; the running turn yields (`steerYield`) and persists as `success`, then `onExecutionDone` chains a `steer-continuation` |
| Retry immediately after stream ends | `send` takes start; `evictStream` clears the grace-period entry first |
| Window closes mid-stream | Next broadcast sees `WebContentsListener.isAlive() === false` and removes it; `PersistenceListener` doesn't depend on a window |
| All windows closed + `backgroundMode='continue'` | Stream continues; `PersistenceListener` persists when done |
| All windows closed + `backgroundMode='abort'` | `onChunk` finds `stream.listeners.size === 0``abort(topicId, 'no-subscribers')`; partial persisted as `paused` |
| Multi-window on same topic | Each window has its own `WebContentsListener`; chunks fan out to all alive listeners |
| Same window re-attaches | Listener id is stable (`wc:${wc.id}:${topicId}`); `addListener` upserts by id |
| Attach mid-stream | `attach` returns compact replay per execution (each buffer compacted independently); observer fills in the gap |
| Ring buffer overflow | At `maxBufferChunks` the oldest chunk drops and `droppedChunks++`; subsequent attach logs the total dropped — replay is no longer lossless |
| Multi-model + resubmit | the steer is queued once per topic; every model's execution yields via `steerYield`, and the single continuation answers it after the turn completes |
| Stream emits `tool-approval-request` | `exec.awaitingApproval = true`; on stream end the topic surfaces `awaiting-approval` via the shared cache |
| Main process restart | `activeStreams` clears; in-flight streams are lost; the renderer re-reads from the DB |
## Design notes
### Testing strategy
- **Manager tests.** `new AiStreamManager({ maxBufferChunks: 3 })` via
the optional config arg; state assertions go through `mgr.inspect(topicId)`;
listener upsert / abort / backgroundMode are tested via behaviour
(drive a chunk, assert which listeners received it).
- **Provider tests.** Assert on the returned `PreparedDispatch` shape
directly — no manager mock.
- **PersistenceListener tests.** `TemporaryChatBackend` as the test
vehicle covers the observer protocol once for every backend.
- All internal state has a public inspection API; production and tests
share the same contract.