refactor(agent): keep a canonical transcript separate from the provider projection
Author: thinhlpgCreated Sep 16, 2026Updated Sep 17, 2026
Problem
Jan keeps one message list and mutates it in place. conversation_messages is the wire request, the stored transcript, and the thing compaction rewrites — all the same Vec<Value>:
src-tauri/src/core/agent/loop.rs:2621-2628
conversation_messages = compacted; // the only copy
let _ = events.send(StreamEvent::MessagesUpdated { // and the client is told to adopt it
messages: conversation_messages.clone(),
});The same destructive rewrite happens for the reasoning-field retry at loop.rs:2648-2651, and compaction.rs:98-104 builds a fresh vector that replaces the old one wholesale.
one list, three jobs two structures, one job each
┌─────────────────────────┐ ┌──────────────────────────────┐
│ conversation_messages │ │ canonical transcript │
│ ├── what we send │ │ append-only · never rewritten │
│ ├── what we store │ ← compaction └──────────────┬───────────────┘
│ └── what the UI shows │ overwrites │ project()
└─────────────────────────┘ all three ▼
┌──────────────────────────┐
summarized span is GONE │ provider projection │
from every one of them │ bounded · rebuildable │
└──────────────────────────┘Three consequences, in increasing order of how hard they are to work around later:
- The summarized span is unrecoverable.
render_transcriptflattens the dropped messages into text, summarizes it, and the originals are dropped. A user who asks "what exactly did that command print two hours ago" gets the summary's paraphrase. - The projection cannot be rebuilt. Because there is no source of truth behind it, you cannot re-derive a prefix — which is exactly what a different provider, a resumed session, or a changed
keep_recentneeds. - Every consumer is coupled to the wire format. The UI, the persisted thread, and the request all read the same OpenAI-shaped vector, so any change to what goes on the wire is a change to what the user sees and what is on disk.
Prior art
- DeepSeek-Reasonix keeps a canonical append-only log and derives a bounded projection for each call. Compaction changes the projection; the log is never edited. That is what lets it schedule a prefix break rather than suffer one.
- OpenAI Codex #37305 is the failure this prevents: compaction fired at ~235,000 input tokens and the compacted request could not reuse its own prefix, because there was nothing stable to rebuild it from.
Proposed change
agent run
- conversation_messages: Vec<Value> # sent, stored, shown, rewritten
+ transcript: Transcript # append-only, canonical, never rewritten
+ projection: Vec<Value> # derived per turn from the transcript
on compaction
- conversation_messages = compact(conversation_messages)
- publish(conversation_messages)
+ transcript.record(CompactionPoint { summary, span })
+ projection = transcript.project() # summary + kept tail
+ publish(projection)
on reasoning-field rejection
- conversation_messages = strip_reasoning(conversation_messages)
+ projection = transcript.project_without(Reasoning)Design notes:
- The transcript records events (a turn, a tool call, a tool result, a compaction point), not wire messages. The wire shape is one projection among several.
project()is a pure function, so two calls with the same transcript produce byte-identical output — which is the property #8965's regression tests assert.- Provider-specific stripping (
reasoning_content, unsupported content parts, a provider's message-shape quirks) becomes a projection parameter instead of a destructive edit. MessagesUpdatedcontinues to publish the projection; clients need no change.
This is the largest item in the epic and the one that makes several others cheap rather than fiddly — #8960, #8961, and #8965 all become straightforward once there is a canonical source to project from.
Acceptance criteria
- A canonical transcript type exists that is only ever appended to; no code path rewrites or deletes a recorded event.
- The provider request is produced by a pure
project()over the transcript. - Compaction records a compaction point rather than replacing the message list; the original span is still in the transcript afterwards.
- The reasoning-field retry becomes a projection option, not a destructive strip.
- A test asserts
project()called twice on an unchanged transcript yields byte-identical output. - A test asserts the full pre-compaction history is still readable from the transcript after compaction.
Related
- Enables #8961 (schedule the break instead of reacting to it) and #8965 (byte-level regression tests need a deterministic projection to assert against).
Source: janhq/jan