Skip to content

feat: preserve conversation context across model, effort, and provider switches - #192

Merged
Desperado merged 2 commits into
mainfrom
Desperado/preserve-switch-context
Sep 8, 2026
Merged

feat: preserve conversation context across model, effort, and provider switches#192
Desperado merged 2 commits into
mainfrom
Desperado/preserve-switch-context

Conversation

@Desperado

Copy link
Copy Markdown
Contributor

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 --resume carry this across restarts, on every backend.

How

  • internal/api/conversation.goConversationState (transcript + per-backend NativeConversation cursors).
  • 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.
  • CLI backends record exposed tool activity (never hidden reasoning, stream envelopes, or config) and refresh effort/output directives on native resumes.
  • 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. RedactRetained matches 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 / SIGTERM exit paths, not just on deferred cleanup; /clear rotates 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

gofmt clean · go vet ./... clean · go test ./... all packages pass · go test -race clean on agent, 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 -race because tui.NewTerminal() starts a readline ioloop whose Close races with it inside chzyer/readline. They now use a zero-value &tui.Terminal{}, which renders headlessly.

…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.
@qualitymaxapp

qualitymaxapp Bot commented Sep 8, 2026

Copy link
Copy Markdown

QualityMax Review

Verdict: COMMENT · Confidence: evidence-backed scan

Files eligible: 20 · Files reviewed: 20 · Files with findings: 0 · Findings: 0 · Inline cards: 0

Priority findings

priority location finding
No blocking findings

Review gates

gate status
AI diff review completed · eligible 17, reviewed 17 · LLM · served gemini-3.1-flash-lite
SAST completed · eligible 20, reviewed 20 · hybrid · served qwen3.7-plus
Overall review evidence clean
Inline evidence not needed

Important files

file risk note next step
No findings

Change diagram — Flow

flowchart 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]
Loading

Review lifecycle

Use 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. @qmax Q&A is tracked separately.

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 617f3ff21d1509925c0fb7a4af755aa5d175e46c · run 2026-09-08T12:42:13+00:00 · model served qwen3.7-plus, gemini-3.1-flash-lite · model requested qwen3.7-plus, gemini-3.1-flash-lite · model review recorded — 4635 model output tokens · model source repository ai_review_preferences.preferred_model · re-review 2 · proof counts {}

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread internal/session/session.go Outdated
Comment on lines +470 to +478
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`))
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. 🧪

@qualitymaxapp qualitymaxapp Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QualityMax Review — canonical overview updated; inline findings are attached to this review.

@qualitymaxapp

qualitymaxapp Bot commented Sep 8, 2026

Copy link
Copy Markdown

⚠️ QualityMax Pipeline

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).
@Desperado
Desperado merged commit c93368f into main Sep 8, 2026
5 checks passed
@Desperado
Desperado deleted the Desperado/preserve-switch-context branch September 8, 2026 12:33
@Desperado Desperado mentioned this pull request Sep 8, 2026

@qualitymaxapp qualitymaxapp Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QualityMax Review — canonical overview updated; inline findings are attached to this review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants