# AgentSession: What State Must Survive a Model Switch or Session Resume?

> AgentSession (core/agent-session.ts) is the shared abstraction across interactive, print, and RPC modes. It owns session persistence, model/thinking-level management, bash execution, and auto-compaction triggers. This page asks: what is serialized to disk, which events drive session persistence, how session branching works, and why AgentSession is deliberately mode-agnostic.

- Repository: earendil-works/pi
- GitHub: https://github.com/earendil-works/pi
- Human wiki: https://grok-wiki.com/public/wiki/earendil-works-pi-8b87608fc234
- Complete Markdown: https://grok-wiki.com/public/wiki/earendil-works-pi-8b87608fc234/llms-full.txt

## Source Files

- `packages/coding-agent/src/core/agent-session.ts`
- `packages/coding-agent/src/core/session-manager.ts`
- `packages/coding-agent/src/core/agent-session-services.ts`
- `packages/coding-agent/src/core/agent-session-runtime.ts`
- `packages/coding-agent/test/suite/agent-session-runtime.test.ts`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [packages/coding-agent/src/core/agent-session.ts](packages/coding-agent/src/core/agent-session.ts)
- [packages/coding-agent/src/core/session-manager.ts](packages/coding-agent/src/core/session-manager.ts)
- [packages/coding-agent/src/core/agent-session-runtime.ts](packages/coding-agent/src/core/agent-session-runtime.ts)
- [packages/coding-agent/src/core/agent-session-services.ts](packages/coding-agent/src/core/agent-session-services.ts)
- [packages/coding-agent/test/suite/agent-session-runtime.test.ts](packages/coding-agent/test/suite/agent-session-runtime.test.ts)
</details>

# AgentSession: What State Must Survive a Model Switch or Session Resume?

`AgentSession` is the shared core abstraction that all run modes — interactive TUI, print (non-interactive), and RPC — build on top of. It owns the coupling between the in-memory agent state (streaming turns, tool calls, message queue) and the durable session log on disk. When a user switches models, resumes a previous session, or forks a conversation branch, `AgentSession` is responsible for ensuring that the right state — and only the right state — moves across that boundary.

This page walks from first principles through what exactly is serialized, which events trigger writes, how branching works mechanically, and why the abstraction is deliberately agnostic to the I/O layer above it.

---

## What Is the Simplest Version?

The simplest possible agent persistence is: write every message to a file. That works until a user switches models partway through a session, compacts context, or resumes a conversation in a different directory. Each of those cases requires persisting *more* than raw messages — you need configuration snapshots, tombstone markers for history replacement, and a way to reconstitute the exact LLM context for a given branch of the conversation.

The actual design therefore stores not just messages but a *typed entry log*, and reconstitutes runtime state from it at load time.

---

## The Session File: Append-Only JSONL with a Tree Structure

The fundamental persistence unit is a JSONL file where each line is a JSON object (a `FileEntry`). The first line is always a `SessionHeader`; every subsequent line is a `SessionEntry` with `id` and `parentId` fields that form a tree.

```
SessionHeader  { type: "session", version: 3, id, timestamp, cwd, parentSession? }
SessionEntry   { type, id, parentId, timestamp, ...type-specific fields }
SessionEntry   ...
```

The `SessionManager` class owns all writes to this file.

Sources: [packages/coding-agent/src/core/session-manager.ts:30-37](), [packages/coding-agent/src/core/session-manager.ts:700-720]()

### Entry Types and What They Encode

| Entry type | What it encodes | Participates in LLM context? |
|---|---|---|
| `message` | A single `AgentMessage` (user, assistant, toolResult, custom) | Yes |
| `model_change` | Provider + model ID snapshot | Yes — replayed to restore model on resume |
| `thinking_level_change` | ThinkingLevel string | Yes — replayed to restore thinking level |
| `compaction` | Summary text, `firstKeptEntryId`, token count before compaction, optional extension details | Yes — summary injected as first message |
| `branch_summary` | Summary of a diverged branch, `fromId` pointer | Yes — injected as synthetic message |
| `custom_message` | Extension-injected user message (shown in TUI) | Yes |
| `custom` | Extension-specific opaque data | No — for extension state only |
| `label` | User-defined bookmark on any entry | No |
| `session_info` | Display name for the session | No |

Sources: [packages/coding-agent/src/core/session-manager.ts:44-147]()

**Key distinction:** `custom` (opaque data bag for extension state reconstruction) vs `custom_message` (actually becomes a user-role message in the LLM context). Both survive a session resume; only `custom_message` changes what the model sees.

---

## When Does a Write Happen?

### Message Persistence: `message_end` Event

`AgentSession` subscribes to every `AgentEvent` from the underlying `Agent` core. On `message_end`, it calls the appropriate `SessionManager.append*` method:

```ts
// packages/coding-agent/src/core/agent-session.ts:498-517
if (event.type === "message_end") {
    if (event.message.role === "custom") {
        this.sessionManager.appendCustomMessageEntry(...)
    } else if (
        event.message.role === "user" ||
        event.message.role === "assistant" ||
        event.message.role === "toolResult"
    ) {
        this.sessionManager.appendMessage(event.message);
    }
}
```

This means persistence is event-driven and synchronous with the streaming pipeline. The session file grows one entry at a time as the agent turn progresses, not in a batch at the end.

Sources: [packages/coding-agent/src/core/agent-session.ts:498-517]()

### Configuration Change Persistence

Model and thinking-level changes are written immediately when they are applied, before the next turn:

- `setModel()` calls `sessionManager.appendModelChange(provider, id)` — Sources: [packages/coding-agent/src/core/agent-session.ts:1417-1431]()
- `setThinkingLevel()` calls `sessionManager.appendThinkingLevelChange(level)` only when the level actually changes — Sources: [packages/coding-agent/src/core/agent-session.ts:1519-1531]()

This is essential: when the session is resumed, `buildSessionContext()` replays entries in branch order, and it extracts the *last* `model_change` and `thinking_level_change` entries it encounters to set `model` and `thinkingLevel` on the reconstituted `SessionContext`. Without persisting these as log entries, a model switch made mid-session would be invisible on resume.

### Compaction Persistence

Compaction appends a `CompactionEntry` with:
- `summary`: the LLM-generated summary text
- `firstKeptEntryId`: which entry is the oldest one still included verbatim in context
- `tokensBefore`: count for display/analytics
- `details`: optional extension-specific opaque data

After appending, `buildSessionContext()` is called to rebuild in-memory messages from the new log state — Sources: [packages/coding-agent/src/core/agent-session.ts:1692-1695]()

### The Deferred Write Optimization

`SessionManager._persist()` has a deliberate optimization: it does not write anything to disk until the first assistant message arrives. Up to that point, a conversation consists only of user messages, which have no value without a response. Once the first assistant message is received, the entire accumulated entry list is flushed in one batch, then subsequent entries are appended one-by-one.

```ts
// packages/coding-agent/src/core/session-manager.ts:843-861
_persist(entry: SessionEntry): void {
    if (!this.persist || !this.sessionFile) return;

    const hasAssistant = this.fileEntries.some(
        (e) => e.type === "message" && e.message.role === "assistant"
    );
    if (!hasAssistant) {
        this.flushed = false;
        return;
    }

    if (!this.flushed) {
        for (const e of this.fileEntries) {
            appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`);
        }
        this.flushed = true;
    } else {
        appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
    }
}
```

This means a session file does not appear on disk (or grow in size) until at least one assistant response has been generated. Prompt-only runs that the user aborts immediately leave no file behind.

Sources: [packages/coding-agent/src/core/session-manager.ts:843-861]()

---

## Reconstituting State on Resume: `buildSessionContext()`

The counterpart to the write path is `buildSessionContext()`, a pure function that accepts the flat entry list and a `leafId` and walks the tree from leaf to root:

```
leaf → parent → ... → root
```

The walk collects (in leaf-wins order):
- The last `thinking_level_change` → `thinkingLevel`
- The last `model_change` or last assistant `message` (which embeds provider/model) → `model`
- The last `compaction` entry → compaction boundary

Then it rebuilds the message array:

1. If a compaction boundary exists: emit the summary message first, then emit only the entries from `firstKeptEntryId` onward (pre-compaction), then all entries after the compaction marker.
2. If no compaction: emit all messages in path order.

Sources: [packages/coding-agent/src/core/session-manager.ts:315-421]()

The rebuilt `SessionContext` is then applied directly to the live agent state:

```ts
// packages/coding-agent/src/core/agent-session.ts:1974-1975
const sessionContext = this.sessionManager.buildSessionContext();
this.agent.state.messages = sessionContext.messages;
```

This means the in-memory agent state is always derivable from the JSONL file. There is no separate in-memory cache that can diverge from the persisted log — the log *is* the source of truth.

---

## What Survives a Model Switch?

When `setModel()` is called:

1. A new `ModelChangeEntry` is appended to the session log.
2. `agent.state.model` is updated immediately.
3. The thinking level is re-clamped to the new model's capabilities (different models support different thinking level sets).
4. `settingsManager.setDefaultModelAndProvider()` records the choice as the new default for future sessions.

```ts
// packages/coding-agent/src/core/agent-session.ts:1417-1431
async setModel(model: Model<any>): Promise<void> {
    ...
    this.agent.state.model = model;
    this.sessionManager.appendModelChange(model.provider, model.id);
    this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
    this.setThinkingLevel(thinkingLevel);
    ...
}
```

The conversation history is **not affected** — all prior messages remain in context. The change only affects which provider and model handle the *next* LLM call. On session resume, `buildSessionContext()` replays the `model_change` entry so the correct model is restored without user intervention.

Sources: [packages/coding-agent/src/core/agent-session.ts:1417-1473]()

### Thinking Level Clamping

A model that does not support reasoning will not honor a `thinking_level_change` entry higher than `"off"`. `clampThinkingLevel()` from `@earendil-works/pi-ai` is applied every time the model changes, ensuring the persisted level is always a value the current model can actually use. This is important when a session is resumed on a machine where a previously-used model is not available.

Sources: [packages/coding-agent/src/core/agent-session.ts:1566-1578]()

---

## Session Branching: How Forks Work

The tree structure (`id`/`parentId`) exists specifically to support branching. Forking creates a new session file that is a copy of the original up to a chosen entry, then diverges:

```
Original session (copied):
  header → e1 → e2 → e3(user) → e4(assistant) → leaf

Fork "before" e3:
  New session starts with parentSession = original file path
  leafId in new session = e3.parentId (= e2)
  The forked session's new entries are children of e2
```

`SessionManager.createBranchedSession()` physically copies the current JSONL file and sets `leafId` to the target entry's parent (for a "before" fork) or the entry itself (for an "at" fork). The copy captures the full history; the new leaf pointer determines what context the agent sees on the next turn.

Sources: [packages/coding-agent/src/core/agent-session-runtime.ts:246-330]()

The `SessionHeader.parentSession` field stores the original file path, creating an audit trail of which session a fork originated from.

### Branch Summaries

When a session is forked, the diverged branch can be summarized into a `BranchSummaryEntry`. On resume of the original session, `buildSessionContext()` encounters this entry and injects a synthetic user message explaining what happened in the diverged branch. This lets the LLM retain awareness of exploratory forks without including the full fork context verbatim.

Sources: [packages/coding-agent/src/core/session-manager.ts:78-86](), [packages/coding-agent/src/core/session-manager.ts:385-387]()

---

## The Session Lifecycle Event Model

`AgentSessionRuntime` wraps `AgentSession` and owns the higher-level session replacement flows (new, resume, fork, import). Every replacement follows a strict sequence of extension events:

```text
session_before_switch / session_before_fork  (cancellable)
        ↓
session_shutdown  (teardown of old session)
        ↓
[new AgentSession created, old one disposed]
        ↓
