mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
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:
1 parent
01cdd70dbc
commit
f60ab17b0a
18 files changed
+597
-76
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user