Files
deepseek-harness/docs/core-data-structures/session.md
T
Tianyi Cui 7d69d759f6 Merge branch 'worktree-hooks-d-subagent' into worktree-hooks-e-protocol
# Conflicts:
#	docs/module-graph.md
#	pnpm-lock.yaml
2026-07-04 03:01:22 +08:00

14 KiB

Sessions

The in-memory, event-sourced model of dsh-session. A Session is an append-only log of typed SessionEvents — the single source of truth for an agent's whole interaction history. The LLM message history is derived from the log, never stored separately; replay is re-derivation from the same events. How the log is made durable (the persistence seam, backends, crash recovery) is the sibling concern on persistence.md.

Source: packages/core/session/src/types.ts

SessionEventMap — the event vocabulary

The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the compaction seam adds compact/start / compact/summary / compact/end, and @deepseek-ai/dsh-hook-protocol adds log-only hook/invoked / hook/result provenance for a hook bridge. Like compact/*, these are NOT SurfaceEventTypes (no surfaceOp).

interface SessionEventMap {
  'turn/start': { turn: number; trigger: TurnTrigger }
  'turn/end': { turn: number; reason: TurnEndReason }
  'step/start': { turn: number; step: number }
  'step/end': { turn: number; step: number }
  /** A user-visible prompt (queued message drained at turn start). */
  'user/message': { content: ContentBlock[]; source: MessageSource }
  /**
   * A queued prompt an `agent/prompt-submit` listener VETOED — the durable
   * record of a blocked prompt and why. Appended in place of the `user/message`
   * the prompt would have become, so the block survives replay even in a MIXED
   * batch where another queued prompt is allowed (there the turn does not end
   * `rejected`, so the boundary reason alone would not preserve it). `content`
   * is the original prompt the listener rejected; `reason` is the veto text
   * ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a
   * blocked prompt produces no LLM message and never reaches `deriveMessages()`.
   */
  'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
  /**
   * In-session context injection (file-change notices, subdir AGENTS.md,
   * skill content, cron notifications, …). Rendered into the derived history
   * as tagged synthetic context — NOT a user prompt.
   */
  'context/message': { content: ContentBlock[]; source: MessageSource }
  /** Raw stream chunk — token-level replay fidelity. */
  'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
  /**
   * Assembled assistant message for one step (derived history uses this).
   * Carries the step's `usage` when the adapter reported token accounting, so
   * the model output and its accounting travel together (there is no separate
   * usage record). `usage` is absent when the adapter reported none.
   */
  'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage }
  'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
  'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown }
  /** Steering content injected between steps of a running turn. */
  'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
  /**
   * The agent's whole todo list, carried as a full snapshot and replaced
   * wholesale on each write — the current list is the most recent `todo/write`
   * (last-write-wins on replay, no fold). Appended by an owning agent via
   * `session.append('todo/write', { todos })`.
   *
   * NOT a {@link SurfaceEventType}: it produces no LLM message and never reaches
   * `deriveMessages()`, so it carries no `surfaceOp` and stays off the surface —
   * it is durable, replayable UI state, distinct from the conversation history.
   * It is a `SessionEventMap` member riding the existing `session/event` emit,
   * not a first-class Cordis `interface Events` notification, so it has no
   * cordis-catalog row.
   */
  'todo/write': { todos: TodoItem[] }
}

TodoItem — one todo-list entry

The unit of the todo/write event's whole-list snapshot. Deliberately minimal — a content line and a three-state status (no id, priority, or activeForm): the list is replaced wholesale on every write, so entries need no stable identity, and the status triple is exactly the ACP PlanEntryStatus, so a UI bridge can map a todo list onto an ACP plan 1:1 (synthesizing the priority ACP additionally requires). See the todo_write RFC.

export interface TodoItem {
  content: string
  status: 'pending' | 'in_progress' | 'completed'
}

SessionEvent<T> — one log entry

