A regular chat system treats a conversation as a simple list of messages: user sends one, assistant replies one, and when reopening the page, it just loads the messages[] array back.
Agent sessions have more complex state. A single interaction may include model streaming output, Tool Calls, Tool Results, context compaction, branch switching, approvals, background tasks, and mid-flight user input. If the system only saves the final message, the UI might be recoverable, but many runtime facts are already lost. Conversely, if we serialize every object in flight, we'd be dragging AbortControllers, network connections, and Promises into the persistence layer.
Therefore, Session design first needs to determine the state boundary: which state belongs to long-term facts, which only exists in the current process, and which part the model actually needs to see for each request. Database choice comes after this.
This boundary can be broken down into four concepts:
Session
A long-lived segment of conversation state
Message
Content with conversational semantics
Event
Facts that have already occurred during the conversation
Context
The input actually visible to a single model request
These four often appear together in the same system, but their lifecycles and responsibilities differ.
Why messages[] Quickly Becomes Insufficient
Let's start with the simplest model:
interface Session {
id: string
messages: Message[]
}
For regular chat, this structure already covers most needs. Both history display and the next model input read from the same array.
After Agent introduces Tool Calling, Message itself can still carry some execution results:
user message
assistant message + tool call
tool result
assistant message
The problem is that more and more state can't naturally fit into Message.
For example, before a model request starts, the system may have already determined:
Current model
System Prompt
Tool schemas
Thinking / Reasoning config
Context Compaction result
Current branch
Injected environment information
During execution, it also produces:
Assistant streaming chunks
Tool execution started
Tool execution completed
Turn started / ended
Step started / ended
Cancellation / interruption
These states aren't all suitable for becoming chat messages. If forced into Message[], Message would simultaneously carry four types of responsibility: UI display, model history, execution logs, and recovery protocol. Every new capability added continues to expand the Message type, making it increasingly difficult to determine whether a particular record should be shown to the user, to the model, or only used for runtime recovery.
Therefore, a more stable principle is needed: the persistence layer saves recoverable facts, and model Context and UI History are constructed from these facts.
This principle doesn't require all systems to adopt Event Sourcing. Pi and DeepSeek Harness use notably different persistence models. The key point is that long-term facts and temporary execution state can't depend on the same object graph.
Session Represents the Long-Term Boundary
A Session's lifecycle typically spans multiple model requests and multiple Agent executions.
It needs to provide at least two capabilities:
- Give persistent state a stable home;
- Reconstruct the state needed to continue working after a new runtime instance starts.
Therefore, content suitable for saving in Session typically shares a common characteristic: it remains meaningful after the process exits.
For example:
Messages the user has already sent
Replies the Agent has already completed
Results Tools have already produced
Branches the conversation has used
Summaries generated by Compaction
Session metadata
Necessary snapshots of model-visible configuration
Conversely, these objects typically belong to Runtime:
AbortController
Pending Promise
SSE connection
Current socket
Stream reader
Process handle
Memory lock
They describe "how to execute right now," and typically need to be recreated after process recovery.
The boundary can be drawn as:
Session
durable / reconstructable
│
┌──────────────┼──────────────┐
▼ ▼ ▼
History Metadata Durable Facts
│ restore
▼
Agent Runtime
│
┌──────────────┼──────────────┐
▼ ▼ ▼
AbortSignal Streams Active Tools
Session provides recovery materials, and Runtime recreates execution objects in the current process.
This also means "saving Session" can't simply equate to JSON-serializing an Agent instance. A mature Runtime should be able to recover a new Runtime from a Session, rather than depending on the old Runtime object continuing to exist.
Message Handles Conversational Semantics
Message remains a very important object in Agent systems because both the model and users treat messages as the primary interaction form.
The issue is that Message is more suitable as a semantic view.
For example, user input:
Check UserService's permission logic for me
The Agent then reads files, searches references, runs tests, and finally replies with analysis results.
The user interface might only need to display:
User
Check UserService's permission logic for me
Assistant
...analysis results...
Developer mode might also show Tool Calls:
Read UserService.java
Search hasPermission
Run ./gradlew test
The history the model needs for its next request may differ again. It needs Tool Results, but not necessarily the collapsed state, duration, buttons, or error stack display format in the UI.
So there are at least three views:
Durable Session State
│
├──→ Conversation Messages
│ for users
│
├──→ Model Messages
│ for LLM Provider
│
└──→ Execution / Debug View
for development and observability
If Message itself is defined as the sole persistent fact, these three views easily pollute each other.
A clearer design: Message represents semantically stable objects that the conversation layer can recognize, while allowing Session to save facts richer than Message.
Pi's Session Entry reflects this. In Session files, besides messages, you'll also see compaction, branch_summary, model changes, and other Entries. Session's persistent state is naturally broader than the model message list. [1]
DeepSeek Harness takes a more thorough approach: Session directly saves typed SessionEvent, where user/message, assistant/message, and tool/result are just part of it that can be projected into model messages. [2]
Event Solves "What Happened"
Event's value is expressing state changes as facts.
For example, when only saving the final Message:
assistant:
Test failed due to permission configuration error.
This result can't tell the system:
Whether this Turn has already started
Which Tools the model requested
Whether Tools actually executed
Whether streaming output was interrupted mid-flight
Which chunks comprise the current output
Whether this work was interrupted by process crash
If this information is valuable for recovery, replay, UI, or observability, it needs to be recorded independently.
A minimal Event Log can be written as:
turn/start
user/message
step/start
assistant/message
tool/call
tool/result
step/end
assistant/message
turn/end
There's an important distinction here: Events describe past occurrences, so persistent events should have immutable semantics.
For example:
session.append({
type: 'tool/result',
data: { ... }
})
means a certain Tool Result has entered the conversation's facts.
If later need to display a different UI, or change how model messages are constructed, the Event can be reprojected; the original facts don't need to change along with the UI.
This is also why Event Log can support multiple Projections:
SessionEvent Log
│
├──→ Message Projection
├──→ UI Projection
├──→ Telemetry Projection
└──→ Recovery Projection
But Event Log also brings extra complexity: event schemas need version management, Projections must be deterministic, long logs need compression, and once historical events are published, their semantics can't be arbitrarily changed.
So don't convert all Agent products to Event Log just because "Event Sourcing is more advanced." The judgment should be whether the system truly needs replay, multiple independent Projections, auditing, and precise recovery.
Context Only Serves the Current Model Request
Session and Context are most easily confused.
If Session already saves complete history, the next request still won't simply send everything to the model.
Reasons include:
Context Window has limits
History may have been compacted
Current Agent may only be allowed to see certain messages
Different models need different message transformations
System Prompt is assembled dynamically
Tools change with current capabilities
Temporary Context may only be valid for the next request
Therefore, Context is more suitable as a runtime Projection:
Session Durable State
│
├── Current branch
├── Compaction
├── Memory
├── Agent Policy
├── System Prompt
├── Tool Schemas
└── Runtime Injection
│
▼
Context Builder
│
▼
Model Context
Pi first creates an AgentContext snapshot from long-term Agent State before the Agent Loop; when a Coding Agent restores an existing Session, it uses SessionManager.buildSessionContext() to construct messages from the current Session path, then puts them back into Agent State. [1][3]
DeepSeek Harness has a stricter boundary: model history is projected from Session Event Surface, while request/header records the actual model configuration, System Prompt, and Tool schemas used in the request, allowing a single model request to be reconstructed from the Session Log. [2][4]
The two designs differ in depth, but both clarify that Context has explicit temporal nature. It describes "what this model call saw," while Session describes "what this conversation long-term preserves."
Compaction Further Proves They Must Be Separated
Long sessions eventually hit the Context Window.
Assume Session already contains:
M1 M2 M3 ... M200
The model can't continuously receive all history. The most direct handling compresses early history into a Summary:
Session History
M1 M2 M3 ... M200
Model Context
Summary(M1...M150)
+ M151 ... M200
If Session and Context are the same array, Compaction easily becomes "delete history, replace with Summary." While this saves Context Tokens, user history, audit information, and branch recovery are also lost.
Pi's design is highly illustrative: Compaction itself is appended to the tree as a Session Entry, recording summary, firstKeptEntryId, and tokensBefore; when constructing Context, this Entry determines which old messages are replaced by the Summary. Original Entries remain in the JSONL. [1][5]
Therefore, Compaction's more accurate meaning: change how subsequent Context is constructed, rather than rewriting already-happened history.
For Agents requiring auditability and branchability, this is a very important boundary.
Why Snapshot and Event Tail Easily Cause Consistency Issues
Many Web Agent systems don't fully adopt Event Sourcing, instead choosing:
Message Snapshot
+
Recent Event Tail
This design is completely viable, but must explicitly handle checkpoints.
Assume the Message Snapshot in the database already contains Event 0..105, while the Event Log has already written to 109:
Snapshot checkpoint = 105
Event Log = 0 ... 109
Recovery should execute:
load Snapshot@105
↓
replay Event 106..109
↓
current state
The real risk comes from write order.
If the Projection has already written to Message 109, but the checkpoint still stays at 105, recovery replaying 106..109 would apply the same state twice.
Conversely, if the checkpoint has already updated to 109, but the Message Projection only wrote to 105, the recovery logic would incorrectly skip tail events.
Therefore, checkpoint and Projection must satisfy atomic commit or verifiable idempotent constraints:
Event Log
│
▼
Projector
│
├── update Message Snapshot
└── advance checkpointSeq
Both must form a consistent commit boundary
If the system has already set Event Log as the sole source of truth, such dual-write problems reduce because Message can be reprojected anytime. The cost is that the read path depends more on Projection performance and event schema stability.
Which States Should Enter Session
A very practical judgment standard:
If the process immediately exits, does it still need to know this fact after recovery?
If yes, it should typically enter Session or another Durable Store.
For example:
| State | Typically Persistent | Reason |
|---|---|---|
| User messages | Yes | Conversation history |
| Completed Assistant Message | Yes | Conversation result |
| Tool Result | Yes | Subsequent model decisions may depend on it |
| Compaction Summary | Yes | Determines subsequent Context |
| Current branch | Yes or reconstructable | Determines current history path |
| Approval decision | Usually yes | Can't re-guess after recovery |
AbortController | No | Process-local object |
| SSE Connection | No | Transport state |
| Streaming iterator | No | Cannot resume across processes |
| Tool subprocess handle | Depends on implementation | Usually needs to be converted to recoverable task state |
A second judgment can also be added:
If this state will affect future model requests, can it be reconstructed from persistent facts?
This raises the bar for Session.
For example, after an Agent recovers with completely different System Prompt and Tools, although history messages still exist, previous Tool Results may have lost their semantic environment. DSH therefore includes request header and agentPreset in the persistently reconstructable range. [4]
Simple products may not need to save to this granularity, but design should clarify whether "recovery" means recovering chat text or recovering the model's execution semantics at that time.
A Stable State Layering
After converging the previous relationships, we get this model:
Session
long-term persistence boundary
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Messages Events Metadata
│ │ │
└─────────────┼─────────────┘
│
▼
Projection
│
┌─────────────┼─────────────┐
▼ ▼ ▼
UI History Model Context Recovery State
│
▼
Agent Runtime
Three core judgments here.
First, Session should save state that can persist across processes, not the entire Runtime object.
Second, Message is an important semantic model, but it doesn't need to carry all persistent facts. Tool lifecycle, Compaction, Branch, request configuration, and other states can have independent representations.
Third, Context should be constructed from current Session and Runtime conditions. Compaction, Memory, and Agent configuration changes shouldn't force the system to tamper with complete history.
The next article dives into Pi's specific implementation. Pi doesn't design Session as a complete execution Event Log; it chooses a lighter structure: physical append to JSONL, Entries form a logical tree through id/parentId, and the current leaf determines the current conversation path. This structure is perfect for analyzing how "persistent history" and "model Context" maintain different forms within the same Session.
References
[1] Pi Session File Format: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/session-format.md
[2] DeepSeek Harness Session: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/session.md
[3] Pi SDK session restore: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/sdk.ts
[4] DeepSeek Harness Architecture / Persistence: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/architecture.zh.md ; https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/persistence.zh.md
[5] Pi Compaction: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/compaction.md