session_start  (startup of new session)
```

Sources: [packages/coding-agent/test/suite/agent-session-runtime.test.ts:164-207]()

The test confirms exact event ordering:
```ts
expect(events).toEqual([
    { type: "session_before_switch", reason: "new", targetSessionFile: undefined },
    { type: "session_shutdown", reason: "new", targetSessionFile: secondSessionFile },
    { type: "session_start", reason: "new", previousSessionFile: originalSessionFile },
]);
```

This sequence matters for extensions that maintain their own state (e.g., an artifact index stored in `custom` entries). The `session_shutdown` event gives them a chance to flush; the `session_start` event gives the new context a chance to scan existing entries and reconstruct their state.

---

## Why AgentSession Is Mode-Agnostic

The class header is explicit:

```ts
/**
 * This class is shared between all run modes (interactive, print, rpc).
 * Modes use this class and add their own I/O layer on top.
 */
```

Sources: [packages/coding-agent/src/core/agent-session.ts:1-14]()

All event emission goes through the `_emit()` method, which broadcasts to a list of `AgentSessionEventListener` callbacks. Interactive mode, print mode, and RPC mode each add their own listener via `subscribe()`. Session persistence happens in `_handleAgentEvent` which runs *before* listeners are notified, so the data is on disk before any UI reacts.

This design means:
- The interactive TUI can subscribe to events and render streaming output.
- RPC mode can subscribe to the same events and serialize them over a protocol.
- Neither mode needs to know that the other exists, and neither is responsible for persistence.
- The `AgentSessionRuntime` layer, which manages session switching and forking, operates on `AgentSession` instances without knowing which I/O layer is attached.

The only mode-specific coupling is the `ExtensionUIContext` and `ExtensionCommandContextActions` passed via `bindExtensions()`, which give extensions access to UI primitives. These are injected by the host (interactive mode wires its TUI here) but have no effect on persistence.

Sources: [packages/coding-agent/src/core/agent-session.ts:668-683](), [packages/coding-agent/src/core/agent-session.ts:2041-2060]()

---

## State Diagram: Session Persistence Lifecycle

```text
┌──────────────────────────────────────────────────────┐
│ AgentSession                                          │
│                                                       │
│  agent.subscribe(_handleAgentEvent)                   │
│         │                                             │
│         ▼                                             │
│  message_end ──► sessionManager.appendMessage()      │
│  model change ──► sessionManager.appendModelChange() │
│  thinking chg ──► sessionManager.appendThinkingLevelChange() │
│  compaction ──► sessionManager.appendCompaction()    │
│         │                                             │
│         ▼                                             │
│  _emit(event) ──► listeners (TUI, RPC, print)        │
└──────────────────────────────────────────────────────┘
         │
         ▼
┌──────────────────────────────────────────────────────┐
│ SessionManager (JSONL file on disk)                   │
│                                                       │
│  [header]                                             │
│  [message: user, parentId=null]                       │
│  [message: assistant, parentId=e1]                    │
│  [model_change, parentId=e2]                          │
│  [compaction, firstKeptEntryId=e1, parentId=e3]       │
│  [message: user, parentId=e4]                         │
│  ...                                                  │
│                                                       │
│  leafId = last entry id                               │
└──────────────────────────────────────────────────────┘
         │
         ▼ buildSessionContext(leafId)
┌──────────────────────────────────────────────────────┐
│ SessionContext { messages[], thinkingLevel, model }   │
│  (what the LLM sees on next turn)                     │
└──────────────────────────────────────────────────────┘
```

---

## Summary

`AgentSession` ensures durable state by treating every important runtime change as an appended log entry: messages arrive via `message_end` events, model and thinking-level transitions are appended immediately on change, and compaction writes a boundary marker with a summary that replaces the preceding history. The JSONL file's tree structure (`id`/`parentId`) enables branching and non-destructive history editing. On resume, `buildSessionContext()` walks from the current leaf to the root, replaying configuration changes and respecting compaction boundaries to rebuild exactly the right in-memory context. The abstraction is mode-agnostic by design: all I/O layers attach as event listeners on top of `AgentSession`, while persistence runs unconditionally in the internal `_handleAgentEvent` handler before any listener is notified.

Sources: [packages/coding-agent/src/core/agent-session.ts:334-337](), [packages/coding-agent/src/core/session-manager.ts:1087-1093]()
