Chat session lifecycle

The backend chat runs are long-lived SDK conversations wrapped by two classes:

Both drive their SDK run through the same lifecycle, modelled as a discriminated union in src/core/chat/session/lifecycle.ts, and adapt raw SDK messages through the same shared adapter (adaptSdkMessage in src/core/chat/session/messages.ts). This doc is the backend counterpart to the frontend's state-machine docs — the park/drain/evict/queue contract the backend previously left implicit.

Phases

                 send()                    backend.start()
      idle ───────────────▶ starting ─────────────────────▶ ready
       ▲                                                    │   ▲
       │ run ended (close handler)                   send() │   │ result
       │                                                    ▼   │
       ├──────────────◀──────────── stopping ◀── stop() ── streaming
       │                                │
       └────────◀───────────────────────┘  run ended
PhaseMeaningisRunning()isBusy()
idleno SDK run; start state and where every run endsnono
startingstartRun() in flight (docs refresh, lock, spawn); no run handle yetnoyes
readyrun open and idle, waiting for the next turnyesno
streaminga turn is in flightyesyes
stoppinga graceful stop() closed the run; close handler will not drainyeswasBusy

stopping is ChatSession-only — a thread session has no queue to protect, so its stop()/park() close straight through ready/streamingidle.

The legal edges live in one table (isLegalChatPhaseTransition); every write site goes through nextLifecycle, which throws an InvariantError on an illegal move (a caller bug over internal state — not a degradable condition).

The contract

Shared code

The two classes share the SDK message-pump skeleton (pumpChatRun in session/consume.ts), the lifecycle union/transitions (session/lifecycle.ts), and the SDK-message adapter (adaptSdkMessage in session/messages.ts).

The adapter is one shared function (Track 6 convergence, 2026-07). Both sessions call the same adaptSdkMessage, so every SDK message type surfaces to both paths identically — there is no longer a thread-local fork that silently dropped types. The thread path's narrowing (it delivers only complete <chat-response> blocks over external chat) now lives as explicit flow control in ChatThreadSession.handleMessage: a switch over the message type where stream_event (partial deltas) and task (background-task lifecycle) are deliberate, commented, logged skips, and assertNever guards the union so a new SDK message type is a compile error rather than a silent drop.

The two classes are not yet collapsed into one base class — see ../../issues/code-quality/2026-07-06-chat-session-shared-core.md for why (the per-turn bodies genuinely diverge: durability + queue draining vs <chat-response> extraction).

Photos in a replayed conversation

A photo attached in chat is written by the SDK into the transcript's own JSONL line as base64, and stored nowhere else in the box. That is what makes an image-bearing line 0.7–1.3 MB, and why the history read strips those payloads textually as it scans rather than parsing them (cli/lib/session-oversize.ts — carrying them per request is what OOM'd production in 2026-08).

Stripping loses the bytes from the read, not from the transcript. So a stripped image block comes back carrying its coordinates instead of a placeholder — <sessionId>/<entryUuid>/<index>, defined in shared/session-media.ts — and GET /api/session-media/<ref> reads that one line back out and serves that one photo (webapp/routes/api-session-media.ts). The client renders it as a lazily-loaded <img>, so scrolling back through an old conversation fetches a photograph at a time and never fetches the ones nobody scrolls to.

Two pieces have to enumerate image blocks identically for a reference to resolve: transformContent (cli/lib/session-content.ts), which mints it, and session-media-extract.ts, which follows it back. Both count blocks of type image in message.content, in document order.

A reference is minted only for an image the guard actually stripped. The strip leaves STRIPPED_MEDIA_MARKER where the payload was rather than an empty string, so the reader can tell a photo it can go and fetch from an upload that failed and has nothing behind it — one turn can carry both, and only the first gets a URL. The second still reads [image not displayed], which is true.

Two records of a message, and which one each reader sees

A send is answered 200 once it is durably recorded: POST /api/chat/send emits the persisted chat-user-message onto the box's event bus and takes the durable claim, and only then starts the engine — "recording it IS acceptance" (webapp/routes/chat-send-routes.ts). The transcript is written later, by the agent subprocess.

So the box holds two records, and they are not in step. chat.history and chat.bootstrap's entries read the transcript: that is what is durable. chat.bootstrap's pending reads the acceptance record: that is what is owed (core/chat/session/accepted-messages.ts). A live client never notices the gap — it holds its own optimistic copy and sees the bus event over the WebSocket — but a reloaded page has neither, and for a message that opened a new chat the gap lasts until the engine assigns a session id.

The two are kept separate on the wire and joined on the client, where reconcilePending already knows how to retire a pending message once the transcript catches up. Nothing server-side compares them; one comparison, in one place.