feat: preserve conversation context across model, effort, and provider switches - #192
Conversation
…r switches Keeps one shared conversation when switching between models, effort levels, and backends. A lossless portable transcript is retained separately from the model's compacted working history; CLI backends reuse their native session when available and receive only the turns they missed. - internal/api: ConversationState + per-backend NativeConversation cursors - internal/agent/conversation.go: transcript append/restore, native session sync, budget-aware handoff, searchable JSONL archive for large histories - CLI backends (cc, codex, agy, opencode) record exposed tool activity and refresh effort/output directives on native resumes - sessions: atomic owner-only writes, transcript persistence, --resume and /save/-p support on every backend Retained content is redacted conservatively: it is replayed as the conversation itself, so RedactRetained matches only unambiguous credential shapes rather than reusing the display-time patterns, which rewrote ordinary source like `token := lexer.Next()`. Handoffs abbreviate message bodies instead of dropping whole entries, so advancing a backend's native cursor cannot skip context permanently. Turn counts ignore tool traffic, the context archive is cleaned up on the signal exit paths, /clear rotates to a new session ID instead of overwriting the saved one, and portable transcripts expire after 90 days so session storage stays bounded. Verified: gofmt, go vet ./..., go test ./..., go test -race on agent, session, repl, and codexrunner, plus a release build.
QualityMax ReviewVerdict: COMMENT · Confidence: evidence-backed scan Files eligible: 20 · Files reviewed: 20 · Files with findings: 0 · Findings: 0 · Inline cards: 0 Priority findings
Review gates
Important files
Change diagram — Flowflowchart TD
A[Agent.RunCLI] --> B{Check Native Session}
B -- Restored --> C[cli.Run]
B -- Not Restored --> D[Reset Conversation]
D --> C
C --> E[Record Tool/Assistant Output]
E --> F[Update Conversation State]
F --> G[Return Response]
Review lifecycleUse the inline cards to inspect evidence and suggested remediation. Re-run the QualityMax review after pushing a fix; unchanged cards are identified by their stable finding marker. Dismiss with a reason through the existing QualityMax/GitHub review feedback flow. 0 prior card(s) are stale/resolved on this head. Proof legend: VERIFIED independently judged patch · REPRODUCED verified finding · GROUNDED deterministic evidence · MODEL-ONLY model judgment. QualityMax project results are available in the configured project. Receipt · commit |
There was a problem hiding this comment.
🟡 Changes recommended
Session cleanup’s durable-transcript detection can misclassify sessions based on substring matching in the file head, which can cause portable-transcript sessions to expire under the legacy cutoff.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR introduces a portable “lossless” conversation transcript that is retained across model/effort/provider switches and persisted via /save, /resume, and --resume, while keeping each backend’s native session cursor in sync where possible. It extends session persistence to store transcript + per-backend native conversation checkpoints, adds bounded retention and cleanup behavior, and records exposed CLI tool activity into the shared history.
Changes:
- Add
ConversationState(portable transcript + per-backend native cursors) and integrate it into agent history management and session persistence. - Implement cross-backend handoff logic (budget-aware inline transfer + optional JSONL archive) and record exposed CLI tool activity for CC/Codex/Antigravity/OpenCode.
- Update session storage to be atomic/owner-only, redact retained content, and apply different TTLs for durable (portable transcript) vs legacy sessions; update docs accordingly.
File summaries
| File | Description |
|---|---|
| SECURITY.md | Documents what local conversation retention stores, TTLs, atomic writes, and redaction behavior. |
| README.md | Updates session semantics to describe shared conversation preservation across backends and restarts. |
| main.go | Wires --resume into RestoreConversation, ensures one-shot uses shared session ID, saves portable conversation state. |
| internal/session/session.go | Adds ConversationState persistence, atomic save, durable TTL, turn counting excluding tool traffic, and retained redaction. |
| internal/session/conversation_test.go | Adds tests for atomic/durable session behavior, redaction, turn counting, TTLs, and deduplication. |
| internal/security/security.go | Adds RedactRetained with narrower secret patterns suitable for replayable retained content. |
| internal/repl/repl.go | Makes interactive REPL use shared session/transcript, rotates session ID on /clear, and improves autosave behavior/errors. |
| internal/api/conversation.go | Introduces ConversationState and NativeConversation types. |
| internal/agent/conversation.go | Implements transcript append/restore, native session sync, and budget-aware handoff with optional JSONL archive + cleanup. |
| internal/agent/conversation_test.go | Adds extensive coverage for handoff completeness, compaction, native resume, archive behavior, and tool recording. |
| internal/agent/agent.go | Integrates transcript-aware history appends/clears and compaction improvements (including archive hints). |
| internal/agent/cc_agent.go | Records exposed CC tool activity into transcript without duplicating assistant text. |
| internal/agent/codex_agent.go | Records exposed Codex tool activity via runner presenter hook. |
| internal/agent/agy_agent.go | Refreshes effort/output directives each turn and records exposed tool activity. |
| internal/agent/opencode_agent.go | Refreshes directives each turn, records tool parts, and supports nested tool state decoding. |
| internal/agent/cerebras_agent.go | Switches to transcript-aware history appends for built-in provider loop. |
| internal/agent/ollama_agent.go | Ensures compaction occurs before running Ollama and uses transcript-aware history appends. |
| docs/COMMANDS.md | Updates --save-session documentation to reflect shared-context saving across all backends. |
| codexrunner/runner.go | Introduces PresentationTool and plumbs exposed tool activity to the presenter boundary. |
| codexrunner/runner_test.go | Adds test ensuring only tool content reaches presenter and hidden reasoning does not. |
Review details
- Files reviewed: 20/20 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| head := make([]byte, 512) | ||
| n, err := file.Read(head) | ||
| if n <= 0 || (err != nil && err != io.EOF) { | ||
| return false | ||
| } | ||
| head = head[:n] | ||
| return bytes.Contains(head, []byte(`"transcript":`)) && | ||
| !bytes.Contains(head, []byte(`"transcript":null`)) | ||
| } |
There was a problem hiding this comment.
Good catch — fixed in 617f3ff ✅
The mechanism you described doesn't actually reproduce: a pasted {"transcript":null} inside message content is escaped on write as \"transcript\":null, so the needle can't match it. I probed it and the session was classified durable correctly.
But you were right that the fixed-head substring match was broken 🎯 — via a different route. MarshalRedacted round-trips through map[string]any, so json.Marshal emits keys in sorted order, which puts native before transcript inside conversation. With four backend checkpoints (each carrying a directory + rollout path) the transcript key lands at byte 1258, past the 512-byte head. Result: durable sessions misclassified as legacy and deleted at 7 days instead of 90 — silent loss of exactly what this PR exists to retain. 😬
Fix: scan the raw bytes in 64KB chunks with an overlap so the key can't straddle a boundary, and match "transcript":[ instead. A populated transcript always serializes with [ and a nil one as null, so one needle distinguishes both cases without a second Contains. Offset and file size no longer matter.
I went with a byte scan rather than locating the conversation object and parsing its value, since this runs at startup over every expired file and the point was to avoid unmarshalling multi-MB sessions just to pick a cutoff.
Added TestDurableDetectionIsIndependentOfKeyOffsetAndEscaping, which asserts the fixture genuinely places the key past byte 512 (so it can't silently stop exercising the bug), plus both escaping directions and the true-legacy case. 🧪
|
| Gate | Result |
|---|---|
| 🔍 AI diff review | ✅ Clean · gemini-3.1-flash-lite · completed · 17 eligible / 17 reviewed · gemini-3.1-flash-lite |
| 🔍 SAST | completed · 20 eligible / 20 reviewed · qwen3.7-plus |
| 🔍 Canonical PR review delivery | completed · 0 eligible / 0 reviewed · exact-head review #5141790296 and overview #5584766333 confirmed |
| 🧪 Repo Tests | ✅ 794/794 passed (go) |
Powered by QualityMax — AI-Powered Test Automation
MarshalRedacted encodes through map[string]any, so json.Marshal emits keys in sorted order and the native checkpoints precede the transcript inside the conversation object. Four backends carrying a directory and rollout path push the transcript key past byte 1258, well beyond the fixed 512-byte head read, so hasDurableTranscript reported false and the session was expired under the 7-day legacy cutoff instead of the 90-day durable one. Scan the raw bytes in 64KB chunks with an overlap so the key cannot straddle a boundary, and match `"transcript":[` — a populated transcript always serializes with a bracket and a nil one as null, so a single needle distinguishes both cases. Escaped content in a message still cannot match, since it is written as \"transcript\":[. Reported by Copilot on #192, though via a different mechanism (a pasted JSON literal, which escaping already prevented).
What
Switching models, effort levels, or providers now keeps one shared conversation. A lossless portable transcript is retained separately from the model's compacted working history. CLI backends (Claude Code, Codex, Antigravity, OpenCode) reuse their native session when it is still valid and receive only the turns they missed; built-in Anthropic, Cerebras, and Ollama receive the shared history including exposed CLI tool activity.
/save,/resume, and--resumecarry this across restarts, on every backend.How
internal/api/conversation.go—ConversationState(transcript + per-backendNativeConversationcursors).internal/agent/conversation.go— transcript append/restore, native session sync, budget-aware handoff, and a private searchable JSONL archive for histories too large to inline.internal/session— atomic owner-only writes, transcript persistence, bounded retention.Notable design decisions
Redaction of retained content is deliberately conservative. Retained content is replayed as the conversation, so a false positive silently corrupts the only copy of the user's context.
RedactRetainedmatches only unambiguous credential shapes rather than reusing the display-time patterns, which rewrote ordinary source (token := lexer.Next()→token :[REDACTED] lexer.Next()). Structured credential fields (password,access_token, …) are still redacted by key name.Handoffs abbreviate, they do not drop. When the inline budget is tight, message bodies shrink progressively but every undelivered message keeps its slot and role. Dropping whole entries would leave a hole that the native cursor then advances past permanently — silent, unrecoverable context loss in exactly the case this PR exists to fix.
Storage stays bounded. Portable transcripts get a 90-day TTL (legacy sessions keep 7 days), detected by reading 512 bytes rather than parsing every expired file at startup. Session files no longer store the same conversation twice.
The archive is an optimization, never a precondition. A read-only or full temp directory degrades the summary instead of failing the turn.
Other behaviour worth calling out: turn counts ignore tool traffic (a prompt with five tool calls is one turn, not six); the context archive is cleaned up on the
Ctrl+C/SIGTERMexit paths, not just on deferred cleanup;/clearrotates to a new session ID so the cleared conversation stays resumable instead of being overwritten with an empty file.Limits
Hidden reasoning and provider-private state cannot be transferred. Provider context windows still apply. This cannot recover history an older version already discarded. Live providers were not exercised — validation is the test suite plus a release build.
Verification
gofmtclean ·go vet ./...clean ·go test ./...all packages pass ·go test -raceclean onagent,session,repl,codexrunner· release build OK.Includes regression tests for handoff completeness under a condensed budget, compaction with an unwritable
TMPDIR, tool records stored once as assistant activity, OpenCode nested-state input preservation, redaction keep/drop lists, turn counting, durable expiry, and no double-storage on disk.One pre-existing issue fixed along the way: the CC stream tests were failing under
-racebecausetui.NewTerminal()starts a readline ioloop whoseCloseraces with it insidechzyer/readline. They now use a zero-value&tui.Terminal{}, which renders headlessly.