Most conventional chat systems tend to treat a session as a set of messages: the user sends one, the assistant replies, and when the page is reopened, the application simply reads messages[] back.
Agent sessions have a much more complex state model. A single interaction may involve streaming model output, Tool Calls, Tool Results, Context Compaction, branch switching, approvals, background tasks, and input that arrives mid-turn. If the system persists only the final messages, the UI may be restorable, but many facts about the run have been lost. If it serializes every live object as-is, transient state such as AbortController instances, network connections, and Promises ends up in the persistence layer.
Designing a Session therefore starts with defining the state boundary: which state is a long-lived fact, which exists only in the current process, and which parts the model actually needs to see on each request. The choice of database is an implementation detail that comes afterward.
The boundary can first be broken down into four concepts:
They often coexist in the same system, but their lifecycles and responsibilities differ.
Why messages[] quickly becomes insufficient
Let's start with the simplest model:
For ordinary chat, this structure covers most requirements. Both the history shown in the UI and the input for the next model turn are read from the same array.
After Tool Calling is introduced, Message itself can still carry some execution outcomes:
user message
assistant message + tool call
tool result
assistant message
The problem arises as more and more state can no longer fit naturally within a Message.
Before a model request begins, for example, the system may already have determined:
Current model
System Prompt
Tool schemas
Thinking / Reasoning configuration
Context Compaction result
Current branch
Environment information injected for this run
Execution also produces:
Assistant streaming chunks
Tool execution started
Tool execution completed
Turn started / ended
Step started / ended
Cancellation / interruption
Not all of these states are suitable for representation as chat messages. If they are forced into Message[], Message ends up serving four purposes at once: UI display, model history, execution log, and recovery protocol. Every new capability requires another extension to the Message type, eventually making it difficult to tell whether a given record should be shown to the user, supplied to the model, or used only for runtime recovery.
We therefore need a more stable principle: the persistence layer stores recoverable facts, while model Context and UI History are constructed from those facts.
This does not require every system to adopt Event Sourcing. Pi and DeepSeek Harness use distinctly different persistence models. The key is that long-lived facts and transient execution state cannot depend on the same object graph.
Session represents the long-lived boundary
A Session's lifecycle normally spans multiple model requests and multiple Agent runs.
It needs to provide at least two capabilities:
- Give persisted state a stable owner.
- Reconstruct the state needed to continue working when a new runtime instance starts.
Content suited to a Session therefore shares a common trait: it remains meaningful after the process exits.
For example:
Conversely, objects such as these usually belong to the Runtime:
AbortController
Pending Promise
SSE connection
Current socket
Stream reader
Process handle
In-memory lock
They describe how execution works at the current moment, so they usually need to be recreated after a restart.
The boundary can be drawn like this:
Session provides the material needed for recovery, while Runtime recreates the execution objects in the current process.
This also means that “saving a Session” cannot simply mean serializing an Agent instance to JSON. A mature Runtime should be able to restore a new Runtime from a Session instead of depending on an old Runtime object still existing.
Message handles conversational semantics
Message remains one of the most important objects in an Agent system because messages are the primary interaction format for both the model and the user.
The issue is that Message works better as a semantic view.
For example, suppose the user enters:
Check the permission logic in UserService
The Agent then reads files, searches for references, runs tests, and eventually replies with the findings.
The user interface may need to display only:
User
Check the permission logic in UserService
Assistant
...analysis results...
Developer mode may also show Tool Calls:
Read UserService.java
Search hasPermission
Run ./gradlew test
The history needed for the model's next request may be different again. It needs the Tool Results, but not the UI's collapsed state, elapsed time, buttons, or stack-trace formatting.
At least three views therefore exist:
If Message itself is defined as the sole durable fact, these views can easily bleed into one another.
A clearer design treats Message as a semantic object that the conversation layer can identify reliably, while allowing Session to store richer facts than Message does.
Pi's Session Entry design illustrates this point. A Pi Session file contains entries besides messages, including compaction, branch_summary, and model changes. The durable state of a Session is naturally broader than the model's list of messages. [1]
DeepSeek Harness takes a more thorough approach: a Session directly stores typed SessionEvent records, and user/message, assistant/message, and tool/result are only a subset that can be projected into model messages. [2]
Event answers “what happened”
The value of an Event lies in expressing a state change as a fact.
If only the final Message is saved:
assistant:
The test failed because the permission configuration was incorrect.
That result cannot tell the system:
If this information is valuable for recovery, replay, the UI, or observability, it needs to be recorded separately.
A minimal Event Log could look like this:
turn/start
user/message
step/start
assistant/message
tool/call
tool/result
step/end
assistant/message
turn/end
One important distinction: Events describe things that have already happened, so persisted Events should ideally have immutable semantics.
For example:
This records that a Tool Result has entered the Session's durable facts.
If a different UI or a different way of constructing model messages is needed later, the Events can be reprojected; the original facts do not need to change along with the UI.
This is also what allows an Event Log to support multiple Projections:
But an Event Log adds complexity: event schemas need version management, Projections must be deterministic, long logs require compaction, and the semantics of historical Events cannot be changed casually once they have been published.
It would therefore be wrong to convert every Agent product into an Event Log just because “Event Sourcing is more advanced.” The deciding factors should be whether the system genuinely needs replay, multiple independent Projections, auditability, and precise recovery.
Context serves only the current model request
Session and Context are the easiest to confuse.
Even if the Session already contains the full history, the next request still will not simply send everything to the model.
The reasons include:
Context is therefore better defined as a runtime Projection:
Before entering the Agent Loop, Pi creates an AgentContext snapshot from long-lived Agent State. 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 enforces a stricter boundary: model history is produced by projecting the Session Event Surface, while request/header records the model configuration, System Prompt, and Tool schemas actually used, making it possible to reconstruct a single model request from the Session Log. [2][4]
Although the two designs operate at different levels of depth, both make clear that Context has a definite time scope. It describes “what this particular model call saw,” whereas Session describes “what this conversation retains over the long term.”
Compaction further proves the two must be separated
Long conversations eventually run into the limits of the Context Window.
Suppose the Session already contains:
M1 M2 M3 ... M200
The model cannot keep receiving the entire history. The most direct response is to compact 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 the history and replace it with a Summary.” This saves Context Tokens, but it also discards the user history, audit information, and the ability to restore branches.
Pi's design is particularly illustrative: Compaction itself is appended to the tree as a Session Entry, recording summary, firstKeptEntryId, and tokensBefore. When constructing Context, the system uses that Entry to decide which older messages the Summary replaces. The original Entry remains in JSONL. [1][5]
Compaction therefore means, more precisely, changing how subsequent Context is constructed—not rewriting history that has already happened.
For Agents that need auditability and branching, this is an important boundary.
Why Snapshots and Event Tails Can Create Consistency Problems
Many Web Agent systems do not adopt Event Sourcing in full. Instead, they use:
Message Snapshot
+
Recent Event Tail
This design is entirely viable, but it must handle checkpoints explicitly.
Suppose the Message Snapshot in the database already includes Events 0..105, while the Event Log has been written through 109:
Snapshot checkpoint = 105
Event Log = 0 ... 109
At recovery time, the system should:
load Snapshot@105
↓
replay Event 106..109
↓
current state
The real risk comes from write ordering.
If the Projection has already written Message 109 while the checkpoint is still at 105, recovery replays 106..109 and may apply the same state twice.
Conversely, if the checkpoint has advanced to 109 but the Message Projection has only been written through 105, the recovery logic will incorrectly skip the tail events.
The checkpoint and Projection must therefore be committed atomically or satisfy verifiable idempotency constraints:
Event Log
│
▼
Projector
│
├── update Message Snapshot
└── advance checkpointSeq
Both must form a consistent commit boundary
If the system has already made the Event Log its sole source of truth, this dual-write problem diminishes because the Message projection can be rebuilt at any time. The tradeoff is that the read path depends more heavily on Projection performance and the stability of the event schema.
Which state should belong to a Session
A very practical test is:
If the process exits immediately, will the system still need to know this fact after recovery?
If the answer is yes, it generally belongs in the Session or another Durable Store.
For example:
| State | Usually persisted? | Reason |
|---|---|---|
| User message | Yes | Conversation history |
| Completed Assistant Message | Yes | Conversation result |
| Tool Result | Yes | Later model decisions may depend on it |
| Compaction Summary | Yes | Determines subsequent Context |
| Current branch | Yes, or reconstructable | Determines the current history path |
| Approval decision | Usually | Must not be guessed again after recovery |
AbortController | No | Process-local object |
| SSE Connection | No | Transport state |
| Streaming iterator | No | Cannot be recovered across processes |
| Tool subprocess handle | Implementation-dependent | Usually needs to be converted into a recoverable task state |
A second test can be added:
If this state will affect future model requests, can it be reconstructed from durable facts?
This raises the requirements on Session to another level.
For example, suppose a restored Agent has a completely different System Prompt and Tools. The historical messages are still there, but earlier Tool Results may have lost their semantic context. DSH therefore includes the request header and agentPreset in the set of information that can be durably reconstructed. [4]
Simple products may not need this level of detail, but the design should make clear whether “recovery” means restoring the chat transcript or restoring the model's execution semantics.
A stable state layering
After bringing the preceding relationships together, we arrive at the following model:
There are three core takeaways.
First, Session should store state that remains valid across process boundaries rather than an entire Runtime object.
Second, Message is an important semantic model, but it does not have to carry every durable fact. Tool lifecycle, Compaction, Branch, request configuration, and similar state can have independent representations.
Third, Context should be constructed from the current Session and Runtime conditions. Changes to Compaction, Memory, or Agent configuration should not force the system to rewrite the complete history.
The next article will get into Pi's concrete implementation. Pi does not design Session as a complete execution Event Log; it chooses a lighter structure: JSONL uses physical appends, Entries form a logical tree through id / parentId, and the current leaf determines the current Session path. This structure is well suited to examining how “durable history” and “model Context” can take 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