fix(agent): preserve compaction projections across restart / 重启后保留压缩投影 (#8923)

* fix(agent): keep compaction across tail-only rewinds / 尾部回退不再丢弃压缩投影

Editing a message rewinds the conversation to that message and resubmits.
CoveredCount used to record the build-time transcript length, so the verbatim
tail was frozen inside the projection and even a tail-only truncation crossed
into covered territory: the fold was discarded, the visible context ballooned
back to the pre-compaction transcript, and the next round re-paid a full
summary. The projection body now freezes only prefix + digest + kept messages
with CoveredCount at the fold boundary (translated to canonical indices when
folding on top of a live projection), so the tail splices from the transcript;
rewind invalidation is conditional on the projection actually going stale.
Re-folds whose boundary lands inside the prior projection body keep the
unfolded body suffix verbatim instead of dropping it from the model context.

Cache-impact: low - the provider-visible request bytes are identical for an
unchanged tail; tail-side rewrites now stay visible instead of serving stale
frozen copies
Cache-guard: TestTailOnlyRewindKeepsCompactedView, control-level
TestTailRewindKeepsCompactionProjection, and
TestRefoldIntoBodyKeepsUnfoldedBodyTail pin the changed boundaries; the
incremental/restart suites stay green

Documentation-impact: none - docs describe compaction behavior, which is
unchanged; this restores the documented behavior after message edits

* fix(agent): restore compacted projections after restart

Problem: Long sessions could reload their full transcript after every desktop restart, discarding a valid compaction sidecar and re-paying the context and token cost. Fork-first rewinds and branches could also lose a valid parent projection.

Root cause: The durable covered-prefix fingerprint was computed before the deterministic tool-pair repair applied during LoadSession, so old transcripts with repaired tool metadata no longer matched after restart. Fork and branch children did not copy the parent's sidecar.

Fix: Fingerprint the provider wire-safe prefix, strictly migrate v1.25.2 sidecars only when the exact pre-repair disk prefix matches, preserve that raw view through resume wrappers, and copy only prefix-validated projections to fork and branch children. Process-local transcript versions remain CAS metadata rather than durable identity.

Compatibility and cache impact: No sidecar schema change. Legacy hashes are rewritten only after an exact raw-prefix and normalized-wire equivalence check; real history edits fail closed. Unchanged provider request bytes remain stable.

Verification: go test ./... -count=1; desktop and SDK suites; go test -race ./internal/agent ./internal/control -count=1; go vet; repolint; request-byte stability; git diff --check.
Co-authored-by: XTLine <[email protected]>

---------

Co-authored-by: XTLine <[email protected]>
This commit is contained in:
SivanColaandXTLine authored and GitHub committed 2026-08-15 23:36:38 +08:00
1 parent 01cdd70dbc
commit f60ab17b0a
18 files changed
+597 -76

No files matched your search

+6 -3
View File
@@ -15,6 +15,9 @@ type summaryProjectionCommit struct {
activeTurn int64
trigger, summary, inputHash, outputHash string
sourceTokens, projectionTokens int
// covered is the canonical length the frozen projection body represents;
// messages past it splice live from the transcript.
covered int
}
// commitSummaryProjection CAS-installs a checkpoint under compactionMu:
@@ -57,11 +60,11 @@ func (a *Agent) summaryProjectionState(commit summaryProjectionCommit) Compactio
projectionVersion := commit.projectionVersion + 1
now := time.Now().UTC()
summaryHash := summaryContentHash(commit.summary)
coveredHash := coveredPrefixHash(commit.canonical, len(commit.canonical))
coveredHash := coveredPrefixHash(commit.canonical, commit.covered)
receipt := &ContextMaintenanceReceipt{
OperationID: fmt.Sprintf("summary-%d-%s", projectionVersion, commit.outputHash), Status: "applied",
Action: "summary", Trigger: commit.trigger, SourceProjection: commit.projectionVersion,
ProjectionVersion: projectionVersion, CoveredCount: len(commit.canonical), CoveredPrefixHash: coveredHash,
ProjectionVersion: projectionVersion, CoveredCount: commit.covered, CoveredPrefixHash: coveredHash,
InputHash: commit.inputHash, OutputHash: commit.outputHash, InputTokens: commit.sourceTokens,
ResultTokens: commit.projectionTokens, SavedTokens: max(0, commit.sourceTokens-commit.projectionTokens),
SummaryHash: summaryHash, CacheBreak: true, CreatedAt: now,
@@ -73,7 +76,7 @@ func (a *Agent) summaryProjectionState(commit summaryProjectionCommit) Compactio
Generation: commit.generation + 1, PromptCacheKey: a.currentPromptCacheKey(),
Projection: ContextProjection{
Messages: commit.projected, TranscriptVersion: commit.transcriptVersion,
ProjectionVersion: projectionVersion, CoveredCount: len(commit.canonical), CoveredPrefixHash: coveredHash,
ProjectionVersion: projectionVersion, CoveredCount: commit.covered, CoveredPrefixHash: coveredHash,
SummaryHash: summaryHash, SourceTokens: commit.sourceTokens, ProjectionTokens: commit.projectionTokens,
ViewInputHash: commit.inputHash, ViewOutputHash: commit.outputHash, CreatedAt: now,
},
@@ -2,6 +2,7 @@ package agent
import (
"context"
"fmt"
"strings"
"testing"
@@ -88,3 +89,67 @@ func (p *recordingProvider) Stream(_ context.Context, req provider.Request) (<-c
close(ch)
return ch, nil
}
// A forced re-compact can fold back into the live projection body — e.g. a
// visible-compression projection (full-freeze body) followed by a manual
// re-compact whose RecentKeep floor reaches past a short canonical tail. Body
// messages past the new boundary have no canonical tail to splice from; they
// must stay verbatim in the new body instead of vanishing from the context.
func TestRefoldIntoBodyKeepsUnfoldedBodyTail(t *testing.T) {
prov := &recordingProvider{reply: "d"}
msgs := []provider.Message{
{Role: provider.RoleSystem, Content: "system"},
{Role: provider.RoleUser, Content: "first task"},
}
for i := range 10 {
msgs = append(msgs, provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("marker turn %d", i)})
}
msgs = append(msgs,
provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("tail wall ", 40)},
provider.Message{Role: provider.RoleUser, Content: "go"},
provider.Message{Role: provider.RoleAssistant, Content: "ok"},
)
sess := &Session{Messages: msgs}
a := New(prov, tool.NewRegistry(), sess, Options{
ContextWindow: 5_000, CompactRatio: 0.5, RecentKeep: 5,
}, event.Discard)
// Prior projection of the visible-compression shape: the body freezes the
// whole view through the kept user turns; only [tail wall, go, ok] splice.
canonical, version := a.sess.conversation.snapshotMessagesVersion()
covered := len(canonical) - 3
body := append([]provider.Message{canonical[0], canonical[1],
formatSummaryMessage(strings.Repeat("prior folded context ", 20))},
canonical[2:covered]...)
a.sess.compactionMu.Lock()
a.sess.compactionState = CompactionState{
SchemaVersion: compactionStateSchemaCurrent, TranscriptVersion: version, Generation: 1,
PromptCacheKey: a.currentPromptCacheKeyLocked(),
Projection: ContextProjection{
Messages: body, TranscriptVersion: version, ProjectionVersion: 1,
CoveredCount: covered, CoveredPrefixHash: coveredPrefixHash(canonical, covered),
},
}
a.sess.compactionMu.Unlock()
if err := a.compact(context.Background(), CompactionTriggerManual, "", true); err != nil {
t.Fatalf("re-compact into body: %v", err)
}
if len(prov.got) == 0 {
t.Fatal("re-compact made no summary request")
}
visible := a.modelVisibleMessages()
for i := range 10 {
want := fmt.Sprintf("marker turn %d", i)
found := false
for _, m := range visible {
if m.Content == want {
found = true
break
}
}
if !found {
t.Fatalf("%q was kept in the projection body but vanished after the re-fold", want)
}
}
}
+40 -14
View File
@@ -75,7 +75,7 @@ func (a *Agent) snapshotExplicitCompression() explicitCompressionSnapshot {
state := a.sess.compactionState
a.sess.compactionMu.Unlock()
visible := canonical
if projectionValid(state, canonical, version, cacheKey) {
if projectionValid(state, canonical, cacheKey) {
if projected := modelVisibleFromProjection(state.Projection, canonical); len(projected) > 0 {
visible = projected
}
@@ -225,6 +225,7 @@ func (a *Agent) compressVisibleRange(
transcriptVersion: snap.transcriptVersion, projectionVersion: snap.projectionVersion, generation: snap.generation,
activeTurn: a.activeTurnCreatedAt.Load(), trigger: trigger, summary: summary,
inputHash: inputHash, outputHash: outputHash, sourceTokens: result.SourceTokens, projectionTokens: projectionTokens,
covered: len(snap.canonical),
})
if err != nil {
if errors.Is(err, errCompressStaleContext) {
@@ -393,7 +394,7 @@ func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions s
startProjectionVersion := a.sess.compactionState.Projection.ProjectionVersion
startGeneration := a.sess.compactionState.Generation
a.sess.compactionMu.Unlock()
msgs := a.visibleInputForFold(stateSnapshot, canonical, transcriptVersion)
msgs, onProjection := a.visibleInputForFold(stateSnapshot, canonical, transcriptVersion)
viewInputHash := providerVisibleFingerprint(provider.ModelMessages(msgs))
if trigger != CompactionTriggerManual && stateSnapshot.LastReceipt != nil && stateSnapshot.LastReceipt.Status == "applied" && stateSnapshot.LastReceipt.Action == "summary" && stateSnapshot.LastReceipt.InputHash == viewInputHash {
return CompactionNoop, nil
@@ -402,6 +403,23 @@ func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions s
if !ok {
return CompactionNoop, nil
}
// start indexes the working view; covered is a canonical index. On a live
// projection the view is frozen body + canonical[prior:], so a boundary
// inside the body covers the prior range and past it maps offset-for-offset.
covered := start
var bodySuffix []provider.Message
if onProjection {
body := len(stateSnapshot.Projection.Messages)
prior := stateSnapshot.Projection.CoveredCount
if start < body {
covered = prior
// The unfolded remainder of the old body stays verbatim in the new
// body; it has no canonical tail to splice from.
bodySuffix = msgs[start:body]
} else {
covered = prior + (start - body)
}
}
kept, fold, retention := a.partitionFoldForProjection(msgs[head:start])
if len(fold) == 0 || (!force && !foldEconomics(fold)) {
return CompactionNoop, nil
@@ -446,8 +464,15 @@ func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions s
return CompactionNoop, err
}
projMsgs := checkpointProjectionMessages(msgs, head, start, kept, summary)
projTokens := a.estimatedPromptTokens(projMsgs)
// The projection body freezes only prefix + digest + kept messages; the
// verbatim tail splices live from canonical[start:] so tail-side rewrites
// (rewind truncation, snips) stay visible without rebuilding the fold.
projMsgs := checkpointProjectionMessages(msgs, head, kept, summary)
if len(bodySuffix) > 0 {
projMsgs = append(projMsgs, provider.ProjectionMessages(bodySuffix)...)
}
spliced := append(append([]provider.Message(nil), projMsgs...), canonical[covered:]...)
projTokens := a.estimatedPromptTokens(spliced)
fixedPrefixTokens = a.estimatedPromptTokens(msgs[:head])
tele.ProjectionTokens = projTokens
tele.UserTurnsKept, tele.UserTurnsDropped = retention.Kept, retention.Dropped
@@ -456,13 +481,13 @@ func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions s
a.emitCompactionAborted(trigger)
return CompactionNoop, err
}
viewOutputHash := providerVisibleFingerprint(provider.ModelMessages(projMsgs))
viewOutputHash := providerVisibleFingerprint(provider.ModelMessages(spliced))
_, err = a.commitSummaryProjection(summaryProjectionCommit{
canonical: canonical, fold: fold, projected: projMsgs, result: res,
transcriptVersion: transcriptVersion, projectionVersion: startProjectionVersion,
generation: startGeneration, activeTurn: activeTurn, trigger: trigger,
summary: summary, inputHash: viewInputHash, outputHash: viewOutputHash,
sourceTokens: sourceTokens, projectionTokens: projTokens,
sourceTokens: sourceTokens, projectionTokens: projTokens, covered: covered,
})
if err != nil {
a.emitCompactionAborted(trigger)
@@ -476,22 +501,23 @@ func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions s
return CompactionInstalled, nil
}
// visibleInputForFold prefers the prior projection + new history over full canonical.
func (a *Agent) visibleInputForFold(state CompactionState, canonical []provider.Message, transcriptVersion uint64) []provider.Message {
if projectionValid(state, canonical, transcriptVersion, a.currentPromptCacheKey()) {
// visibleInputForFold prefers the prior projection + new history over full
// canonical. The second return reports whether the projection was used, so
// fold boundaries can be translated back to canonical indices.
func (a *Agent) visibleInputForFold(state CompactionState, canonical []provider.Message, transcriptVersion uint64) ([]provider.Message, bool) {
if projectionValid(state, canonical, a.currentPromptCacheKey()) {
if projected := modelVisibleFromProjection(state.Projection, canonical); len(projected) > 0 {
return projected
return projected, true
}
}
return canonical
return canonical, false
}
func checkpointProjectionMessages(msgs []provider.Message, head, start int, kept []provider.Message, summary string) []provider.Message {
projMsgs := make([]provider.Message, 0, head+1+len(kept)+len(msgs)-start)
func checkpointProjectionMessages(msgs []provider.Message, head int, kept []provider.Message, summary string) []provider.Message {
projMsgs := make([]provider.Message, 0, head+1+len(kept))
projMsgs = append(projMsgs, msgs[:head]...)
projMsgs = append(projMsgs, formatSummaryMessage(summary))
projMsgs = append(projMsgs, kept...)
projMsgs = append(projMsgs, msgs[start:]...)
return provider.ProjectionMessages(projMsgs)
}
@@ -66,7 +66,7 @@ func prepareContext(ctx context.Context, a *Agent, trigger string) error {
// foldRegionOf is the region the next compaction would hand the summarizer.
func foldRegionOf(a *Agent) []provider.Message {
canonical, version := a.sess.conversation.snapshotMessagesVersion()
msgs := a.visibleInputForFold(a.sess.compactionState, canonical, version)
msgs, _ := a.visibleInputForFold(a.sess.compactionState, canonical, version)
head, start, ok := a.planFoldRegion(msgs, false)
if !ok {
return nil
+79
View File
@@ -0,0 +1,79 @@
package agent
import (
"context"
"fmt"
"strings"
"testing"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// A conversation rewind that only removes tail messages must keep the fold
// usable: the covered prefix is byte-identical, so the projection stays valid
// for the shorter transcript and the model-visible view keeps the compacted
// size instead of ballooning back to the pre-compaction transcript and
// re-paying a full summary.
func TestTailOnlyRewindKeepsCompactedView(t *testing.T) {
const window = 10_000
big := strings.Repeat("line\n", 200)
msgs := []provider.Message{
{Role: provider.RoleSystem, Content: "system"},
{Role: provider.RoleUser, Content: "first task"},
}
for i := range 40 {
id := fmt.Sprintf("old-%d", i)
msgs = append(msgs,
provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: "{}"}}},
provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: big},
)
}
msgs = append(msgs,
provider.Message{Role: provider.RoleUser, Content: "recent question"},
provider.Message{Role: provider.RoleAssistant, Content: "recent answer"},
)
sess := &Session{Messages: msgs}
a := New(&fakeProvider{reply: "structured digest"}, tool.NewRegistry(), sess, Options{
ContextWindow: window,
CompactRatio: 0.80,
RecentKeep: 2,
ArchiveDir: t.TempDir(),
}, event.Discard)
fold := a.compactTrigger()
before := estimateMessagesTokens(provider.ModelMessages(sess.Messages))
if before < fold {
t.Fatalf("fixture estimates %d tokens, below the fold trigger %d", before, fold)
}
if err := a.CompactNow(context.Background(), ""); err != nil {
t.Fatalf("compact: %v", err)
}
if after := projectionTokens(a); after >= fold {
t.Fatalf("compacted view %d tokens still at or above fold %d", after, fold)
}
a.sess.compactionMu.Lock()
saved := a.sess.compactionState
a.sess.compactionMu.Unlock()
canonical, _ := a.sess.conversation.snapshotMessagesVersion()
// Rewind past the final exchange — a tail-only truncation.
boundary := len(canonical) - 2
sess.Rewrite(canonical[:boundary], "rewind_truncate")
truncated, _ := a.sess.conversation.snapshotMessagesVersion()
if !projectionContentValid(saved, truncated) {
t.Fatal("tail-only truncation must keep the projection valid")
}
view := provider.ModelMessages(modelVisibleFromProjection(saved.Projection, truncated))
if after := estimateMessagesTokens(view); after >= fold {
t.Fatalf("spliced view %d tokens ballooned past the fold trigger %d", after, fold)
}
for _, m := range view {
if m.Content == "recent answer" || m.Content == "recent question" {
t.Fatal("rewound message still visible in the spliced view")
}
}
}
+2 -2
View File
@@ -25,13 +25,13 @@ func (a *Agent) ContextMaintenanceSnapshot() ContextMaintenanceSnapshot {
if a == nil || a.sess.conversation == nil {
return ContextMaintenanceSnapshot{}
}
canonical, version := a.sess.conversation.snapshotMessagesVersion()
canonical, _ := a.sess.conversation.snapshotMessagesVersion()
a.sess.compactionMu.Lock()
state := a.sess.compactionState
checkpointState := a.sess.checkpointState
a.sess.compactionMu.Unlock()
visible := canonical
valid := projectionValid(state, canonical, version, a.currentPromptCacheKey())
valid := projectionValid(state, canonical, a.currentPromptCacheKey())
if valid {
if projected := modelVisibleFromProjection(state.Projection, canonical); len(projected) > 0 {
visible = projected
+1 -1
View File
@@ -37,7 +37,7 @@ func TestProjectionValidAcceptsLegacyNativeKey(t *testing.T) {
},
TranscriptVersion: 1,
}
if !projectionValid(st, msgs, 1, "ws|s|m") {
if !projectionValid(st, msgs, "ws|s|m") {
t.Fatal("projectionValid rejected legacy native lineage key")
}
}
+35 -13
View File
@@ -20,11 +20,11 @@ func (a *Agent) modelVisibleMessages() []provider.Message {
if a == nil || a.sess.conversation == nil {
return nil
}
msgs, version := a.sess.conversation.snapshotMessagesVersion()
msgs, _ := a.sess.conversation.snapshotMessagesVersion()
a.sess.compactionMu.Lock()
st := a.sess.compactionState
a.sess.compactionMu.Unlock()
if projectionValid(st, msgs, version, a.currentPromptCacheKey()) {
if projectionValid(st, msgs, a.currentPromptCacheKey()) {
if visible := modelVisibleFromProjection(st.Projection, msgs); len(visible) > 0 {
return visible
}
@@ -75,6 +75,26 @@ func (a *Agent) InvalidateProjection() {
}
}
// InvalidateProjectionIfStale keeps the projection when it still matches the
// current transcript and performs the full invalidation otherwise. History
// rewrites that only touch messages past CoveredCount keep their fold.
func (a *Agent) InvalidateProjectionIfStale() {
if a == nil {
return
}
a.sess.compactionMu.Lock()
st := a.sess.compactionState
if len(st.Projection.Messages) > 0 && a.sess.conversation != nil {
msgs, _ := a.sess.conversation.snapshotMessagesVersion()
if projectionValid(st, msgs, a.currentPromptCacheKeyLocked()) {
a.sess.compactionMu.Unlock()
return
}
}
a.sess.compactionMu.Unlock()
a.InvalidateProjection()
}
// LoadProjectionSidecar loads the context sidecar into the agent. Corrupt or
// incompatible state is dropped so the next request rebuilds from canonical.
// Sidecars whose PromptCacheKey does not match the current agent lineage are
@@ -103,9 +123,14 @@ func (a *Agent) LoadProjectionSidecar(sessionPath string) {
a.resetCompactionState()
return
}
var msgs, preRepair []provider.Message
if a.sess.conversation != nil {
msgs, preRepair = a.sess.conversation.projectionValidationMessages()
}
a.sess.compactionMu.Lock()
key := a.currentPromptCacheKeyLocked()
normalized, keyOK := lineageKeyCompatible(st.PromptCacheKey, key)
needsNormalization := false
// Keep receipt-only blocked/failed sidecars (no projection body) and legacy
// top-level BlockedInputHash so generation-scoped suppressions survive restart.
hasMaintenanceSignal := st.Projection.CoveredPrefixHash != "" ||
@@ -115,12 +140,12 @@ func (a *Agent) LoadProjectionSidecar(sessionPath string) {
if key != "" && !keyOK {
// Lineage key changed (upgrade, model/workspace switch). Rebind when
// the projection body still matches the canonical covered prefix.
var msgs []provider.Message
var version uint64
if a.sess.conversation != nil {
msgs, version = a.sess.conversation.snapshotMessagesVersion()
contentValid := projectionContentValid(st, msgs)
if !contentValid && migrateLegacyCoveredPrefixHash(&st, msgs, preRepair) {
contentValid = true
needsNormalization = true
}
if projectionContentValid(st, msgs, version) {
if contentValid {
normalized, keyOK = key, true
}
}
@@ -131,18 +156,15 @@ func (a *Agent) LoadProjectionSidecar(sessionPath string) {
return
}
// Only rewrite legacy native-editing lineage keys; exact matches stay pure-read.
needsNormalization := false
if keyOK && key != "" && normalized != st.PromptCacheKey {
st.PromptCacheKey = normalized
needsNormalization = true
}
// Only mark restored when the projection still matches the transcript.
var msgs []provider.Message
var version uint64
if a.sess.conversation != nil {
msgs, version = a.sess.conversation.snapshotMessagesVersion()
if !projectionContentValid(st, msgs) && migrateLegacyCoveredPrefixHash(&st, msgs, preRepair) {
needsNormalization = true
}
valid := len(st.Projection.Messages) > 0 && projectionValid(st, msgs, version, key)
valid := len(st.Projection.Messages) > 0 && projectionValid(st, msgs, key)
if !valid && len(st.Projection.Messages) > 0 {
// Keep blocked receipts / telemetry; drop unusable projection body.
st.Projection = ContextProjection{}
+49 -11
View File
@@ -245,16 +245,55 @@ func summaryContentHash(summary string) string {
}
// coveredPrefixHash fingerprints the provider-visible prefix of msgs[:n].
// LocalOnly and transport-only fields are stripped via ModelMessages; every
// remaining provider-visible field is included so edits to images, signed
// reasoning, Responses items, or thought signatures invalidate the projection.
// ModelMessages strips local fields and SanitizeToolPairing applies the same
// deterministic repair used on the wire, keeping hashes stable when LoadSession
// persists an equivalent repair while still detecting real prefix edits.
func coveredPrefixHash(msgs []provider.Message, n int) string {
if n <= 0 || n > len(msgs) {
return ""
}
visible := provider.ModelMessages(msgs[:n])
return providerVisibleFingerprint(provider.SanitizeToolPairing(visible))
}
// legacyCoveredPrefixHash reproduces the v1.25.2 fingerprint. It is used only
// to migrate a sidecar whose persisted pre-repair transcript is still available;
// new checkpoints always use coveredPrefixHash.
func legacyCoveredPrefixHash(msgs []provider.Message, n int) string {
if n <= 0 || n > len(msgs) {
return ""
}
return providerVisibleFingerprint(provider.ModelMessages(msgs[:n]))
}
// migrateLegacyCoveredPrefixHash upgrades a v1.25.2 sidecar after LoadSession
// performed a deterministic provider-visible repair. It is deliberately strict:
// the stored legacy hash must match the exact pre-repair disk prefix, and that
// prefix's wire-safe form must equal the current prefix. A real history or
// system-prompt change therefore remains invalid.
func migrateLegacyCoveredPrefixHash(st *CompactionState, current, preRepair []provider.Message) bool {
if st == nil || len(preRepair) == 0 {
return false
}
n := st.Projection.CoveredCount
stored := st.Projection.CoveredPrefixHash
if stored == "" || legacyCoveredPrefixHash(preRepair, n) != stored {
return false
}
preRepairWireHash := coveredPrefixHash(preRepair, n)
currentHash := coveredPrefixHash(current, n)
if currentHash == "" || preRepairWireHash != currentHash {
return false
}
st.Projection.CoveredPrefixHash = currentHash
if st.LastReceipt != nil && st.LastReceipt.CoveredPrefixHash == stored {
receipt := *st.LastReceipt
receipt.CoveredPrefixHash = currentHash
st.LastReceipt = &receipt
}
return true
}
// providerVisibleFingerprint is the stable hash of fields that reach a provider.
func providerVisibleFingerprint(msgs []provider.Message) string {
type wireCall struct {
@@ -317,7 +356,7 @@ func providerVisibleFingerprint(msgs []provider.Message) string {
// projectionValid reports whether st can be reused for the current transcript
// and provider/model lineage. Fail closed: missing CoveredPrefixHash or a blank
// sidecar PromptCacheKey when the current lineage key is known forces rebuild.
func projectionValid(st CompactionState, msgs []provider.Message, transcriptVersion uint64, cacheKey string) bool {
func projectionValid(st CompactionState, msgs []provider.Message, cacheKey string) bool {
if len(st.Projection.Messages) == 0 {
return false
}
@@ -327,13 +366,13 @@ func projectionValid(st CompactionState, msgs []provider.Message, transcriptVers
return false
}
}
return projectionContentValid(st, msgs, transcriptVersion)
return projectionContentValid(st, msgs)
}
// projectionContentValid reports whether st's projection body still matches the
// canonical transcript, independent of provider/model lineage. LoadProjectionSidecar
// uses it to rebind across upgrade/model/workspace key changes.
func projectionContentValid(st CompactionState, msgs []provider.Message, transcriptVersion uint64) bool {
func projectionContentValid(st CompactionState, msgs []provider.Message) bool {
if len(st.Projection.Messages) == 0 {
return false
}
@@ -348,11 +387,10 @@ func projectionContentValid(st CompactionState, msgs []provider.Message, transcr
if coveredPrefixHash(msgs, n) != st.Projection.CoveredPrefixHash {
return false
}
if st.TranscriptVersion == transcriptVersion || st.Projection.TranscriptVersion == transcriptVersion {
return true
}
// Append-only growth with a verified covered prefix.
return n < len(msgs)
// TranscriptVersion is a process-local CAS generation that resets on load.
// The covered prefix hash is the durable identity across append-only growth
// and exact tail truncation.
return true
}
// modelVisibleFromProjection splices the projection with any messages appended
+155
View File
@@ -0,0 +1,155 @@
package agent
import (
"path/filepath"
"testing"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
func restartProjectionState(path string, msgs []provider.Message, version uint64, covered int) CompactionState {
key := promptCacheKey("workspace", BranchID(path), "provider/model")
return CompactionState{
SchemaVersion: compactionStateSchemaCurrent,
TranscriptVersion: version,
PromptCacheKey: key,
Projection: ContextProjection{
Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "system"},
{Role: provider.RoleUser, Content: "[compacted context]"},
},
TranscriptVersion: version,
ProjectionVersion: 1,
CoveredCount: covered,
CoveredPrefixHash: coveredPrefixHash(msgs, covered),
},
}
}
func reopenProjectionAgent(t *testing.T, path string) *Agent {
t.Helper()
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got := loaded.TranscriptVersion(); got != 0 {
t.Fatalf("loaded transcript version = %d, want process-local reset to 0", got)
}
return New(nil, tool.NewRegistry(), loaded, Options{
SessionPath: path,
WorkspaceID: "workspace",
ModelRef: "provider/model",
}, event.Discard)
}
func TestProjectionRestoresAfterRestartWithResetTranscriptVersion(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
sess := NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "task"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
msgs, version := sess.snapshotMessagesVersion()
if version == 0 {
t.Fatal("fixture must compact at a non-zero transcript version")
}
if err := sess.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot: %v", err)
}
if err := SaveCompactionState(path, restartProjectionState(path, msgs, version, len(msgs))); err != nil {
t.Fatalf("SaveCompactionState: %v", err)
}
reopened := reopenProjectionAgent(t, path)
if got := reopened.ContextMaintenanceSnapshot().CheckpointState; got != "restored" {
t.Fatalf("checkpoint state = %q, want restored", got)
}
if got := len(reopened.modelVisibleMessages()); got != 2 {
t.Fatalf("visible messages = %d, want compacted projection", got)
}
}
func TestProjectionRestoresAfterRestartNormalization(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
sess := NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "inspect"})
sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
ID: "call-1", Arguments: `{"path":"main.go"}`,
}}})
sess.Add(provider.Message{
Role: provider.RoleTool, ToolCallID: "call-1", Name: "read_file", Content: "package main",
})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "first result"})
covered, version := sess.snapshotMessagesVersion()
if version == 0 {
t.Fatal("fixture must compact at a non-zero transcript version")
}
state := restartProjectionState(path, covered, version, len(covered))
state.Projection.CoveredPrefixHash = legacyCoveredPrefixHash(covered, len(covered))
// The covered prefix contains an empty tool-call name that LoadSession repairs
// from its result. Projection identity must normalize it on both sides of the
// restart even when the live transcript grew after compaction.
sess.Add(provider.Message{Role: provider.RoleUser, Content: "continue"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "second result"})
if err := sess.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot: %v", err)
}
if err := SaveCompactionState(path, state); err != nil {
t.Fatalf("SaveCompactionState: %v", err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got := loaded.Snapshot()[2].ToolCalls[0].Name; got != "read_file" {
t.Fatalf("load-time tool-call normalization = %q, want read_file", got)
}
// Desktop resume wraps the loaded session to refresh the system prompt.
// The exact pre-repair disk view must survive that wrapper for safe sidecar
// migration.
loaded = loaded.CloneWithMessages(loaded.Snapshot())
reopened := New(nil, tool.NewRegistry(), loaded, Options{
SessionPath: path,
WorkspaceID: "workspace",
ModelRef: "provider/model",
}, event.Discard)
if got := reopened.ContextMaintenanceSnapshot().CheckpointState; got != "restored" {
t.Fatalf("checkpoint state = %q, want restored", got)
}
if got := len(reopened.modelVisibleMessages()); got != 4 {
t.Fatalf("visible messages = %d, want projection plus two-message live tail", got)
}
persisted, ok, err := LoadCompactionState(path)
if err != nil || !ok {
t.Fatalf("LoadCompactionState after migration: ok=%v err=%v", ok, err)
}
if got, want := persisted.Projection.CoveredPrefixHash, coveredPrefixHash(loaded.Snapshot(), len(covered)); got != want {
t.Fatalf("migrated covered prefix hash = %q, want %q", got, want)
}
}
func TestLegacyProjectionHashMigrationRejectsRealPrefixChange(t *testing.T) {
preRepair := []provider.Message{
{Role: provider.RoleSystem, Content: "system-v1"},
{Role: provider.RoleUser, Content: "inspect"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-1", Arguments: `{}`}}},
{Role: provider.RoleTool, ToolCallID: "call-1", Name: "read_file", Content: "result"},
}
current := NormalizeSession(preRepair)
current = append([]provider.Message(nil), current...)
current[0].Content = "system-v2"
state := CompactionState{Projection: ContextProjection{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "summary"}},
CoveredCount: len(preRepair),
CoveredPrefixHash: legacyCoveredPrefixHash(preRepair, len(preRepair)),
}}
if migrateLegacyCoveredPrefixHash(&state, current, preRepair) {
t.Fatal("legacy hash migration accepted a changed provider-visible system prompt")
}
if projectionContentValid(state, current) {
t.Fatal("changed prefix unexpectedly validated the legacy projection")
}
}
+2 -1
View File
@@ -360,7 +360,8 @@ func visibleContext(a *Agent) []provider.Message {
return nil
}
if msgs := a.sess.compactionState.Projection.Messages; len(msgs) > 0 {
return msgs
canonical, _ := a.sess.conversation.snapshotMessagesVersion()
return modelVisibleFromProjection(a.sess.compactionState.Projection, canonical)
}
if a.sess.conversation != nil {
return a.sess.conversation.Snapshot()
+9 -9
View File
@@ -33,18 +33,18 @@ func TestProjectionValidRejectsEditedPrefix(t *testing.T) {
CoveredPrefixHash: coveredPrefixHash(msgs, 3),
},
}
if !projectionValid(st, msgs, 2, "ws|sess|model") {
if !projectionValid(st, msgs, "ws|sess|model") {
t.Fatal("expected valid projection for matching prefix")
}
// Append-only growth still valid.
grown := append(append([]provider.Message(nil), msgs...), provider.Message{Role: provider.RoleAssistant, Content: "more"})
if !projectionValid(st, grown, 3, "ws|sess|model") {
if !projectionValid(st, grown, "ws|sess|model") {
t.Fatal("append-only growth should keep projection valid")
}
// Prefix edit invalidates.
edited := append([]provider.Message(nil), msgs...)
edited[1].Content = "task-EDITED"
if projectionValid(st, edited, 4, "ws|sess|model") {
if projectionValid(st, edited, "ws|sess|model") {
t.Fatal("edited covered prefix must invalidate projection")
}
}
@@ -65,21 +65,21 @@ func TestProjectionValidRejectsCacheKeyMismatch(t *testing.T) {
TranscriptVersion: 1,
},
}
if projectionValid(st, msgs, 1, "ws|sess|model-b") {
if projectionValid(st, msgs, "ws|sess|model-b") {
t.Fatal("model/lineage key mismatch must invalidate projection")
}
if !projectionValid(st, msgs, 1, "ws|sess|model-a") {
if !projectionValid(st, msgs, "ws|sess|model-a") {
t.Fatal("matching key should be valid")
}
// Fail closed: blank stored key is rejected when current key is known.
st.PromptCacheKey = ""
if projectionValid(st, msgs, 1, "ws|sess|model-a") {
if projectionValid(st, msgs, "ws|sess|model-a") {
t.Fatal("missing sidecar cache key must invalidate when lineage is known")
}
// Missing prefix hash is always rejected.
st.PromptCacheKey = "ws|sess|model-a"
st.Projection.CoveredPrefixHash = ""
if projectionValid(st, msgs, 1, "ws|sess|model-a") {
if projectionValid(st, msgs, "ws|sess|model-a") {
t.Fatal("missing CoveredPrefixHash must invalidate projection")
}
}
@@ -315,8 +315,8 @@ func TestCompactInstallsCoveredPrefixHash(t *testing.T) {
if st.PromptCacheKey != promptCacheKey("ws", BranchID(path), "m") {
t.Fatalf("PromptCacheKey = %q", st.PromptCacheKey)
}
msgs, ver := sess.snapshotMessagesVersion()
if !projectionValid(st, msgs, ver, st.PromptCacheKey) {
msgs, _ := sess.snapshotMessagesVersion()
if !projectionValid(st, msgs, st.PromptCacheKey) {
t.Fatal("fresh projection should validate")
}
}
+30 -13
View File
@@ -40,7 +40,9 @@ func (s *Session) writeRecoveryBranchAtPath(
return RecoveryBranchInfo{}, false, digestErr
}
if bytes.Equal(existingDigest[:], digest[:]) {
s.inheritParentProjection(opts.OriginalPath, path, msgs, version)
if _, err := copyValidContextProjection(opts.OriginalPath, path, msgs); err != nil {
slog.Warn("session: recovery branch did not inherit context projection", "path", path, "err", err)
}
meta, err := s.saveRecoveryBranchMeta(path, opts, preview, turns, digestText, recoveryDepth)
if err != nil {
return RecoveryBranchInfo{}, false, err
@@ -71,7 +73,9 @@ func (s *Session) writeRecoveryBranchAtPath(
if err := writeSessionMessages(path, msgs); err != nil {
return RecoveryBranchInfo{}, false, err
}
s.inheritParentProjection(opts.OriginalPath, path, msgs, version)
if _, err := copyValidContextProjection(opts.OriginalPath, path, msgs); err != nil {
slog.Warn("session: recovery branch did not inherit context projection", "path", path, "err", err)
}
meta, err := s.saveRecoveryBranchMeta(path, opts, preview, turns, digestText, recoveryDepth)
if err != nil {
return RecoveryBranchInfo{}, false, err
@@ -83,26 +87,39 @@ func (s *Session) writeRecoveryBranchAtPath(
return RecoveryBranchInfo{Path: path, Digest: digestText, Meta: meta, Preview: preview, Turns: turns}, false, nil
}
// inheritParentProjection copies a valid context projection from the parent
// session onto the recovery fork and fails closed on content mismatch.
func (s *Session) inheritParentProjection(originalPath, recoveryPath string, msgs []provider.Message, version uint64) {
if _, ok, err := LoadCompactionState(recoveryPath); err == nil && ok {
return
// CopyValidContextProjection copies a parent's projection to this session's
// path only when the covered canonical prefix still matches. Forks use it to
// preserve compacted context without borrowing stale parent history.
func (s *Session) CopyValidContextProjection(originalPath, targetPath string) (bool, error) {
if s == nil {
return false, nil
}
return copyValidContextProjection(originalPath, targetPath, s.Snapshot())
}
func copyValidContextProjection(originalPath, targetPath string, msgs []provider.Message) (bool, error) {
if _, ok, err := LoadCompactionState(targetPath); err != nil {
return false, err
} else if ok {
return false, nil
}
st, ok, err := LoadCompactionState(originalPath)
if err != nil || !ok {
return
if err != nil {
return false, err
}
if !ok {
return false, nil
}
n := st.Projection.CoveredCount
if len(st.Projection.Messages) == 0 || n <= 0 || n > len(msgs) ||
st.Projection.CoveredPrefixHash == "" ||
coveredPrefixHash(msgs, n) != st.Projection.CoveredPrefixHash {
return
return false, nil
}
if err := SaveCompactionState(recoveryPath, st); err != nil {
slog.Warn("session: recovery branch did not inherit context projection",
"path", recoveryPath, "err", err)
if err := SaveCompactionState(targetPath, st); err != nil {
return false, err
}
return true, nil
}
// healEmptyCheckpointFromWAL rebuilds a missing or 0-byte checkpoint from its
+19
View File
@@ -371,6 +371,7 @@ func (s *Session) CloneWithMessages(msgs []provider.Message) *Session {
persisted: s.persisted,
normalizedDirty: s.normalizedDirty,
eventLogDamaged: s.eventLogDamaged,
rawMessages: append([]provider.Message(nil), s.rawMessages...),
pendingContentReasons: append([]string(nil), s.pendingContentReasons...),
}
}
@@ -400,10 +401,28 @@ func (s *Session) CloneWithMessagesIfCompatible(msgs []provider.Message) (*Sessi
persisted: s.persisted,
normalizedDirty: s.normalizedDirty,
eventLogDamaged: s.eventLogDamaged,
rawMessages: append([]provider.Message(nil), s.rawMessages...),
pendingContentReasons: append([]string(nil), s.pendingContentReasons...),
}, true
}
// projectionValidationMessages returns the current canonical transcript and,
// when LoadSession repaired it, the exact pre-repair disk view. Resume wrappers
// preserve both so projection sidecars can be migrated without weakening the
// covered-prefix check.
func (s *Session) projectionValidationMessages() (current, preRepair []provider.Message) {
if s == nil {
return nil, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
current = append([]provider.Message(nil), s.Messages...)
if s.normalizedDirty && len(s.rawMessages) > 0 {
preRepair = append([]provider.Message(nil), s.rawMessages...)
}
return current, preRepair
}
// snapshotWithVersion returns the messages together with the version and
// rewriteVersion they were captured under, in one lock window: save paths
// persist exactly this rewriteVersion as the new baseline, so a rewrite that
+10 -4
View File
@@ -3273,6 +3273,9 @@ func (c *Controller) forkNamedReady(turn int, name string, switchToFork bool) (s
if err := sess.SaveIfAbsent(newPath); err != nil {
return "", c.rewindFail(err)
}
if _, err := sess.CopyValidContextProjection(parentPath, newPath); err != nil {
slog.Warn("controller: fork did not inherit context projection", "err", err)
}
forkPreview, forkTurns := agent.SessionPreviewFromMessages(forked)
if err := agent.SaveBranchMeta(newPath, agent.BranchMeta{
Name: strings.TrimSpace(name),
@@ -3296,9 +3299,9 @@ func (c *Controller) forkNamedReady(turn int, name string, switchToFork bool) (s
c.sessionPath = newPath
c.guardianPath = guardian.PathFor(newPath)
c.mu.Unlock()
// New lineage: rebind sidecar path and clear any in-memory projection
// without deleting the parent session's .context.json.
c.bindExecutorProjection(newPath, false)
// Load the child sidecar when the covered prefix survived the fork. The
// loader rebinds its lineage key without touching the parent's sidecar.
c.bindExecutorProjection(newPath, true)
c.ResetPlannerSession()
c.setActiveJobSession(newPath)
c.rebindCheckpoints(newPath)
@@ -3364,6 +3367,9 @@ func (c *Controller) Branch(name string) (string, error) {
if err := sess.SaveIfAbsent(newPath); err != nil {
return "", c.rewindFail(err)
}
if _, err := sess.CopyValidContextProjection(parentPath, newPath); err != nil {
slog.Warn("controller: branch did not inherit context projection", "err", err)
}
branchPreview, branchTurns := agent.SessionPreviewFromMessages(branched)
if err := agent.SaveBranchMeta(newPath, agent.BranchMeta{
Name: strings.TrimSpace(name),
@@ -3386,7 +3392,7 @@ func (c *Controller) Branch(name string) (string, error) {
c.sessionPath = newPath
c.guardianPath = guardian.PathFor(newPath)
c.mu.Unlock()
c.bindExecutorProjection(newPath, false)
c.bindExecutorProjection(newPath, true)
c.ResetPlannerSession()
c.setActiveJobSession(newPath)
c.rebindCheckpoints(newPath)
+27 -1
View File
@@ -1,11 +1,15 @@
package control
import (
"context"
"fmt"
"strings"
"testing"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
func TestNewSessionRebindsProjectionSidecarPath(t *testing.T) {
@@ -112,15 +116,30 @@ func TestBranchRebindsProjectionSidecarPath(t *testing.T) {
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "task"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
for i := range 30 {
id := fmt.Sprintf("bulk-%d", i)
sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: "{}"}}})
sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: strings.Repeat("line\n", 200)})
}
if err := sess.Save(path); err != nil {
t.Fatal(err)
}
if _, err := agent.EnsureBranchMeta(path); err != nil {
t.Fatal(err)
}
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{SessionPath: path, ContextWindow: 1000}, event.Discard)
prov := &scriptedTurns{turns: [][]provider.Chunk{textTurn("summary")}}
exec := agent.New(prov, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{
SessionPath: path, ContextWindow: 10_000, CompactRatio: 0.8, RecentKeep: 2,
}, event.Discard)
c := New(Options{Executor: exec, SessionDir: dir, Label: "test", DisableColdResumePrune: true})
c.Resume(sess, path)
if err := exec.CompactNow(context.Background(), ""); err != nil {
t.Fatalf("CompactNow: %v", err)
}
before := exec.ContextMaintenanceSnapshot()
if before.ProjectionVersion == 0 {
t.Fatal("branch fixture installed no projection")
}
newPath, err := c.Branch("side")
if err != nil {
@@ -135,4 +154,11 @@ func TestBranchRebindsProjectionSidecarPath(t *testing.T) {
if got := c.SessionPath(); got != newPath {
t.Fatalf("controller SessionPath after Branch = %q, want %q", got, newPath)
}
after := exec.ContextMaintenanceSnapshot()
if after.ProjectionVersion != before.ProjectionVersion {
t.Fatalf("branch projection version %d -> %d", before.ProjectionVersion, after.ProjectionVersion)
}
if _, ok, err := agent.LoadCompactionState(newPath); err != nil || !ok {
t.Fatalf("branch projection sidecar: ok=%v err=%v", ok, err)
}
}
+4 -3
View File
@@ -49,8 +49,9 @@ func (a conversationApplier) ApplyConversationTruncate(boundary int, forward []b
}
}
s.Rewrite(msgs[:boundary], "rewind_truncate")
// Lineage changed: drop any projection built for the longer history.
c.executor.InvalidateProjection()
// Drop the projection only when the truncation reached into the folded
// prefix; a tail-only rewind keeps the covered prefix byte-identical.
c.executor.InvalidateProjectionIfStale()
if err := c.SnapshotRewrite(); err != nil {
_ = a.RestoreConversation(forward)
return fmt.Errorf("persist conversation after rewind: %w", err)
@@ -68,7 +69,7 @@ func (a conversationApplier) RestoreConversation(forward []byte) error {
return err
}
c.executor.Session().Rewrite(msgs, "rewind_restore")
c.executor.InvalidateProjection()
c.executor.InvalidateProjectionIfStale()
if err := c.SnapshotRewrite(); err != nil {
return fmt.Errorf("restore conversation: %w", err)
}
+63
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
@@ -348,6 +349,68 @@ func TestPositionalCompressionPreservesCheckpointLineage(t *testing.T) {
}
}
// TestTailRewindKeepsCompactionProjection covers the desktop edit flow at its
// final boundary: edit = conversation rewind + resubmit. A rewind whose
// boundary lands in the live tail (past the fold start) must keep the
// compaction projection instead of ballooning the context back to the
// pre-compaction transcript and re-paying a full summary.
func TestTailRewindKeepsCompactionProjection(t *testing.T) {
dir := t.TempDir()
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("first answer"),
textTurn("second answer"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{
ContextWindow: 10_000, CompactRatio: 0.80, RecentKeep: 2,
}, event.Discard)
c := New(Options{
Runner: ag,
Executor: ag,
SessionDir: dir,
Label: "test",
Sink: event.Discard,
})
c.SetSessionPath(agent.NewSessionPath(dir, "test"))
ctx := context.Background()
if err := c.runTurnWithRaw(ctx, "first prompt", "first prompt"); err != nil {
t.Fatalf("turn 1: %v", err)
}
big := strings.Repeat("line\n", 200)
sess := ag.Session()
for i := range 40 {
id := fmt.Sprintf("bulk-%d", i)
sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: "{}"}}})
sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: big})
}
if err := c.runTurnWithRaw(ctx, "second prompt", "second prompt"); err != nil {
t.Fatalf("turn 2: %v", err)
}
if err := ag.CompactNow(ctx, ""); err != nil {
t.Fatalf("compact: %v", err)
}
before := ag.ContextMaintenanceSnapshot()
if before.ProjectionVersion == 0 || before.ProjectedTokens >= before.FoldTrigger {
t.Fatalf("pre-rewind snapshot = %+v, want an installed projection under the fold trigger", before)
}
c.checkpoints.mu.Lock()
lastTurn := c.checkpoints.turn - 1
c.checkpoints.mu.Unlock()
if err := c.Rewind(lastTurn, RewindConversation); err != nil {
t.Fatalf("tail rewind: %v", err)
}
after := ag.ContextMaintenanceSnapshot()
if after.ProjectionVersion != before.ProjectionVersion {
t.Fatalf("projection version %d -> %d, want the fold kept across a tail-only rewind",
before.ProjectionVersion, after.ProjectionVersion)
}
if after.ProjectedTokens >= after.FoldTrigger {
t.Fatalf("post-rewind view %d tokens at or above fold %d, want the compacted size kept",
after.ProjectedTokens, after.FoldTrigger)
}
}
func TestEditPromptPersistsOriginalPrompt(t *testing.T) {
c, ag, _ := runTwoTurns(t)