A proper discriminated union over type (not independent type/data unions), so switch (event.type) narrows event.data without casts. seq is the monotonic position in the log (seq = log.length); time is epoch ms.

type SessionEvent<T extends SessionEventType = SessionEventType> = {
  [K in SessionEventType]: {
    type: K
    /** Monotonic sequence number within the session. */
    seq: number
    /** Unix epoch milliseconds. */
    time: number
    data: SessionEventMap[K]
  } & (K extends SurfaceEventType ? {
    /**
     * Seq numbers of events that are provenance sources of this event
     * (e.g. the `assistant/chunk` seqs that built an `assistant/message`,
     * or the surface nodes shadowed by a compaction replace node).
     */
    sourceEventSeqs?: number[]
    /** How this event entered the surface; absent for non-surface events. */
    surfaceOp?: SurfaceOp
  } : object)
}[T]

SessionEventType = keyof SessionEventMap. Because SessionEventMap is merge-extensible, switches over SessionEvent must NOT use assertNever — a plugin-added variant is a valid unknown value; handle the known cases and fall through default.

Surface types

The five message-producing types (SurfaceEventTypeuser/message, assistant/message, tool/result, context/message, steering/message) carry surface metadata declaring how they join the derived surface linked list. See the session surface RFC.

SurfaceEventType — the message-producing subset of event types

export type SurfaceEventType =
  | 'user/message'
  | 'assistant/message'
  | 'tool/result'
  | 'context/message'
  | 'steering/message'

SurfaceOp — how an event entered the surface

export type SurfaceOp =
  | 'append'
  | { op: 'replace'; start: number; end: number }

'append' is the normal tail-append path. replace shadows surface nodes from start through end inclusive (both must be valid surface node seqs; start === end replaces a single node) and inserts the new node in their place.

SurfaceIntent — the parameter to session.append()

export interface SurfaceIntent {
  surfaceOp: SurfaceOp
  sourceEventSeqs?: number[]
}

Required for SurfaceEventType events — every message-producing event must declare how it joins the surface, the sole source of derived history. Non-surface types reject it at compile time.

SurfaceNode — a node in the surface linked list

export interface SurfaceNode {
  seq: number
  prev: number | null
  next: number | null
}

Derived history: deriveMessages()

Session.deriveMessages() projects the event log into the Message[] the model sees. The projection rules:

  • user/message → a user message.
  • assistant/message → an assistant message. Raw assistant/chunk events are replay/UI data and are skipped in derivation (the assembled message is authoritative). An empty-content assistant/message is also skipped — a max-tokens step cut off with no content still records an assistant/message to host its usage, but a content-less assistant turn must not enter the provider transcript.
  • tool/result → a user message carrying a tool-result block.
  • context/message, steering/message → user-role messages wrapped in a tagged envelope (<context source="…">…</context>) at their chronological position — the "system-reminder" pattern; the model distinguishes them from real prompts by the envelope.

Everything else (turn/*, step/*) is structural and does not project into a message. Token usage is observed on assistant/message.usage (the step that produced it); an operational error's step number is on turn/end.reason for kind: 'error'.

What started a turn: TurnTriggerMap

interface TurnTriggerMap {
  message: { kind: 'message'; source: MessageSource }
  continuation: { kind: 'continuation' }
  /**
   * An out-of-band context injection (`agent.inject()`) made while the agent
   * was idle. The loop wraps the injected `context/message` in a one-shot turn
   * (`turn/start` → `context/message` → `turn/end`) so every event in the log
   * stays turn-enclosed — the durability/replay boundary is the turn, and a
   * bare event between turns would otherwise be indistinguishable from a crash
   * tail on reload.
   */
  injection: { kind: 'injection'; source: MessageSource }
}

Why a turn ended: TurnEndReasonMap

