Skip to content

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.

PackageOwns
@deepseek-ai/dsh-sessionSession, SessionEventMap, SessionStore (ctx.sessions), deriveMessages()
@deepseek-ai/dsh-session-persistencectx.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.

ts
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:

CategoryEvent typePayload (key fields)Model-visible?
Lifecycleturn/start{ turn }
Lifecycleturn/end{ turn, reason: TurnEndReason }
Lifecyclestep/start{ turn, step }
Lifecyclestep/end{ turn, step }
Messageuser/messageUserMessage (role user)surface
Messageassistant/chunk{ turn, step, chunk: StreamChunk }replay-only
Messageassistant/message{ turn, step, message, usage? }surface
Tooltool/call{ turn, step, callId, name, arguments }
Tooltool/result{ turn, step, message, error?, meta? }surface
Statetodo/write{ todos: TodoItem[] } (whole-list snapshot)
Staterequest/header{ header: EpochHeader, reason }
Staterequest/contextRequestContext
Lifecyclesession/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 incremental SurfaceManager) into an LLM Message[], caching across appends:
ts
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/:

PackageRole
dsh-session-persistenceThe SessionPersistence interface, abstract append(id, events)/load(id), PersistenceCoordinator, write-behind batching
dsh-session-persistence-jsonlJSONL backend (one event per line, header first)
dsh-session-persistence-sqliteSQLite backend
dsh-session-checkpoint-policyOwns the per-request session/flush durability checkpoint
dsh-session-projectionDerives 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 / SQLite

Every 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.sessions is scoped per agent.
  • System prompt assembly — what the request/header system/tools fields 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).