| 1 | package sessioncatalog |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "reasonix/internal/agent" |
| 12 | "reasonix/internal/provider" |
| 13 | ) |
| 14 | |
| 15 | func saveLineageSession(t *testing.T, path string, messages ...string) { |
| 16 | t.Helper() |
| 17 | s := agent.NewSession("sys") |
| 18 | for i, message := range messages { |
| 19 | role := provider.RoleUser |
| 20 | if i%2 == 1 { |
| 21 | role = provider.RoleAssistant |
| 22 | } |
| 23 | s.Add(provider.Message{Role: role, Content: message}) |
| 24 | } |
| 25 | if err := s.Save(path); err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | func TestClassifyRecoveryLineageUsesParentDirectory(t *testing.T) { |
| 31 | dir := t.TempDir() |
| 32 | root := filepath.Join(dir, "root.jsonl") |
| 33 | saveLineageSession(t, root, "q", "a", "continued", "done") |
| 34 | branchSession := agent.NewSession("sys") |
| 35 | branchSession.Add(provider.Message{Role: provider.RoleUser, Content: "q"}) |
| 36 | branchSession.Add(provider.Message{Role: provider.RoleAssistant, Content: "a"}) |
| 37 | info, err := branchSession.SaveConflictRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: root}) |
| 38 | if err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | covered := classifyRecoveryLineage(SessionRecord{ |
| 42 | Path: info.Path, Recovered: true, ParentID: agent.BranchID(root), |
| 43 | }) |
| 44 | if covered.RecoveryRole != RecoveryRoleCoveredCopy || !covered.RecoveryCopy { |
| 45 | t.Fatalf("covered = %+v", covered) |
| 46 | } |
| 47 | normal := classifyRecoveryLineage(SessionRecord{Path: root}) |
| 48 | if normal.RecoveryRole != RecoveryRoleNormal { |
| 49 | t.Fatalf("normal role = %q", normal.RecoveryRole) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func TestPromoteCanonicalLeavesRequiresContentCoverage(t *testing.T) { |
| 54 | dir := t.TempDir() |
| 55 | root := filepath.Join(dir, "root.jsonl") |
| 56 | leaf := filepath.Join(dir, "leaf.jsonl") |
| 57 | saveLineageSession(t, root, "q", "a") |
| 58 | saveLineageSession(t, leaf, "q", "a", "next", "answer") |
| 59 | recs := []SessionRecord{ |
| 60 | {Path: root, RecoveryRole: RecoveryRoleNormal, Turns: 1, TurnsState: TurnsValid}, |
| 61 | {Path: leaf, Recovered: true, ParentID: "root", RecoveryGroupID: "root", RecoveryRole: RecoveryRoleDiverged, Turns: 2, TurnsState: TurnsValid}, |
| 62 | } |
| 63 | out := promoteCanonicalLeaves(recs) |
| 64 | if !out[1].RecoveryCanonical || out[1].RecoveryRole != RecoveryRoleAdopted { |
| 65 | t.Fatalf("unique covering leaf = %+v", out[1]) |
| 66 | } |
| 67 | |
| 68 | peer := filepath.Join(dir, "peer.jsonl") |
| 69 | saveLineageSession(t, peer, "q", "a", "other", "branch") |
| 70 | recs = append(recs, SessionRecord{ |
| 71 | Path: peer, Recovered: true, ParentID: "root", RecoveryGroupID: "root", RecoveryRole: RecoveryRoleDiverged, Turns: 2, TurnsState: TurnsValid, |
| 72 | }) |
| 73 | out = promoteCanonicalLeaves(recs) |
| 74 | canonical := 0 |
| 75 | for _, record := range out[1:] { |
| 76 | if record.RecoveryRole != RecoveryRoleDiverged { |
| 77 | t.Fatalf("ambiguous equal-length leaves must keep diverged role: %+v", out) |
| 78 | } |
| 79 | if record.RecoveryCanonical { |
| 80 | canonical++ |
| 81 | } |
| 82 | } |
| 83 | // Ordinary list still needs exactly one stable representative even when |
| 84 | // content truly forks; History remains the place to open the other leaf. |
| 85 | if canonical != 1 { |
| 86 | t.Fatalf("ambiguous group canonical count = %d, want 1 stable representative: %+v", canonical, out) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestPromoteCanonicalLeavesIgnoresUnknownTurnMetadataAndCoversAncestors(t *testing.T) { |
| 91 | dir := t.TempDir() |
| 92 | root := filepath.Join(dir, "root.jsonl") |
| 93 | ancestor := filepath.Join(dir, "ancestor.jsonl") |
| 94 | leaf := filepath.Join(dir, "leaf.jsonl") |
| 95 | saveLineageSession(t, root, "q", "a") |
| 96 | saveLineageSession(t, ancestor, "q", "a", "next", "one") |
| 97 | saveLineageSession(t, leaf, "q", "a", "next", "one", "again", "two") |
| 98 | recs := []SessionRecord{ |
| 99 | {Path: root, RecoveryRole: RecoveryRoleNormal, TurnsState: TurnsUnknown}, |
| 100 | {Path: ancestor, Recovered: true, ParentID: "root", RecoveryGroupID: "root", RecoveryRole: RecoveryRoleDiverged, TurnsState: TurnsUnknown}, |
| 101 | {Path: leaf, Recovered: true, ParentID: "ancestor", RecoveryGroupID: "root", RecoveryRole: RecoveryRoleDiverged, TurnsState: TurnsUnknown}, |
| 102 | } |
| 103 | out := promoteCanonicalLeaves(recs) |
| 104 | if !out[2].RecoveryCanonical || out[2].RecoveryRole != RecoveryRoleAdopted { |
| 105 | t.Fatalf("leaf = %+v, want adopted despite unknown turns", out[2]) |
| 106 | } |
| 107 | if !out[1].RecoveryCopy || out[1].RecoveryRole != RecoveryRoleCoveredCopy { |
| 108 | t.Fatalf("ancestor = %+v, want covered copy", out[1]) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | func TestCanonicalSessionPathForTopic(t *testing.T) { |
| 113 | sessions := []SessionRecord{ |
| 114 | {Path: "/s/old.jsonl", RecoveryRole: RecoveryRoleNormal}, |
| 115 | {Path: "/s/leaf.jsonl", RecoveryRole: RecoveryRoleAdopted, RecoveryCanonical: true}, |
| 116 | } |
| 117 | if got := CanonicalSessionPathForTopic(sessions, "/s/old.jsonl"); got != "/s/leaf.jsonl" { |
| 118 | t.Fatalf("retarget = %q", got) |
| 119 | } |
| 120 | if got := CanonicalSessionPathForTopic(sessions, "/s/leaf.jsonl"); got != "" { |
| 121 | t.Fatalf("already canonical should not retarget: %q", got) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | func TestOrdinaryContinuePathFollowsParentOnly(t *testing.T) { |
| 126 | parent := "/s/old.jsonl" |
| 127 | leaf := "/s/leaf.jsonl" |
| 128 | fork := "/s/fork.jsonl" |
| 129 | sessions := []SessionRecord{ |
| 130 | {Path: parent, RecoveryRole: RecoveryRoleNormal}, |
| 131 | {Path: leaf, Recovered: true, RecoveryRole: RecoveryRoleAdopted, RecoveryCanonical: true}, |
| 132 | {Path: fork, Recovered: true, RecoveryRole: RecoveryRoleDiverged}, |
| 133 | } |
| 134 | if got := OrdinaryContinuePath(sessions, parent); got != leaf { |
| 135 | t.Fatalf("parent continue = %q, want leaf", got) |
| 136 | } |
| 137 | if got := OrdinaryContinuePath(sessions, leaf); got != "" { |
| 138 | t.Fatalf("leaf continue = %q, want keep", got) |
| 139 | } |
| 140 | if got := OrdinaryContinuePath(sessions, fork); got != "" { |
| 141 | t.Fatalf("fork continue = %q, want keep inspection path", got) |
| 142 | } |
| 143 | if got := OrdinaryContinuePath(sessions, "/s/stale-parent.jsonl"); got != leaf { |
| 144 | t.Fatalf("stale parent continue = %q, want leaf", got) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | func TestPromoteCanonicalLeavesHonorsPreferredOriginalMember(t *testing.T) { |
| 149 | root := "/s/root.jsonl" |
| 150 | branch := "/s/branch.jsonl" |
| 151 | records := []SessionRecord{ |
| 152 | {Path: root, RecoveryRole: RecoveryRoleNormal, RecoveryPreferred: true, TurnsState: TurnsValid}, |
| 153 | {Path: branch, Recovered: true, ParentID: "root", RecoveryGroupID: "root", RecoveryRole: RecoveryRoleDiverged, TurnsState: TurnsValid}, |
| 154 | } |
| 155 | got := promoteCanonicalLeaves(records) |
| 156 | if got[0].RecoveryRole != RecoveryRolePreferred || !got[0].RecoveryCanonical { |
| 157 | t.Fatalf("preferred original = %+v, want preferred canonical", got[0]) |
| 158 | } |
| 159 | if got[1].RecoveryCanonical { |
| 160 | t.Fatalf("recovery leaf stayed canonical after choosing original: %+v", got[1]) |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | func TestOrdinaryContinuePathFollowsUniqueLinearCompactedLeaf(t *testing.T) { |
| 165 | root := filepath.Join("/s", "root.jsonl") |
| 166 | parentID := "root" |
| 167 | sessions := []SessionRecord{{ |
| 168 | Path: root, RecoveryRole: RecoveryRoleNormal, |
| 169 | Turns: 9, TurnsState: TurnsValid, LastActivityAt: 1, |
| 170 | }} |
| 171 | for i := 1; i <= 130; i++ { |
| 172 | id := fmt.Sprintf("recovery-%03d", i) |
| 173 | sessions = append(sessions, SessionRecord{ |
| 174 | Path: filepath.Join("/s", id+".jsonl"), Recovered: true, |
| 175 | ParentID: parentID, RecoveryGroupID: "root", RecoveryRole: RecoveryRoleDiverged, |
| 176 | Turns: 9 + (i*63)/130, TurnsState: TurnsValid, LastActivityAt: int64(i + 1), |
| 177 | }) |
| 178 | parentID = id |
| 179 | } |
| 180 | leaf := sessions[len(sessions)-1].Path |
| 181 | if got := OrdinaryContinuePath(sessions, root); got != leaf { |
| 182 | t.Fatalf("parent continue = %q, want unique 72-turn leaf %q", got, leaf) |
| 183 | } |
| 184 | if got := topicRepresentativePath(sessions); got != leaf { |
| 185 | t.Fatalf("representative = %q, want unique 72-turn leaf %q", got, leaf) |
| 186 | } |
| 187 | if got := OrdinaryContinuePath(sessions, leaf); got != "" { |
| 188 | t.Fatalf("leaf continue = %q, want keep current leaf", got) |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | func TestOrdinaryContinuePathRejectsForkedOrRegressingLineage(t *testing.T) { |
| 193 | root := SessionRecord{Path: "/s/root.jsonl", RecoveryRole: RecoveryRoleNormal, Turns: 9, TurnsState: TurnsValid, LastActivityAt: 1} |
| 194 | linear := SessionRecord{ |
| 195 | Path: "/s/linear.jsonl", Recovered: true, ParentID: "root", RecoveryGroupID: "root", |
| 196 | RecoveryRole: RecoveryRoleDiverged, Turns: 10, TurnsState: TurnsValid, LastActivityAt: 2, |
| 197 | } |
| 198 | fork := SessionRecord{ |
| 199 | Path: "/s/fork.jsonl", Recovered: true, ParentID: "root", RecoveryGroupID: "root", |
| 200 | RecoveryRole: RecoveryRoleDiverged, Turns: 11, TurnsState: TurnsValid, LastActivityAt: 3, |
| 201 | } |
| 202 | if got := OrdinaryContinuePath([]SessionRecord{root, linear, fork}, root.Path); got != "" { |
| 203 | t.Fatalf("forked continue = %q, want unresolved", got) |
| 204 | } |
| 205 | regressed := linear |
| 206 | regressed.Turns = 8 |
| 207 | if got := OrdinaryContinuePath([]SessionRecord{root, regressed}, root.Path); got != "" { |
| 208 | t.Fatalf("regressing continue = %q, want unresolved", got) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | func TestExplicitPreferredRecoveryWinsWithoutMakingPeersCovered(t *testing.T) { |
| 213 | dir := t.TempDir() |
| 214 | root := filepath.Join(dir, "root.jsonl") |
| 215 | left := filepath.Join(dir, "left.jsonl") |
| 216 | right := filepath.Join(dir, "right.jsonl") |
| 217 | saveLineageSession(t, root, "q", "a") |
| 218 | saveLineageSession(t, left, "q", "a", "left", "answer") |
| 219 | saveLineageSession(t, right, "q", "a", "right", "answer") |
| 220 | records := []SessionRecord{ |
| 221 | {Path: root, RecoveryRole: RecoveryRoleNormal}, |
| 222 | {Path: left, Recovered: true, ParentID: "root", RecoveryGroupID: "root", RecoveryRole: RecoveryRoleDiverged, RecoveryPreferred: true}, |
| 223 | {Path: right, Recovered: true, ParentID: "root", RecoveryGroupID: "root", RecoveryRole: RecoveryRoleDiverged}, |
| 224 | } |
| 225 | out := promoteCanonicalLeaves(records) |
| 226 | if !out[1].RecoveryCanonical || out[1].RecoveryRole != RecoveryRolePreferred { |
| 227 | t.Fatalf("preferred = %+v", out[1]) |
| 228 | } |
| 229 | if out[2].RecoveryCopy || out[2].RecoveryRole != RecoveryRoleDiverged { |
| 230 | t.Fatalf("diverged peer must retain unique content: %+v", out[2]) |
| 231 | } |
| 232 | if got := CanonicalSessionPathForTopic(out, root); got != left { |
| 233 | t.Fatalf("canonical path = %q, want %q", got, left) |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | func TestReconcilePersistsContentProvenCanonicalLeaf(t *testing.T) { |
| 238 | ctx := context.Background() |
| 239 | dir := t.TempDir() |
| 240 | root := filepath.Join(dir, "root.jsonl") |
| 241 | saveLineageSession(t, root, "q", "a") |
| 242 | continued := agent.NewSession("sys") |
| 243 | continued.Add(provider.Message{Role: provider.RoleUser, Content: "q"}) |
| 244 | continued.Add(provider.Message{Role: provider.RoleAssistant, Content: "a"}) |
| 245 | continued.Add(provider.Message{Role: provider.RoleUser, Content: "next"}) |
| 246 | continued.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer"}) |
| 247 | info, err := continued.SaveConflictRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: root}) |
| 248 | if err != nil { |
| 249 | t.Fatal(err) |
| 250 | } |
| 251 | catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true}) |
| 252 | if err != nil { |
| 253 | t.Fatal(err) |
| 254 | } |
| 255 | defer catalog.Close(ctx) |
| 256 | if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil { |
| 257 | t.Fatal(err) |
| 258 | } |
| 259 | record, ok, err := catalog.GetSession(ctx, info.Path) |
| 260 | if err != nil || !ok { |
| 261 | t.Fatalf("GetSession ok=%v err=%v", ok, err) |
| 262 | } |
| 263 | if record.RecoveryRole != RecoveryRoleAdopted || !record.RecoveryCanonical { |
| 264 | t.Fatalf("reconciled recovery = %+v, want adopted canonical", record) |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | func TestReconcileOpensUniqueLinearCompactedLeafWithoutAuthorizingCleanup(t *testing.T) { |
| 269 | ctx := context.Background() |
| 270 | dir := t.TempDir() |
| 271 | root := filepath.Join(dir, "root.jsonl") |
| 272 | leaf := filepath.Join(dir, "leaf.jsonl") |
| 273 | rootMessages := make([]string, 0, 18) |
| 274 | for i := range 9 { |
| 275 | rootMessages = append(rootMessages, fmt.Sprintf("root question %d", i), fmt.Sprintf("root answer %d", i)) |
| 276 | } |
| 277 | leafMessages := make([]string, 0, 144) |
| 278 | for i := range 72 { |
| 279 | leafMessages = append(leafMessages, fmt.Sprintf("compacted question %d", i), fmt.Sprintf("compacted answer %d", i)) |
| 280 | } |
| 281 | saveLineageSession(t, root, rootMessages...) |
| 282 | saveLineageSession(t, leaf, leafMessages...) |
| 283 | for path, meta := range map[string]agent.BranchMeta{ |
| 284 | root: {ID: "root", Scope: "global", TopicID: "conversation", TopicTitle: "Compacted"}, |
| 285 | leaf: {ID: "leaf", Scope: "global", TopicID: "conversation", TopicTitle: "Compacted", Recovered: true, ParentID: "root", RecoveryDepth: 1}, |
| 286 | } { |
| 287 | if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil { |
| 288 | t.Fatal(err) |
| 289 | } |
| 290 | } |
| 291 | catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true}) |
| 292 | if err != nil { |
| 293 | t.Fatal(err) |
| 294 | } |
| 295 | defer catalog.Close(ctx) |
| 296 | if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil { |
| 297 | t.Fatal(err) |
| 298 | } |
| 299 | topic, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "conversation"}) |
| 300 | if err != nil || !ok { |
| 301 | t.Fatalf("GetTopic ok=%v err=%v", ok, err) |
| 302 | } |
| 303 | if topic.RepresentativePath != leaf { |
| 304 | t.Fatalf("representative = %q, want compacted leaf %q", topic.RepresentativePath, leaf) |
| 305 | } |
| 306 | if topic.RecoveryCleanupEligibleCount != 0 { |
| 307 | t.Fatalf("cleanup eligible = %d, want 0 without content coverage", topic.RecoveryCleanupEligibleCount) |
| 308 | } |
| 309 | if got := CanonicalSessionPathForTopic(topic.Sessions, root); got != "" { |
| 310 | t.Fatalf("cleanup canonical = %q, want unresolved", got) |
| 311 | } |
| 312 | if got := OrdinaryContinuePath(topic.Sessions, root); got != leaf { |
| 313 | t.Fatalf("ordinary continue = %q, want compacted leaf %q", got, leaf) |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | func TestReconcileReanchorsCrossTopicRecoveryIntoOneLogicalTopic(t *testing.T) { |
| 318 | ctx := context.Background() |
| 319 | dir := t.TempDir() |
| 320 | root := filepath.Join(dir, "root.jsonl") |
| 321 | copyPath := filepath.Join(dir, "copy.jsonl") |
| 322 | leaf := filepath.Join(dir, "leaf.jsonl") |
| 323 | saveLineageSession(t, root, "q", "a") |
| 324 | saveLineageSession(t, copyPath, "q", "a") |
| 325 | saveLineageSession(t, leaf, "q", "a", "next", "done") |
| 326 | for path, meta := range map[string]agent.BranchMeta{ |
| 327 | root: {ID: "root", Scope: "global", TopicID: "conversation", TopicTitle: "Upgraded"}, |
| 328 | copyPath: {ID: "copy", Scope: "global", TopicID: "legacy-copy-topic", Recovered: true, ParentID: "root", RecoveryDepth: 1}, |
| 329 | leaf: {ID: "leaf", Scope: "global", TopicID: "legacy-leaf-topic", Recovered: true, ParentID: "copy", RecoveryDepth: 2}, |
| 330 | } { |
| 331 | if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil { |
| 332 | t.Fatal(err) |
| 333 | } |
| 334 | } |
| 335 | catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true}) |
| 336 | if err != nil { |
| 337 | t.Fatal(err) |
| 338 | } |
| 339 | defer catalog.Close(ctx) |
| 340 | if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil { |
| 341 | t.Fatal(err) |
| 342 | } |
| 343 | page, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 50}) |
| 344 | if err != nil { |
| 345 | t.Fatal(err) |
| 346 | } |
| 347 | if len(page.Items) != 1 || page.Items[0].TopicID != "conversation" { |
| 348 | t.Fatalf("ListTopics = %+v, want one logical topic conversation", page.Items) |
| 349 | } |
| 350 | leafRec, ok, err := catalog.GetSession(ctx, leaf) |
| 351 | if err != nil || !ok { |
| 352 | t.Fatalf("GetSession leaf ok=%v err=%v", ok, err) |
| 353 | } |
| 354 | if leafRec.TopicID != "conversation" || leafRec.LogicalTopicID != "conversation" { |
| 355 | t.Fatalf("leaf projection = %+v, want re-anchored to conversation", leafRec) |
| 356 | } |
| 357 | if leafRec.OrdinaryVisible { |
| 358 | t.Fatalf("leaf must not be ordinary-visible while root exists: %+v", leafRec) |
| 359 | } |
| 360 | rootRec, ok, err := catalog.GetSession(ctx, root) |
| 361 | if err != nil || !ok || !rootRec.OrdinaryVisible { |
| 362 | t.Fatalf("root ordinary visibility = %+v ok=%v err=%v", rootRec, ok, err) |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | func TestRecoveryFilenameParentID(t *testing.T) { |
| 367 | parent, ok := agent.RecoveryFilenameParentID("/s/chat-recovery-0123456789abcdef.jsonl") |
| 368 | if !ok || parent != "chat" { |
| 369 | t.Fatalf("parent = %q ok=%v", parent, ok) |
| 370 | } |
| 371 | if !agent.LooksLikeRecoveryFilename("/s/chat-recovery-0123456789abcdef.jsonl") { |
| 372 | t.Fatal("expected recovery filename") |
| 373 | } |
| 374 | if agent.LooksLikeRecoveryFilename("/s/chat.jsonl") { |
| 375 | t.Fatal("normal session must not look like recovery") |
| 376 | } |
| 377 | } |
| 378 | |
| 379 | func TestUpgradeMatrixV4RebuildKeepsSingleLogicalRowAndAuthority(t *testing.T) { |
| 380 | // Simulates 1.24.2-style multi-topic recovery storm → new v6 projection → |
| 381 | // discard cache → reindex. Ordinary list stays one row; JSONL/meta bytes |
| 382 | // are never rewritten (the 1.23.0→new-version reinstall path). |
| 383 | ctx := context.Background() |
| 384 | dir := t.TempDir() |
| 385 | cacheDir := t.TempDir() |
| 386 | root := filepath.Join(dir, "root.jsonl") |
| 387 | copyPath := filepath.Join(dir, "copy.jsonl") |
| 388 | leaf := filepath.Join(dir, "leaf.jsonl") |
| 389 | saveLineageSession(t, root, "q", "a") |
| 390 | saveLineageSession(t, copyPath, "q", "a") |
| 391 | saveLineageSession(t, leaf, "q", "a", "next", "done") |
| 392 | for path, meta := range map[string]agent.BranchMeta{ |
| 393 | root: {ID: "root", Scope: "global", TopicID: "conversation", TopicTitle: "Upgraded"}, |
| 394 | copyPath: {ID: "copy", Scope: "global", TopicID: "legacy-copy-topic", Recovered: true, ParentID: "root", RecoveryDepth: 1}, |
| 395 | leaf: {ID: "leaf", Scope: "global", TopicID: "legacy-leaf-topic", Recovered: true, ParentID: "copy", RecoveryDepth: 2}, |
| 396 | } { |
| 397 | if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil { |
| 398 | t.Fatal(err) |
| 399 | } |
| 400 | } |
| 401 | hash := func(path string) string { |
| 402 | t.Helper() |
| 403 | data, err := os.ReadFile(path) |
| 404 | if err != nil { |
| 405 | t.Fatal(err) |
| 406 | } |
| 407 | return string(data) |
| 408 | } |
| 409 | before := map[string]string{} |
| 410 | for _, path := range []string{root, copyPath, leaf, agent.BranchMetaPath(root), agent.BranchMetaPath(copyPath), agent.BranchMetaPath(leaf)} { |
| 411 | before[path] = hash(path) |
| 412 | } |
| 413 | |
| 414 | openAndList := func(label string) (topicID, rep string) { |
| 415 | t.Helper() |
| 416 | catalog, err := Open(ctx, Options{Path: filepath.Join(cacheDir, label+".sqlite"), DisableRepair: true}) |
| 417 | if err != nil { |
| 418 | t.Fatal(err) |
| 419 | } |
| 420 | defer catalog.Close(ctx) |
| 421 | if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil { |
| 422 | t.Fatal(err) |
| 423 | } |
| 424 | page, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 50}) |
| 425 | if err != nil { |
| 426 | t.Fatal(err) |
| 427 | } |
| 428 | if len(page.Items) != 1 { |
| 429 | t.Fatalf("%s ListTopics = %+v, want one logical row", label, page.Items) |
| 430 | } |
| 431 | return page.Items[0].TopicID, page.Items[0].RepresentativePath |
| 432 | } |
| 433 | |
| 434 | topic1, rep1 := openAndList("pass1") |
| 435 | topic2, rep2 := openAndList("pass2") |
| 436 | if topic1 != "conversation" || topic2 != "conversation" { |
| 437 | t.Fatalf("logical topics = %q/%q, want conversation both rebuilds", topic1, topic2) |
| 438 | } |
| 439 | if rep1 == "" || rep1 != rep2 { |
| 440 | t.Fatalf("representative unstable across rebuilds: %q vs %q", rep1, rep2) |
| 441 | } |
| 442 | for path, want := range before { |
| 443 | if got := hash(path); got != want { |
| 444 | t.Fatalf("authority file mutated during catalog rebuild: %s", path) |
| 445 | } |
| 446 | } |
| 447 | // v7 isolates the persistent v11 repair scheduler from older writers. |
| 448 | if !strings.HasSuffix(filepath.ToSlash(DefaultPath()), "session-catalog/v9.sqlite") && DefaultPath() != "" { |
| 449 | t.Fatalf("DefaultPath = %q, want v9.sqlite", DefaultPath()) |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | func TestReconcileFoldsFilenameRecoveryIntoRootTopic(t *testing.T) { |
| 454 | ctx := context.Background() |
| 455 | dir := t.TempDir() |
| 456 | root := filepath.Join(dir, "normal.jsonl") |
| 457 | recovery := filepath.Join(dir, "normal-recovery-0123456789abcdef.jsonl") |
| 458 | saveLineageSession(t, root, "normal imported prompt") |
| 459 | saveLineageSession(t, recovery, "legacy recovery prompt") |
| 460 | if err := agent.SaveBranchMetaPreserveUpdated(root, agent.BranchMeta{ |
| 461 | ID: "normal", Scope: "global", TopicID: "legacy_normal", TopicTitle: "normal", |
| 462 | }); err != nil { |
| 463 | t.Fatal(err) |
| 464 | } |
| 465 | if err := agent.SaveBranchMetaPreserveUpdated(recovery, agent.BranchMeta{ |
| 466 | ID: "normal-recovery-0123456789abcdef", Scope: "global", |
| 467 | TopicID: "legacy_recovery", TopicTitle: "recovery", |
| 468 | }); err != nil { |
| 469 | t.Fatal(err) |
| 470 | } |
| 471 | catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true}) |
| 472 | if err != nil { |
| 473 | t.Fatal(err) |
| 474 | } |
| 475 | defer catalog.Close(ctx) |
| 476 | if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil { |
| 477 | t.Fatal(err) |
| 478 | } |
| 479 | page, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 50}) |
| 480 | if err != nil { |
| 481 | t.Fatal(err) |
| 482 | } |
| 483 | if len(page.Items) != 1 || page.Items[0].TopicID != "legacy_normal" { |
| 484 | t.Fatalf("ListTopics = %+v, want one folded legacy_normal topic", page.Items) |
| 485 | } |
| 486 | rec, ok, err := catalog.GetSession(ctx, recovery) |
| 487 | if err != nil || !ok { |
| 488 | t.Fatalf("GetSession recovery ok=%v err=%v", ok, err) |
| 489 | } |
| 490 | if !rec.Recovered || rec.TopicID != "legacy_normal" { |
| 491 | t.Fatalf("recovery projection = %+v, want re-anchored recovered row", rec) |
| 492 | } |
| 493 | } |
| 494 |