返回 DeepSeek-Reasonix
exact_index_test.go
根目录 / internal / sessioncatalog / exact_index_test.go
1 package sessioncatalog
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "reasonix/internal/agent"
11 )
12
13 func TestIndexSessionPathSkipsUnchangedProjection(t *testing.T) {
14 t.Parallel()
15 ctx := context.Background()
16 dir := t.TempDir()
17 path := filepath.Join(dir, "chat.jsonl")
18 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o600); err != nil {
19 t.Fatal(err)
20 }
21 if err := agent.SaveBranchMeta(path, agent.BranchMeta{
22 Scope: "global", TopicID: "topic", TopicTitle: "Chat",
23 SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1,
24 }); err != nil {
25 t.Fatal(err)
26 }
27 events := 0
28 catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true, OnRevision: func(uint64, []string, string) { events++ }})
29 if err != nil {
30 t.Fatal(err)
31 }
32 t.Cleanup(func() { _ = catalog.Close(ctx) })
33 target := DirectoryTarget{Path: dir, Scope: "global"}
34 if err := catalog.ReconcileDirectory(ctx, target); err != nil {
35 t.Fatal(err)
36 }
37 revision := catalog.Status().Revision
38 before, _, _ := catalog.GetSession(ctx, path)
39 for range 5 {
40 if err := catalog.IndexSessionPath(ctx, target, path); err != nil {
41 t.Fatal(err)
42 }
43 }
44 if got := catalog.Status().Revision; got != revision {
45 after, _, _ := catalog.GetSession(ctx, path)
46 t.Logf("before=%+v after=%+v", before, after)
47 t.Fatalf("revision after unchanged exact indexes = %d, want %d", got, revision)
48 }
49 if events != 1 {
50 t.Fatalf("revision events after unchanged exact indexes = %d, want 1", events)
51 }
52 }
53
54 func TestIndexSessionPathDoesNotRequeueRepairAfterRepair(t *testing.T) {
55 t.Parallel()
56 ctx := context.Background()
57 dir := t.TempDir()
58 path := filepath.Join(dir, "legacy.jsonl")
59 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o600); err != nil {
60 t.Fatal(err)
61 }
62 if err := agent.SaveBranchMeta(path, agent.BranchMeta{TopicID: "topic", SchemaVersion: 1}); err != nil {
63 t.Fatal(err)
64 }
65 catalog, err := Open(ctx, Options{InMemory: true, QueueCapacity: 2})
66 if err != nil {
67 t.Fatal(err)
68 }
69 t.Cleanup(func() { _ = catalog.Close(ctx) })
70 target := DirectoryTarget{Path: dir, Scope: "global"}
71 if err := catalog.ReconcileDirectory(ctx, target); err != nil {
72 t.Fatal(err)
73 }
74 deadline := time.Now().Add(5 * time.Second)
75 for time.Now().Before(deadline) {
76 if catalog.Status().RepairPending == 0 {
77 break
78 }
79 time.Sleep(10 * time.Millisecond)
80 }
81 if catalog.Status().RepairPending != 0 {
82 t.Fatal("repair did not complete")
83 }
84 for range 20 {
85 if err := catalog.IndexSessionPath(ctx, target, path); err != nil {
86 t.Fatal(err)
87 }
88 }
89 if got := catalog.Status().RepairPending; got != 0 {
90 t.Fatalf("repair pending after unchanged exact indexes = %d", got)
91 }
92 }
93
94 func TestExactIndexDoesNotDowngradeKnownCounts(t *testing.T) {
95 t.Parallel()
96 ctx := context.Background()
97 catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
98 if err != nil {
99 t.Fatal(err)
100 }
101 t.Cleanup(func() { _ = catalog.Close(ctx) })
102 record := SessionRecord{Path: "/sessions/chat.jsonl", Directory: "/sessions", Scope: "global", TopicID: "topic", CreatedAt: 1, LastActivityAt: 2, Preview: "hi", Turns: 1, TurnsState: TurnsValid, ContentFingerprint: "10:1", MetaFingerprint: "20:1", Health: HealthOK}
103 if err := catalog.UpsertSession(ctx, record); err != nil {
104 t.Fatal(err)
105 }
106 record.Preview, record.Turns, record.TurnsState, record.MetaFingerprint = "", 0, TurnsUnknown, "20:2"
107 record.Recovered, record.RecoveryReason, record.RecoveryDigest, record.ParentID = true, "recovery", "digest", "parent"
108 if err := catalog.UpsertSession(ctx, record); err != nil {
109 t.Fatal(err)
110 }
111 got, ok, err := catalog.GetSession(ctx, record.Path)
112 if err != nil || !ok {
113 t.Fatalf("GetSession: ok=%v err=%v", ok, err)
114 }
115 if got.TurnsState != TurnsValid || got.Turns != 1 || got.Preview != "hi" ||
116 !got.Recovered || got.RecoveryReason != "recovery" || got.RecoveryDigest != "digest" || got.ParentID != "parent" {
117 t.Fatalf("exact index lost known counts or recovery metadata: %+v", got)
118 }
119 }
120
121 // A previous-generation projection is never certified, but its counts stay as
122 // hints: a session whose save was interrupted must not disappear from the
123 // sidebar while the repair worker recomputes it (#9890).
124 func TestRecordFromOrderKeepsPreviousGenerationCountsAsUncertifiedHints(t *testing.T) {
125 record := recordFromOrder(DirectoryTarget{Path: "/sessions", Scope: "global"}, agent.SessionOrderInfo{
126 Path: "/sessions/chat.jsonl",
127 Scope: "global",
128 Preview: "stale preview",
129 Turns: 7,
130 SchemaVersion: agent.BranchMetaCountsVersion,
131 Revision: 2,
132 ContentDigest: "new-digest",
133 ListingRevision: 1,
134 ListingContentDigest: "old-digest",
135 })
136 if record.TurnsState != TurnsUnknown {
137 t.Fatalf("stale listing projection was certified: %+v", record)
138 }
139 if record.Turns != 7 || record.Preview != "stale preview" {
140 t.Fatalf("stale listing projection lost its last-known hints: %+v", record)
141 }
142 }
143
144 func TestExactIndexKeepsKnownCountsAsHintsWhenTranscriptChanged(t *testing.T) {
145 t.Parallel()
146 ctx := context.Background()
147 catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
148 if err != nil {
149 t.Fatal(err)
150 }
151 t.Cleanup(func() { _ = catalog.Close(ctx) })
152 record := SessionRecord{Path: "/sessions/chat.jsonl", Directory: "/sessions", Scope: "global", TopicID: "topic", CreatedAt: 1, LastActivityAt: 2, Preview: "hi", Turns: 1, TurnsState: TurnsValid, ContentFingerprint: "10:1", MetaFingerprint: "20:1", Health: HealthOK}
153 if err := catalog.UpsertSession(ctx, record); err != nil {
154 t.Fatal(err)
155 }
156 record.Preview, record.Turns, record.TurnsState, record.ContentFingerprint, record.MetaFingerprint = "", 0, TurnsUnknown, "11:2", "20:2"
157 if err := catalog.UpsertSession(ctx, record); err != nil {
158 t.Fatal(err)
159 }
160 got, ok, err := catalog.GetSession(ctx, record.Path)
161 if err != nil || !ok {
162 t.Fatalf("GetSession: ok=%v err=%v", ok, err)
163 }
164 if got.TurnsState != TurnsUnknown {
165 t.Fatalf("changed transcript kept a certified count: %+v", got)
166 }
167 if got.Turns != 1 || got.Preview != "hi" {
168 t.Fatalf("changed transcript lost its last-known hints: %+v", got)
169 }
170 }
171
172 func TestPreserveKnownSourceStatesKeepsHintsAcrossFingerprintChange(t *testing.T) {
173 t.Parallel()
174 ctx := context.Background()
175 catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
176 if err != nil {
177 t.Fatal(err)
178 }
179 t.Cleanup(func() { _ = catalog.Close(ctx) })
180 known := SessionRecord{Path: "/sessions/chat.jsonl", Directory: "/sessions", Scope: "global", TopicID: "topic", CreatedAt: 1, LastActivityAt: 2, Preview: "hi", Turns: 1, TurnsState: TurnsValid, ContentFingerprint: "10:1", MetaFingerprint: "20:1", Health: HealthOK}
181 if err := catalog.UpsertSession(ctx, known); err != nil {
182 t.Fatal(err)
183 }
184 changed := SessionRecord{Path: known.Path, Directory: known.Directory, Scope: "global", TurnsState: TurnsUnknown, ContentFingerprint: "11:2", Health: HealthOK}
185 records, err := catalog.preserveKnownSourceStates(ctx, known.Directory, []SessionRecord{changed})
186 if err != nil {
187 t.Fatal(err)
188 }
189 if got := records[0]; got.TurnsState != TurnsUnknown || got.Turns != 1 || got.Preview != "hi" {
190 t.Fatalf("changed fingerprint = %+v, want unknown state with last-known hints", got)
191 }
192 same := changed
193 same.ContentFingerprint = known.ContentFingerprint
194 records, err = catalog.preserveKnownSourceStates(ctx, known.Directory, []SessionRecord{same})
195 if err != nil {
196 t.Fatal(err)
197 }
198 if got := records[0]; got.TurnsState != TurnsValid || got.Turns != 1 || got.Preview != "hi" {
199 t.Fatalf("unchanged fingerprint = %+v, want the certified state restored", got)
200 }
201 }
202
203 func TestExactIndexDoesNotRegressKnownActivity(t *testing.T) {
204 t.Parallel()
205 ctx := context.Background()
206 dir := t.TempDir()
207 path := filepath.Join(dir, "chat.jsonl")
208 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o600); err != nil {
209 t.Fatal(err)
210 }
211 created := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
212 current := created.Add(15 * 24 * time.Hour)
213 if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
214 CreatedAt: created, UpdatedAt: current, Scope: "global", TopicID: "topic",
215 TopicTitle: "Chat", SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1,
216 }); err != nil {
217 t.Fatal(err)
218 }
219 catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
220 if err != nil {
221 t.Fatal(err)
222 }
223 t.Cleanup(func() { _ = catalog.Close(ctx) })
224 target := DirectoryTarget{Path: dir, Scope: "global"}
225 if err := catalog.ReconcileDirectory(ctx, target); err != nil {
226 t.Fatal(err)
227 }
228 // A transient sidecar read can expose an older timestamp while the
229 // authoritative transcript is unchanged. Exact indexing must not move the
230 // conversation backwards in the sidebar.
231 if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
232 CreatedAt: created, UpdatedAt: created.Add(30 * time.Hour), Scope: "global", TopicID: "topic",
233 TopicTitle: "Chat", SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1,
234 }); err != nil {
235 t.Fatal(err)
236 }
237 if err := catalog.IndexSessionPath(ctx, target, path); err != nil {
238 t.Fatal(err)
239 }
240 record, ok, err := catalog.GetSession(ctx, path)
241 if err != nil || !ok {
242 t.Fatalf("GetSession: ok=%v err=%v", ok, err)
243 }
244 if record.LastActivityAt != current.UnixMilli() {
245 t.Fatalf("lastActivityAt = %d, want preserved %d", record.LastActivityAt, current.UnixMilli())
246 }
247 }
248
249 func TestExactIndexPreservesRootlessRecoveryRepresentative(t *testing.T) {
250 ctx := context.Background()
251 dir := t.TempDir()
252 root := filepath.Join(dir, "root.jsonl")
253 leaf := filepath.Join(dir, "leaf.jsonl")
254 saveLineageSession(t, root, "q", "a")
255 saveLineageSession(t, leaf, "q", "a", "next", "done")
256 for path, meta := range map[string]agent.BranchMeta{
257 root: {ID: "root", Scope: "global", TopicID: "root-topic", TopicTitle: "Root"},
258 leaf: {ID: "leaf", Scope: "global", TopicID: "leaf-source-topic", TopicTitle: "Leaf", Recovered: true, ParentID: "root", RecoveryDepth: 1},
259 } {
260 if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil {
261 t.Fatal(err)
262 }
263 }
264 if err := os.Remove(root); err != nil {
265 t.Fatal(err)
266 }
267 if err := os.Remove(agent.BranchMetaPath(root)); err != nil {
268 t.Fatal(err)
269 }
270
271 catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true})
272 if err != nil {
273 t.Fatal(err)
274 }
275 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
276 target := DirectoryTarget{Path: dir, Scope: "global"}
277 if err := catalog.ReconcileDirectory(ctx, target); err != nil {
278 t.Fatal(err)
279 }
280 beforePage, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 50})
281 if err != nil || len(beforePage.Items) != 1 {
282 t.Fatalf("before ListTopics: items=%+v err=%v", beforePage.Items, err)
283 }
284 before, ok, err := catalog.GetSession(ctx, leaf)
285 if err != nil || !ok || !before.OrdinaryVisible || !before.RecoveryCanonical {
286 t.Fatalf("before recovery projection: record=%+v ok=%v err=%v", before, ok, err)
287 }
288 if err := agent.UpdateBranchMeta(leaf, false, func(meta *agent.BranchMeta) error {
289 meta.Preview = "sidecar refresh"
290 return nil
291 }); err != nil {
292 t.Fatal(err)
293 }
294 if err := catalog.IndexSessionPath(ctx, target, leaf); err != nil {
295 t.Fatal(err)
296 }
297 afterPage, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 50})
298 if err != nil || len(afterPage.Items) != 1 {
299 t.Fatalf("after ListTopics: items=%+v err=%v", afterPage.Items, err)
300 }
301 if afterPage.Items[0].RepresentativePath != beforePage.Items[0].RepresentativePath ||
302 afterPage.Items[0].TopicID != beforePage.Items[0].TopicID {
303 t.Fatalf("representative changed across exact index: before=%+v after=%+v", beforePage.Items[0], afterPage.Items[0])
304 }
305 after, ok, err := catalog.GetSession(ctx, leaf)
306 if err != nil || !ok {
307 t.Fatalf("after GetSession: ok=%v err=%v", ok, err)
308 }
309 if after.RecoveryCopy != before.RecoveryCopy || after.RecoveryGroupID != before.RecoveryGroupID ||
310 after.RecoveryRole != before.RecoveryRole || after.RecoveryCanonical != before.RecoveryCanonical ||
311 after.LogicalTopicID != before.LogicalTopicID || after.OrdinaryVisible != before.OrdinaryVisible ||
312 after.TopicID != before.TopicID || after.TopicTitle != before.TopicTitle {
313 t.Fatalf("exact index overwrote directory projection: before=%+v after=%+v", before, after)
314 }
315 }
316
317 func TestExactUpsertSQLRetainsDirectoryProjection(t *testing.T) {
318 ctx := context.Background()
319 catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
320 if err != nil {
321 t.Fatal(err)
322 }
323 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
324 record := SessionRecord{
325 Path: "/sessions/recovery.jsonl", Directory: "/sessions", Scope: "global",
326 TopicID: "projected-topic", TopicTitle: "Projected", Recovered: true, ParentID: "root",
327 RecoveryCopy: true, RecoveryGroupID: "root", RecoveryRole: RecoveryRoleCoveredCopy,
328 RecoveryCanonical: true, LogicalTopicID: "projected-topic", OrdinaryVisible: true,
329 TurnsState: TurnsValid, Health: HealthOK,
330 }
331 if _, err := catalog.upsertSessionsWithNotification(ctx, []SessionRecord{record}, nil, "seed", false, upsertDirectoryProjection); err != nil {
332 t.Fatal(err)
333 }
334 incoming := record
335 incoming.TopicID = "source-topic"
336 incoming.TopicTitle = "Source"
337 incoming.RecoveryCopy = false
338 incoming.RecoveryGroupID = "other"
339 incoming.RecoveryRole = RecoveryRoleDiverged
340 incoming.RecoveryCanonical = false
341 incoming.LogicalTopicID = "source-topic"
342 incoming.OrdinaryVisible = false
343 incoming.Preview = "updated"
344 if err := catalog.UpsertSession(ctx, incoming); err != nil {
345 t.Fatal(err)
346 }
347 got, ok, err := catalog.GetSession(ctx, record.Path)
348 if err != nil || !ok {
349 t.Fatalf("GetSession: ok=%v err=%v", ok, err)
350 }
351 if got.TopicID != record.TopicID || got.TopicTitle != record.TopicTitle ||
352 got.RecoveryCopy != record.RecoveryCopy || got.RecoveryGroupID != record.RecoveryGroupID ||
353 got.RecoveryRole != record.RecoveryRole || got.RecoveryCanonical != record.RecoveryCanonical ||
354 got.LogicalTopicID != record.LogicalTopicID || got.OrdinaryVisible != record.OrdinaryVisible {
355 t.Fatalf("SQL exact-update branch replaced directory projection: got=%+v want=%+v", got, record)
356 }
357 if got.Preview != "updated" {
358 t.Fatalf("source-owned preview = %q, want updated", got.Preview)
359 }
360 }
361
362 func TestNewRecoveryExactIndexCreatesNoTopicShell(t *testing.T) {
363 ctx := context.Background()
364 catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
365 if err != nil {
366 t.Fatal(err)
367 }
368 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
369 record := SessionRecord{
370 Path: "/sessions/new-recovery.jsonl", Directory: "/sessions", Scope: "global",
371 TopicID: "unproven-source-topic", TopicTitle: "Unproven", Recovered: true, ParentID: "root",
372 TurnsState: TurnsValid, Health: HealthOK,
373 }
374 if err := catalog.UpsertSession(ctx, record); err != nil {
375 t.Fatal(err)
376 }
377 got, ok, err := catalog.GetSession(ctx, record.Path)
378 if err != nil || !ok {
379 t.Fatalf("GetSession: ok=%v err=%v", ok, err)
380 }
381 if got.TopicID != "" || got.TopicTitle != "" || got.LogicalTopicID != "" || got.OrdinaryVisible {
382 t.Fatalf("new recovery exact shell leaked a logical topic: %+v", got)
383 }
384 page, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 50})
385 if err != nil {
386 t.Fatal(err)
387 }
388 if len(page.Items) != 0 {
389 t.Fatalf("new recovery exact shell appeared in ListTopics: %+v", page.Items)
390 }
391 }
392
392 lines GO