Choosing a Source of Truth for Agent Conversations
The UI ledger and the agent snapshot drifted through head trimming, synthetic cancel turns, and interrupted saves. The fix made user-visible SQLite history authoritative on normal cold restores and kept snapshots as derived history or fallback data.

On this page
A hard bug report for a chat product reads: "I told it something five messages ago, I can scroll up and see myself telling it, and it is acting like I never said it." The screenshot shows the message, and the model is answering from a history in which that message does not exist. Both are correct, because the UI and the agent runtime each kept their own copy of the conversation, and the copies had drifted.
How you end up with two conversations
Nobody designs two conversation stores; they accumulate. In Foxl Agent, the UI renders chat from the documented local conversation store: SQLite tables for messages, tool_calls, and reasoning, written incrementally as each streamed token and tool event lands. That store exists because the UI needs granular, ordered, resumable rows. Separately, the agent runtime persists a snapshot of its own in-memory message array once per invocation, so a cold process restart can rebuild the model's context without replaying the world. That store exists because the agent needs its exact wire-format history, content blocks and all.
Both stores are called "the conversation", and both are written by code that is locally correct. Two writers describing one thing will drift apart as soon as their write paths differ in any detail.
Three quiet leaks
Ours diverged in three places, and in each one a reasonable line of code was doing its job.
First, the runtime used a sliding window that trimmed old turns from the head of the in-memory array to keep prompts bounded. That is a sensible per-request view, but the snapshot was taken from that same array, so the trim got baked into the persisted history. After a process restart, the model's history permanently started at turn 41, while the UI table still had turns 1 through 40: the user could see them and the model could not.
Second, when a user cancelled a streaming response, the SDK injected a synthetic "cancelled by user" turn into the agent's array to keep its bookkeeping consistent. That turn went into the snapshot but never into the UI tables, because the UI correctly showed nothing for a response the user had cancelled. Each store was self-consistent, and the two now described different histories.
Third, the reverse case: the snapshot was saved at invocation end. After a crash or a hard kill mid-turn, the UI tables had the partial turn (rows are written as events land) and the snapshot did not. The store with the finer write granularity won by default, without any error.
None of the three threw an exception. No invariant checker fired, because there was no invariant: nothing in the system expressed the claim that the two stores should agree, and each store passed its own tests. The disagreement was observable only as behavior, a model that seemed to forget earlier turns or that acted on turns the user never saw.
Demote one store
Synchronizing the writers did not hold up; we tried variations of that, and every new write path was a new place to leak. What held was deciding which store is the truth and making the other one mechanically derivable from it. That source-of-truth choice is also central to Foxl's local-first boundary. The UI tables won because they are the store the user audits; every screenshot in every bug report is a picture of that store. When the system and the user disagree about what was said, the system should side with the record the user can see.

So the latest snapshot became a derived fallback, not the normal authority. On a cold rebuild, the agent's history is re-derived from the same tables the UI reads, deterministically - same input rows, byte-identical output array, which matters because prompt caching keys off those bytes. Immutable snapshots still support explicit historical loads, and the latest snapshot remains a defensive fallback when table reconstruction is unavailable. They can still be stale; the normal restore path simply no longer trusts them over the user-visible ledger. The same distinction between durable events and reconstructable runtime state appears in Foxl Code's reconciliation architecture.
You already have two stores
The same problem exists outside chat apps. Every system past a certain age has second stores: the cache, the denormalized column, the search index, the read replica, the materialized view, the "temporary" JSON file someone added during an incident in 2024. Second stores are fine on their own; the failure is having two writers - two code paths that each believe they are recording the truth, with no function that maps one store onto the other and no rule for who wins when they differ.
The test is cheap to state: for every pair of stores describing the same entity, is there code that produces one from the other? If reconciling them would take a meeting instead of a function call, you have two independent records, one of which is wrong, and nothing tells you or the model which one.
The user always reads one of the stores, so make that one authoritative and treat every other store as a cache derived from it.
References and further reading
- How Foxl stores conversationsDocumentation
- Foxl workspace memoryDocumentation