| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "path/filepath" |
| 5 | "testing" |
| 6 | |
| 7 | "reasonix/internal/event" |
| 8 | "reasonix/internal/provider" |
| 9 | "reasonix/internal/tool" |
| 10 | ) |
| 11 | |
| 12 | func restartProjectionState(path string, msgs []provider.Message, version uint64, covered int) CompactionState { |
| 13 | key := promptCacheKey("workspace", BranchID(path), "provider/model") |
| 14 | return CompactionState{ |
| 15 | SchemaVersion: compactionStateSchemaCurrent, |
| 16 | TranscriptVersion: version, |
| 17 | PromptCacheKey: key, |
| 18 | Projection: ContextProjection{ |
| 19 | Messages: []provider.Message{ |
| 20 | {Role: provider.RoleSystem, Content: "system"}, |
| 21 | {Role: provider.RoleUser, Content: "[compacted context]"}, |
| 22 | }, |
| 23 | TranscriptVersion: version, |
| 24 | ProjectionVersion: 1, |
| 25 | CoveredCount: covered, |
| 26 | CoveredPrefixHash: coveredPrefixHash(msgs, covered), |
| 27 | }, |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | func reopenProjectionAgent(t *testing.T, path string) *Agent { |
| 32 | t.Helper() |
| 33 | loaded, err := LoadSession(path) |
| 34 | if err != nil { |
| 35 | t.Fatalf("LoadSession: %v", err) |
| 36 | } |
| 37 | if got := loaded.TranscriptVersion(); got != 0 { |
| 38 | t.Fatalf("loaded transcript version = %d, want process-local reset to 0", got) |
| 39 | } |
| 40 | return New(nil, tool.NewRegistry(), loaded, Options{ |
| 41 | SessionPath: path, |
| 42 | WorkspaceID: "workspace", |
| 43 | ModelRef: "provider/model", |
| 44 | }, event.Discard) |
| 45 | } |
| 46 | |
| 47 | func TestProjectionRestoresAfterRestartWithResetTranscriptVersion(t *testing.T) { |
| 48 | path := filepath.Join(t.TempDir(), "session.jsonl") |
| 49 | sess := NewSession("system") |
| 50 | sess.Add(provider.Message{Role: provider.RoleUser, Content: "task"}) |
| 51 | sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"}) |
| 52 | msgs, version := sess.snapshotMessagesVersion() |
| 53 | if version == 0 { |
| 54 | t.Fatal("fixture must compact at a non-zero transcript version") |
| 55 | } |
| 56 | if err := sess.SaveSnapshot(path); err != nil { |
| 57 | t.Fatalf("SaveSnapshot: %v", err) |
| 58 | } |
| 59 | if err := SaveCompactionState(path, restartProjectionState(path, msgs, version, len(msgs))); err != nil { |
| 60 | t.Fatalf("SaveCompactionState: %v", err) |
| 61 | } |
| 62 | |
| 63 | reopened := reopenProjectionAgent(t, path) |
| 64 | if got := reopened.ContextMaintenanceSnapshot().CheckpointState; got != "restored" { |
| 65 | t.Fatalf("checkpoint state = %q, want restored", got) |
| 66 | } |
| 67 | if got := len(reopened.modelVisibleMessages()); got != 2 { |
| 68 | t.Fatalf("visible messages = %d, want compacted projection", got) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func TestProjectionRestoresAfterRestartNormalization(t *testing.T) { |
| 73 | path := filepath.Join(t.TempDir(), "session.jsonl") |
| 74 | sess := NewSession("system") |
| 75 | sess.Add(provider.Message{Role: provider.RoleUser, Content: "inspect"}) |
| 76 | sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ |
| 77 | ID: "call-1", Arguments: `{"path":"main.go"}`, |
| 78 | }}}) |
| 79 | sess.Add(provider.Message{ |
| 80 | Role: provider.RoleTool, ToolCallID: "call-1", Name: "read_file", Content: "package main", |
| 81 | }) |
| 82 | sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "first result"}) |
| 83 | covered, version := sess.snapshotMessagesVersion() |
| 84 | if version == 0 { |
| 85 | t.Fatal("fixture must compact at a non-zero transcript version") |
| 86 | } |
| 87 | state := restartProjectionState(path, covered, version, len(covered)) |
| 88 | state.Projection.CoveredPrefixHash = legacyCoveredPrefixHash(covered, len(covered)) |
| 89 | |
| 90 | // The covered prefix contains an empty tool-call name that LoadSession repairs |
| 91 | // from its result. Projection identity must normalize it on both sides of the |
| 92 | // restart even when the live transcript grew after compaction. |
| 93 | sess.Add(provider.Message{Role: provider.RoleUser, Content: "continue"}) |
| 94 | sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "second result"}) |
| 95 | if err := sess.SaveSnapshot(path); err != nil { |
| 96 | t.Fatalf("SaveSnapshot: %v", err) |
| 97 | } |
| 98 | if err := SaveCompactionState(path, state); err != nil { |
| 99 | t.Fatalf("SaveCompactionState: %v", err) |
| 100 | } |
| 101 | |
| 102 | loaded, err := LoadSession(path) |
| 103 | if err != nil { |
| 104 | t.Fatalf("LoadSession: %v", err) |
| 105 | } |
| 106 | if got := loaded.Snapshot()[2].ToolCalls[0].Name; got != "read_file" { |
| 107 | t.Fatalf("load-time tool-call normalization = %q, want read_file", got) |
| 108 | } |
| 109 | // Desktop resume wraps the loaded session to refresh the system prompt. |
| 110 | // The exact pre-repair disk view must survive that wrapper for safe sidecar |
| 111 | // migration. |
| 112 | loaded = loaded.CloneWithMessages(loaded.Snapshot()) |
| 113 | reopened := New(nil, tool.NewRegistry(), loaded, Options{ |
| 114 | SessionPath: path, |
| 115 | WorkspaceID: "workspace", |
| 116 | ModelRef: "provider/model", |
| 117 | }, event.Discard) |
| 118 | if got := reopened.ContextMaintenanceSnapshot().CheckpointState; got != "restored" { |
| 119 | t.Fatalf("checkpoint state = %q, want restored", got) |
| 120 | } |
| 121 | if got := len(reopened.modelVisibleMessages()); got != 4 { |
| 122 | t.Fatalf("visible messages = %d, want projection plus two-message live tail", got) |
| 123 | } |
| 124 | persisted, ok, err := LoadCompactionState(path) |
| 125 | if err != nil || !ok { |
| 126 | t.Fatalf("LoadCompactionState after migration: ok=%v err=%v", ok, err) |
| 127 | } |
| 128 | if got, want := persisted.Projection.CoveredPrefixHash, coveredPrefixHash(loaded.Snapshot(), len(covered)); got != want { |
| 129 | t.Fatalf("migrated covered prefix hash = %q, want %q", got, want) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | func TestLegacyProjectionHashMigrationRejectsRealPrefixChange(t *testing.T) { |
| 134 | preRepair := []provider.Message{ |
| 135 | {Role: provider.RoleSystem, Content: "system-v1"}, |
| 136 | {Role: provider.RoleUser, Content: "inspect"}, |
| 137 | {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-1", Arguments: `{}`}}}, |
| 138 | {Role: provider.RoleTool, ToolCallID: "call-1", Name: "read_file", Content: "result"}, |
| 139 | } |
| 140 | current := NormalizeSession(preRepair) |
| 141 | current = append([]provider.Message(nil), current...) |
| 142 | current[0].Content = "system-v2" |
| 143 | state := CompactionState{Projection: ContextProjection{ |
| 144 | Messages: []provider.Message{{Role: provider.RoleUser, Content: "summary"}}, |
| 145 | CoveredCount: len(preRepair), |
| 146 | CoveredPrefixHash: legacyCoveredPrefixHash(preRepair, len(preRepair)), |
| 147 | }} |
| 148 | |
| 149 | if migrateLegacyCoveredPrefixHash(&state, current, preRepair) { |
| 150 | t.Fatal("legacy hash migration accepted a changed provider-visible system prompt") |
| 151 | } |
| 152 | if projectionContentValid(state, current) { |
| 153 | t.Fatal("changed prefix unexpectedly validated the legacy projection") |
| 154 | } |
| 155 | } |
| 156 |