interface TurnEndReasonMap {
  completed: { kind: 'completed' }
  aborted: { kind: 'aborted'; reason?: string }
  /**
   * The turn failed: a step threw or the model reported a failure. `step` is the
   * step number the failure occurred on (the operational error's location — the
   * single durable record of an in-turn failure; live diagnostics also fire via
   * `agent/error`). `code` is the error's code when one was attached.
   */
  error: { kind: 'error'; step: number; message: string; code?: string }
  disposed: { kind: 'disposed' }
  'max-tokens': { kind: 'max-tokens' }
  /**
   * The turn's entire prompt batch was BLOCKED before any step ran — every
   * drained queued message was vetoed by an `agent/prompt-submit` listener (a
   * hook). The turn still opened (so the boundary stays balanced and the block
   * is a durable in-turn fact), but ran zero steps. `reason` carries the block
   * message from the vetoing decision. Distinct from `aborted` (a user-driven
   * cancel) and `error` (a failure): the prompt was rejected by policy, not
   * interrupted or broken. A UI renders it as "prompt blocked by hook".
   */
  rejected: { kind: 'rejected'; reason: string }
  /**
   * The turn never ended on its own: the process crashed mid-turn and a
   * persistence backend later closed the orphaned (open) turn on reload so the
   * log stays balanced. SYNTHESIZED by the backend's crash-recovery repair — no
   * loop ever emits this. Its events are real (they were durably appended before
   * the crash) and are PRESERVED, not discarded: a single turn can be huge in a
   * long-horizon task (many steps, large tool output), so truncating it would
   * lose real work. The marker records that the turn was cut short, not that the
   * model completed it. See the session-persistence RFC.
   */
  interrupted: { kind: 'interrupted' }
}

max-tokens mirrors the model-call FinishReason of the same name: any max-tokens step in a turn makes the whole turn end max-tokens (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one. rejected is a zero-step turn whose whole prompt batch an agent/prompt-submit hook blocked (the ACP bridge maps it to cancelled). interrupted is the one reason no loop emits — it is synthesized by crash recovery (see persistence.md). Both maps are merge-extensible.

The turn-enclosure invariant

Every session event lives inside a turn (between a turn/start and its turn/end). The loop appends queued user/message events after turn/start, and an idle agent.inject() wraps its context/message in a one-shot injection turn. This makes the turn the single durability/replay boundary: a backend can treat anything after the last turn/end as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The dsh-invariants plugin enforces it in dev (a message event outside an open turn throws). See the turn-enclosure invariant RFC.

Plugin-contributed log-only events

A plugin may declaration-merge extra SessionEventMap types. These are log-only: NOT SurfaceEventTypes (they carry no surfaceOp and contribute nothing to derived history), but, like every event, they must sit inside an open turn. The compaction seam's compact/* are documented on compaction.md; the hook bridges' hook/* provenance (from @deepseek-ai/dsh-hook-protocol) are:

Event Payload Role
hook/invoked { turn, point, dialect, matcher?, handlerId } A hook command was invoked at a hook point (PreToolUse, Stop, …). dialect is the bridge (claude/codex/native); matcher the matcher-group pattern that selected it (absent for match-all); handlerId correlates with the result.
hook/result { turn, point, handlerId, decision, exitCode?, stderrSummary?, durationMs } The decided outcome, paired by handlerId. decision is the resolved neutral outcome (deny/allow/block/stop/pass/…); exitCode absent when the hook could not run; stderrSummary the truncated block-reason source.

The mid-turn hook points (PreToolUse/PostToolUse/UserPromptSubmit/Stop) fire inside the loop's open turn, so their hook/* records are turn-enclosed by construction. SessionStart gets no hook/* record — its injected context/message is the durable evidence — because it has no open turn to enclose one (see the hooks RFC).

Durability contract

What a persistence backend relies on: the durable log persists every event verbatim, including assistant/chunkseq must stay contiguous, so chunks cannot be filtered out of the canonical log. All event.data must be JSON-serializable; Session.append enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and session.events always equals what a backend can persist. Adding an event type that carries non-serializable data, or that breaks the turn/step nesting the invariants plugin checks, is a breaking change to the on-disk format.

The backends that consume this contract are on persistence.md.