| 1 | package session |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "path/filepath" |
| 7 | "reflect" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "reasonix/internal/agent" |
| 12 | "reasonix/internal/agent/testutil" |
| 13 | "reasonix/internal/event" |
| 14 | "reasonix/internal/provider" |
| 15 | "reasonix/internal/tool" |
| 16 | ) |
| 17 | |
| 18 | func legacyCompactionFixture(t *testing.T) (string, []provider.Message, []provider.Message) { |
| 19 | t.Helper() |
| 20 | path := filepath.Join(t.TempDir(), "sessions", "legacy.jsonl") |
| 21 | s := agent.NewSession("system") |
| 22 | for i := range 24 { |
| 23 | s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("task %d", i)}) |
| 24 | s.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("x", 2_000)}) |
| 25 | } |
| 26 | s.Add(provider.Message{Role: provider.RoleUser, Content: "continue"}) |
| 27 | s.Add(provider.Message{Role: provider.RoleAssistant, Content: "ready"}) |
| 28 | if err := s.Save(path); err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | exec := agent.New(testutil.NewMock("migration", testutil.Turn{Text: "durable summary"}), tool.NewRegistry(), s, agent.Options{ |
| 32 | SessionPath: path, ContextWindow: 10_000, CompactRatio: .8, |
| 33 | }, event.Discard) |
| 34 | if err := exec.CompactNow(t.Context(), ""); err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | return path, s.Snapshot(), provider.ModelMessages(exec.ModelHistorySnapshot()) |
| 38 | } |
| 39 | |
| 40 | func migratedProjection(t *testing.T, dir string) Projection { |
| 41 | t.Helper() |
| 42 | commits, err := Replay(dir, nil) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | projection, err := Project(commits) |
| 47 | if err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | return projection |
| 51 | } |
| 52 | |
| 53 | func TestMigrateLegacyPreservesValidContextProjection(t *testing.T) { |
| 54 | path, canonical, compacted := legacyCompactionFixture(t) |
| 55 | result, err := MigrateLegacy(t.Context(), path, filepath.Join(t.TempDir(), "sessions-v5")) |
| 56 | if err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | projection := migratedProjection(t, result.TargetDir) |
| 60 | if !reflect.DeepEqual(projection.Messages, canonical) { |
| 61 | t.Fatal("migration changed canonical history") |
| 62 | } |
| 63 | if !reflect.DeepEqual(projection.ModelMessages, compacted) { |
| 64 | t.Fatal("migration discarded the valid compacted model projection") |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | func TestMigrateLegacyIgnoresInvalidProjectionWithDiagnostic(t *testing.T) { |
| 69 | path, canonical, _ := legacyCompactionFixture(t) |
| 70 | state, ok, err := agent.LoadCompactionState(path) |
| 71 | if err != nil || !ok { |
| 72 | t.Fatalf("load sidecar: ok=%v err=%v", ok, err) |
| 73 | } |
| 74 | state.Projection.CoveredPrefixHash = "does-not-match-canonical-history" |
| 75 | if err := agent.SaveCompactionState(path, state); err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | result, err := MigrateLegacy(t.Context(), path, filepath.Join(t.TempDir(), "sessions-v5")) |
| 79 | if err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | projection := migratedProjection(t, result.TargetDir) |
| 83 | if !reflect.DeepEqual(projection.ModelMessages, provider.ModelMessages(canonical)) { |
| 84 | t.Fatal("invalid projection replaced the canonical model history") |
| 85 | } |
| 86 | found := false |
| 87 | if err := VisitCommits(t.Context(), result.TargetDir, func(commit Commit) error { |
| 88 | for _, item := range commit.Events { |
| 89 | if item.Kind != "diagnostic" { |
| 90 | continue |
| 91 | } |
| 92 | var payload map[string]string |
| 93 | if err := json.Unmarshal(item.Payload, &payload); err != nil { |
| 94 | return err |
| 95 | } |
| 96 | found = found || payload["code"] == "legacy_context_projection_ignored" |
| 97 | } |
| 98 | return nil |
| 99 | }); err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | if !found { |
| 103 | t.Fatal("invalid legacy projection did not leave a diagnostic") |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func TestMigrateLegacyRepairsOnlyPristinePublishedTarget(t *testing.T) { |
| 108 | for _, advanced := range []bool{false, true} { |
| 109 | t.Run(fmt.Sprintf("advanced=%v", advanced), func(t *testing.T) { |
| 110 | path, canonical, compacted := legacyCompactionFixture(t) |
| 111 | targetRoot := filepath.Join(t.TempDir(), "sessions-v5") |
| 112 | oldImporter, err := freezeLegacyHead(t.Context(), path, "", false) |
| 113 | if err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | oldImporter.modelMessages = nil |
| 117 | first, err := oldImporter.publish(t.Context(), targetRoot, CreateOptions{}) |
| 118 | if err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | if advanced { |
| 122 | target, err := OpenWithOptions(first.TargetDir, first.TargetID, OpenOptions{ExternalHistory: true}) |
| 123 | if err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | raw, _ := json.Marshal(map[string]any{"message": provider.Message{ID: "new-work", Role: provider.RoleUser, Content: "new work"}}) |
| 127 | if _, err := target.Append(t.Context(), Batch{OperationID: "new-work", Events: []Event{{Kind: "message/complete", Payload: raw}}}); err != nil { |
| 128 | t.Fatal(err) |
| 129 | } |
| 130 | if _, err := target.Flush(t.Context()); err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | if err := target.Close(t.Context()); err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | currentImporter, err := freezeLegacyHead(t.Context(), path, "", false) |
| 139 | if err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | second, err := currentImporter.publish(t.Context(), targetRoot, CreateOptions{}) |
| 143 | if err != nil { |
| 144 | t.Fatal(err) |
| 145 | } |
| 146 | if !second.Reused { |
| 147 | t.Fatal("existing deterministic target was not reused") |
| 148 | } |
| 149 | projection := migratedProjection(t, second.TargetDir) |
| 150 | if advanced { |
| 151 | if reflect.DeepEqual(projection.ModelMessages, compacted) || len(projection.Messages) != len(canonical)+1 { |
| 152 | t.Fatal("repair overwrote a target that already contained new work") |
| 153 | } |
| 154 | return |
| 155 | } |
| 156 | if !reflect.DeepEqual(projection.ModelMessages, compacted) { |
| 157 | t.Fatal("pristine legacy target was not repaired with its valid projection") |
| 158 | } |
| 159 | }) |
| 160 | } |
| 161 | } |
| 162 |