A session is the single source of truth for one agent's whole interaction history. Concretely it is an append-only log of typed SessionEvents owned by the Session class in packages/core/session/src/index.ts, with the event vocabulary and envelope declared in packages/core/session/src/types.ts. The working dictionary of the loop — messages, content blocks, replay — is derived from this log, never stored separately, so replay is merely re-deriving from the same immutable events.
| Package | Owns |
|---|---|
@deepseek-ai/dsh-session | Session, SessionEventMap, SessionStore (ctx.sessions), deriveMessages() |
@deepseek-ai/dsh-session-persistence | ctx.sessionPersistence: the JSONL/SQLite backends, flush checkpoint, crash recovery |
SessionEvent — one log entry
Every entry is immutable, lossless-JSON, and monotonic. The seq is always the log length at append time (seq = log.length, the contiguity contract the whole system relies on), and time is epoch milliseconds. Because it is a discriminated union over type, switch (event.type) narrows event.data without casts.
type SessionEvent<T extends SessionEventType = SessionEventType> = {
[K in SessionEventType]: {
type: K
seq: number // monotonic position; = log.length at append
time: number // Unix epoch ms
data: SessionEventMap[K]
ignorable?: true // reader may skip an unrecognized type; absent = required
} & (K extends SurfaceEventType ? {
sourceEventSeqs?: number[] // seqs of earlier events this one cites
surfaceOp?: SurfaceOp // how this event entered the ordered surface
} : object)
}[T]ignorable matters for forward compatibility: an unrecognized required event makes a reader refuse to reconstruct the session rather than silently drop it, because it may change how the rest of the log is interpreted.
The three surface-eligible types (user/message, assistant/message, tool/result) may carry surfaceOp and sourceEventSeqs. surfaceOp is either 'append' (normal tail insertion) or { op: 'replace', start, end } (used by compaction to shadow surface nodes and cite them in sourceEventSeqs).
SessionEventMap — the event categories
The event vocabulary is a merge-extensible map; plugins add types by declaration merging without touching the owning package. The core thirteen, with the categories they fall into:
| Category | Event type | Payload (key fields) | Model-visible? |
|---|---|---|---|
| Lifecycle | turn/start | { turn } | — |
| Lifecycle | turn/end | { turn, reason: TurnEndReason } | — |
| Lifecycle | step/start | { turn, step } | — |
| Lifecycle | step/end | { turn, step } | — |
| Message | user/message | UserMessage (role user) | surface |
| Message | assistant/chunk | { turn, step, chunk: StreamChunk } | replay-only |
| Message | assistant/message | { turn, step, message, usage? } | surface |
| Tool | tool/call | { turn, step, callId, name, arguments } | — |
| Tool | tool/result | { turn, step, message, error?, meta? } | surface |
| State | todo/write | { todos: TodoItem[] } (whole-list snapshot) | — |
| State | request/header | { header: EpochHeader, reason } | — |
| State | request/context | RequestContext | — |
| Lifecycle | session/end-seed | {} (boundary marker) | — |
user/message covers three distinct producers that all project their content verbatim — a direct human prompt, a synthetic agent.inject() context (file-change notices, skill content, cron notifications, …), or an entered goal continuation round. The source field on UserMessage tells them apart. tool/call records the raw arguments JSON string exactly as the model produced it (unparsed); callId pairs it with its tool/result.
The request/header event records the full EpochHeader — call config, adapter-supplied defaults, rendered system prompt, and assembled tool schemas — so every conversation request is a pure function of the log. A full snapshot with reason 'initial' or 'resume' marks each loop-instance boundary; a later different request appends another full snapshot with reason 'change'. foldRequestHeader() reconstructs the latest by selecting the last snapshot. request/context is separate route metadata (provider, model, contextWindow) that stays out of EpochHeader so a capacity change does not register as a request-envelope change.
Session identity
The session's identity is SessionId, a branded string derived from the durable SessionHeader (which holds id, format version, cwd, fork lineage, createdAt, seed boundary). session.header is a storage concern, kept out of the event log — it is not replayable conversation state. The SessionStore (ctx.sessions) mints ids (session-<n>) and keeps a Map<SessionId, SessionEntry>; create() builds a fresh session. Resuming a persisted session is the AgentRegistry's job: ctx.agents.resume() loads it through the session-persistence layer first.
The log as source of truth
The Session class holds a private log array and exposes two read paths:
events— a frozen snapshot of the append-only log (reused until the next append).deriveMessages()— projects the ordered surface (the incrementalSurfaceManager) into an LLMMessage[], caching across appends:
deriveMessages(): Message[] {
const surface = this.surface
const nodes = surface.nodes
const generation = surface.replaceGeneration
if (generation !== this.derivedGeneration) {
this.derived = []; this.derivedNodes = 0; this.derivedGeneration = generation
}
for (const seq of nodes.slice(this.derivedNodes)) {
const msg = this.deriveEventMessage(this.log[seq]!)
if (msg) this.derived.push(msg)
}
this.derivedNodes = nodes.length
return [...this.derived]
}append(type, data, opts) is the only mutation. It validates that data is lossless-JSON, snaps a frozen copy into the log, assigns seq/time, applies surface metadata (required on message-producing events, forbidden on log-only ones), and synchronously notifies observers — the hot path never blocks on I/O because persistence buffers asynchronously. Once an event enters the log the append is committed: observer failures are logged and contained per listener.
This is why replay and fork are just construction: seeding a Session with an existing event log (ctx.sessions.create(id, { seed })) validates the seed to the same invariants append enforces (seq contiguous from 0) and inserts the session/end-seed marker. SessionForkError codes (SESSION_NOT_FOUND, SESSION_NOT_LIVE, SESSION_ALREADY_EXISTS, INVALID_BOUNDARY, OPEN_TURN) guard the fork boundary.
Making the log durable
Persistence is deliberately not in dsh-session. Plugins subscribe to the store's session/event firehose and write the log to a backend SessionPersistence, while the store owns the in-memory live Session. The related packages in packages/session/:
| Package | Role |
|---|---|
dsh-session-persistence | The SessionPersistence interface, abstract append(id, events)/load(id), PersistenceCoordinator, write-behind batching |
dsh-session-persistence-jsonl | JSONL backend (one event per line, header first) |
dsh-session-persistence-sqlite | SQLite backend |
dsh-session-checkpoint-policy | Owns the per-request session/flush durability checkpoint |
dsh-session-projection | Derives projection state (stats, titles, telemetry) from the log |
The write path is batched per session by the SessionWriteBehind controller: events are structuredCloned into a pending queue, flushed on a fixed deadline, and joined into one barrier through a quiescent point. The shared PersistenceCoordinator preserves contiguous, losslessly JSON-serializable events and a separate non-replayable SessionHeader. append resolves only after backend durability; load materializes the full validated log (meta + events), and raw-artifact text is read separately via readRaw(id) when the backend supports it — there is no header-only probe. A crash mid-append leaves an interrupted tail that load balances and durably closes. flush(id) is the explicit quiescence barrier; the session/flush checkpoint policy drives it per request.
Because downstream consumers may emit event types the read path does not know, packages/core/session/src/known-event-types.ts maintains the KNOWN_SESSION_EVENT_TYPES set; a persisted log containing a type outside it is refused unless the event carries the ignorable marker — silently skipping a required event would reconstruct a wrong session.
The store and the event firehose
The SessionStore exposes ctx.sessions and pairs create / prepare / enter / announce / get. create() mints an id, builds the Session, and folds its lifecycle into the calling fiber's effect — disposing that fiber stops event notification and removes the session. The composite path (prepare + enter + announce) is the advanced ordered-lifecycle primitive the async agent factory uses to tear the session down in order with its agent.
ctx.sessions.create(id, { seed?, meta? })
│ build Session (validate seed, freeze events, insert end-seed)
▼
enter(session) ── install publication hooks (module-private)
▼
announce(session) ── attach to the store, notify snapshot readers
│
▼
session/event notifications ── per-listener contained
│
└─ persistence backend (async, buffered) → JSONL / SQLiteEvery append synchronously feeds observers through the store-owned, module-private publication hooks; persistence plugins subscribe and buffer asynchronously, so the hot path never blocks on I/O. A snapshot reader (session/event firehose) replays the log as a publication substitute where the ordering of the firehose matters — telemetry adoption starts from firstLiveSeq because constructor seeds do not emit.
requestHeader and header equality
Two Session accessors keep the request-call loop grounded in the log. requestHeader() folds the latest request/header event; requestContext() folds the latest request/context. foldRequestHeader and canonicalHeader/headerEquals let the loop decide whether a header really changed before logging a change snapshot — a pure-log decision that keeps reconstructed requests deterministic (see model-selection.md).
Session stats & replay
Because history is a derived projection and every entry is immutable, "stats", replay, and forks are just folds over the same log. The loop's assistant/message event carries each step's usage (token accounting when the adapter reported it), so the model output and its accounting travel together — there is no separate usage record. The request/context event exposes contextWindow for the resolved route.
Replay is re-derivation: reopen a session (or a fork seeded with { seed }) and run the projection over the same immutable events. Everything a request depends on — the rendered system prompt and tool schemas (request/header), the route capacity (request/context), the surface history (deriveMessages()) — is reconstructable purely from the log, which is the reconstructability invariant the loop upholds. Projection is a separate concern: dsh-session-projection and its siblings (session-stats, session-title-*, session-telemetry, session-telemetry-otel) fold the log into derived UI/observability state such as session stats and titles.
When a persisted log is reopened for resume, Session.fromRestore validates the storage format, event envelopes, sequence continuity, surface transitions, and header fields before freezing, and load durably closes any crash-orphaned open turn. The durable fork boundary is header.seedLength, while the in-process construction boundary is firstLiveSeq (projected into the log as session/end-seed).
Further reading
- The agent loop — how the loop appends
user/message,tool/call,tool/result, and derives history each step. - Scope system — how
ctx.sessionsis scoped per agent. - System prompt assembly — what the
request/headersystem/toolsfields carry. - Repo source:
packages/core/session/src/types.ts,packages/core/session/src/index.ts,packages/session/session-persistence/src/write-behind.ts. - Official scaffold:
docs/subsystems/session.md,docs/subsystems/persistence.md,docs/glossary.md(the "session" source-of-truth and "replay" entries).