返回 DeepSeek-Reasonix
heads_projection_test.go
根目录 / internal / sessioncatalog / heads_projection_test.go
1 package sessioncatalog
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/projectiondb"
12 "reasonix/internal/provider"
13 "reasonix/internal/store"
14 )
15
16 func writeSchemaTwoSession(t *testing.T, path string) (*agent.Session, *agent.Session) {
17 t.Helper()
18 a := agent.NewSession("sys")
19 a.Add(provider.Message{Role: provider.RoleUser, Content: "q1"})
20 a.Add(provider.Message{Role: provider.RoleAssistant, Content: "a1"})
21 if err := a.Save(path); err != nil {
22 t.Fatal(err)
23 }
24 if err := agent.SaveBranchMeta(path, agent.BranchMeta{Scope: "global", TopicID: "topic-heads", TopicTitle: "Heads"}); err != nil {
25 t.Fatal(err)
26 }
27 if err := a.Save(path); err != nil {
28 t.Fatal(err)
29 }
30 b, err := agent.LoadSession(path)
31 if err != nil {
32 t.Fatal(err)
33 }
34 a.Add(provider.Message{Role: provider.RoleUser, Content: "q2 from a"})
35 if err := a.Save(path); err != nil {
36 t.Fatal(err)
37 }
38 b.Add(provider.Message{Role: provider.RoleUser, Content: "q2 from b"})
39 if err := b.Save(path); err != nil {
40 t.Fatal(err)
41 }
42 return a, b
43 }
44
45 func TestReconcileProjectsSchemaTwoHeadsFromTheIndex(t *testing.T) {
46 ctx := context.Background()
47 dir := t.TempDir()
48 path := filepath.Join(dir, "heads.jsonl")
49 _, b := writeSchemaTwoSession(t, path)
50
51 catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
52 if err != nil {
53 t.Fatal(err)
54 }
55 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
56 if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
57 t.Fatal(err)
58 }
59 page, err := catalog.ListSessions(ctx, SessionPageRequest{Scope: "global", Limit: 10})
60 if err != nil || len(page.Items) != 1 {
61 t.Fatalf("sessions = %+v err=%v", page.Items, err)
62 }
63 rec := page.Items[0]
64 refB, _ := b.Head()
65 if rec.LogFormat != 2 || rec.HeadCount != 2 || rec.SelectedHeadID != refB.HeadID {
66 t.Fatalf("record = logFormat %d heads %d selected %q, want schema 2 with b's head selected (%q)", rec.LogFormat, rec.HeadCount, rec.SelectedHeadID, refB.HeadID)
67 }
68 if !strings.Contains(rec.ContentFingerprint, "|h:"+refB.HeadID+":") {
69 t.Fatalf("fingerprint %q must bind the selected head", rec.ContentFingerprint)
70 }
71 if rec.TurnsState != TurnsValid || rec.Recovered || rec.RecoveryRole != RecoveryRoleNormal || !rec.OrdinaryVisible {
72 t.Fatalf("schema-2 row must be an ordinary valid session: %+v", rec)
73 }
74 heads, err := catalog.ListHeads(ctx, path)
75 if err != nil || len(heads) != 2 {
76 t.Fatalf("heads = %+v err=%v", heads, err)
77 }
78 if heads[0].ID != agent.SessionMainHead || heads[0].Kind != agent.HeadKindMain || heads[0].Selected {
79 t.Fatalf("main head = %+v", heads[0])
80 }
81 if heads[1].ID != refB.HeadID || heads[1].Kind != agent.HeadKindConcurrent || !heads[1].Selected || heads[1].Turns != 2 || heads[1].Path != path {
82 t.Fatalf("concurrent head = %+v", heads[1])
83 }
84 if none, err := catalog.ListHeads(ctx, filepath.Join(dir, "missing.jsonl")); err != nil || none == nil || len(none) != 0 {
85 t.Fatalf("missing session heads = %#v err=%v, want empty slice", none, err)
86 }
87 }
88
89 func TestReconcileMarksStaleHeadIndexUnknownAndKeepsRows(t *testing.T) {
90 ctx := context.Background()
91 dir := t.TempDir()
92 path := filepath.Join(dir, "heads.jsonl")
93 writeSchemaTwoSession(t, path)
94 catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
95 if err != nil {
96 t.Fatal(err)
97 }
98 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
99 if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
100 t.Fatal(err)
101 }
102 // A log that grew without its index (a writer died mid-save) is projected
103 // from the sidecar mirror and queued for repair; the head rows stay.
104 if err := os.Remove(store.SessionEventIndex(path)); err != nil {
105 t.Fatal(err)
106 }
107 f, err := os.OpenFile(store.SessionEventLog(path), os.O_WRONLY|os.O_APPEND, 0o600)
108 if err != nil {
109 t.Fatal(err)
110 }
111 if _, err := f.WriteString("\n"); err != nil {
112 t.Fatal(err)
113 }
114 f.Close()
115 if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
116 t.Fatal(err)
117 }
118 page, err := catalog.ListSessions(ctx, SessionPageRequest{Scope: "global", Limit: 10})
119 if err != nil || len(page.Items) != 1 {
120 t.Fatalf("sessions = %+v err=%v", page.Items, err)
121 }
122 rec := page.Items[0]
123 if rec.LogFormat != 2 || rec.HeadCount != 2 || rec.SelectedHeadID == "" || rec.TurnsState != TurnsUnknown {
124 t.Fatalf("stale projection = %+v, want schema-2 mirror with unknown counts", rec)
125 }
126 if heads, err := catalog.ListHeads(ctx, path); err != nil || len(heads) != 2 {
127 t.Fatalf("head rows after stale index = %+v err=%v", heads, err)
128 }
129 }
130
131 func TestMigrationV12AddsHeadProjectionAndForcesRescan(t *testing.T) {
132 ctx := context.Background()
133 path := filepath.Join(t.TempDir(), "catalog.sqlite")
134 handle, err := projectiondb.Open(ctx, projectiondb.OpenOptions{
135 Path: path, MemoryName: "session-catalog-v11-test", Migrations: sessionMigrations()[:11], RequireDisk: true,
136 })
137 if err != nil {
138 t.Fatal(err)
139 }
140 for _, statement := range []string{
141 `INSERT INTO catalog_directories(path,path_key,scope) VALUES('/Sessions','/sessions','global')`,
142 `INSERT INTO catalog_sessions(path,path_key,directory,directory_key,scope,topic_id) VALUES('/Sessions/Old.jsonl','/sessions/old.jsonl','/Sessions','/sessions','global','topic')`,
143 } {
144 if _, err := handle.DB.ExecContext(ctx, statement); err != nil {
145 _ = handle.DB.Close()
146 t.Fatal(err)
147 }
148 }
149 if err := handle.DB.Close(); err != nil {
150 t.Fatal(err)
151 }
152 migrated, err := projectiondb.Open(ctx, projectiondb.OpenOptions{
153 Path: path, MemoryName: "session-catalog-v12-test", Migrations: sessionMigrations()[:12], RequireDisk: true,
154 })
155 if err != nil {
156 t.Fatal(err)
157 }
158 t.Cleanup(func() { _ = migrated.DB.Close() })
159 catalog := &Catalog{db: migrated.DB, pathIdentity: PathIdentityKey}
160 var directories, sessions, logFormat int
161 if err := catalog.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_directories`).Scan(&directories); err != nil {
162 t.Fatal(err)
163 }
164 if err := catalog.db.QueryRowContext(ctx, `SELECT COUNT(*), MAX(log_format) FROM catalog_sessions`).Scan(&sessions, &logFormat); err != nil {
165 t.Fatal(err)
166 }
167 if directories != 0 || sessions != 1 || logFormat != 1 {
168 t.Fatalf("after v12: directories=%d sessions=%d log_format=%d, want rescan forced and rows kept as schema 1", directories, sessions, logFormat)
169 }
170 if heads, err := catalog.ListHeads(ctx, "/Sessions/Old.jsonl"); err != nil || len(heads) != 0 {
171 t.Fatalf("legacy row heads = %+v err=%v", heads, err)
172 }
173 }
174
174 lines GO