#8962·jan

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

rust
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:

  1. The summarized span is unrecoverable. render_transcript flattens 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.
  2. 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_recent needs.
  3. 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

diff
 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.
  • MessagesUpdated continues 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).