返回 DeepSeek-Reasonix
topic_migration_marker_test.go
根目录 / desktop / topic_migration_marker_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9 "time"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/config"
13 )
14
15 func TestTopicMigrationMarkerDetectsSameSizeRewriteWithRestoredMtime(t *testing.T) {
16 isolateDesktopUserDirs(t)
17 dir := config.SessionDir()
18 if err := os.MkdirAll(dir, 0o755); err != nil {
19 t.Fatalf("mkdir sessions: %v", err)
20 }
21 path := writeLegacySession(t, dir, "same-size.jsonl", "alpha", time.Now().Add(-time.Hour))
22 info, err := os.Stat(path)
23 if err != nil {
24 t.Fatalf("stat original session: %v", err)
25 }
26 markTopicMigrationDone(dir)
27 if !topicMigrationDone(dir) {
28 t.Fatal("fresh migration marker should match")
29 }
30
31 if err := os.WriteFile(path, []byte("{\"role\":\"user\",\"content\":\"bravo\"}\n"), 0o644); err != nil {
32 t.Fatalf("rewrite same-size session: %v", err)
33 }
34 if rewritten, err := os.Stat(path); err != nil {
35 t.Fatalf("stat rewritten session: %v", err)
36 } else if rewritten.Size() != info.Size() {
37 t.Fatalf("fixture size changed: before=%d after=%d", info.Size(), rewritten.Size())
38 }
39 if err := os.Chtimes(path, info.ModTime(), info.ModTime()); err != nil {
40 t.Fatalf("restore session mtime: %v", err)
41 }
42 if topicMigrationDone(dir) {
43 t.Fatal("same-size rewrite with restored mtime must invalidate marker")
44 }
45 }
46
47 func TestForcedTopicMigrationBypassesMatchingMarker(t *testing.T) {
48 isolateDesktopUserDirs(t)
49 dir := config.SessionDir()
50 if err := os.MkdirAll(dir, 0o755); err != nil {
51 t.Fatalf("mkdir sessions: %v", err)
52 }
53 path := writeLegacySession(t, dir, "forced.jsonl", "force legacy migration", time.Now().Add(-time.Hour))
54 markTopicMigrationDone(dir)
55 markTopicIndexRepairDone(dir)
56 if migrated := migrateLegacySessionsIntoGlobalTopics(dir); len(migrated) != 0 {
57 t.Fatalf("ordinary migration ignored matching marker: %v", migrated)
58 }
59 if _, ok, err := agent.LoadBranchMeta(path); err != nil {
60 t.Fatalf("load legacy meta before forced migration: %v", err)
61 } else if ok {
62 t.Fatal("matching marker should have kept ordinary migration from creating meta")
63 }
64
65 migrated, migratedPaths := forceMigrateLegacySessionsIntoGlobalTopicsWithPaths(dir)
66 wantTopicID := legacySessionTopicID(path)
67 if len(migrated) != 1 || migrated[0] != wantTopicID {
68 t.Fatalf("forced migration = %v, want %q", migrated, wantTopicID)
69 }
70 if len(migratedPaths) != 1 || !sameDesktopPath(migratedPaths[0], path) {
71 t.Fatalf("forced migration paths = %v, want %q", migratedPaths, path)
72 }
73 meta, ok, err := agent.LoadBranchMeta(path)
74 if err != nil || !ok {
75 t.Fatalf("load forced migration meta: ok=%v err=%v", ok, err)
76 }
77 if strings.TrimSpace(meta.TopicID) != wantTopicID {
78 t.Fatalf("forced migration topic = %q, want %q", meta.TopicID, wantTopicID)
79 }
80 }
81
82 func TestTopicMigrationMarkerIncludesAuthoritativeEventLog(t *testing.T) {
83 isolateDesktopUserDirs(t)
84 dir := config.SessionDir()
85 if err := os.MkdirAll(dir, 0o755); err != nil {
86 t.Fatalf("mkdir sessions: %v", err)
87 }
88 path := writeLegacySession(t, dir, "event-aware.jsonl", "checkpoint", time.Now().Add(-time.Hour))
89 markTopicMigrationDone(dir)
90 if !topicMigrationDone(dir) {
91 t.Fatal("fresh migration marker should match")
92 }
93 eventPath := strings.TrimSuffix(path, ".jsonl") + ".events.jsonl"
94 if err := os.WriteFile(eventPath, []byte("{\"type\":\"session.header\",\"schemaVersion\":1}\n"), 0o644); err != nil {
95 t.Fatalf("write authoritative event log: %v", err)
96 }
97 if topicMigrationDone(dir) {
98 t.Fatalf("new event log %q must invalidate marker", filepath.Base(eventPath))
99 }
100 }
101
102 func TestRepairIndexedTopicDecodesStaleListingProjection(t *testing.T) {
103 isolateDesktopUserDirs(t)
104 dir := config.SessionDir()
105 if err := os.MkdirAll(dir, 0o755); err != nil {
106 t.Fatal(err)
107 }
108 sessionPath := writeLegacySession(t, dir, "stale-projection.jsonl", "authoritative first prompt", time.Now())
109 preview, _ := agent.SessionPreview(sessionPath)
110 wantTitle := topicTitleFromText(preview)
111 topicID := "legacy_stale_projection"
112 if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
113 ID: agent.BranchID(sessionPath), Scope: "global", TopicID: topicID,
114 Revision: 7, ContentDigest: "pre-upgrade-digest", SchemaVersion: agent.BranchMetaCountsVersion,
115 }); err != nil {
116 t.Fatal(err)
117 }
118 markTopicMigrationDone(dir)
119 if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != topicID {
120 t.Fatalf("repaired topics = %v, want %q", repaired, topicID)
121 }
122 if got := loadTopicTitle("", topicID); got != wantTitle {
123 t.Fatalf("repaired title = %q, want transcript preview title %q", got, wantTitle)
124 }
125 }
126
127 func TestRepairIndexedTopicDefersUnreadableTranscriptWithoutPersistingFallback(t *testing.T) {
128 isolateDesktopUserDirs(t)
129 dir := config.SessionDir()
130 if err := os.MkdirAll(dir, 0o755); err != nil {
131 t.Fatal(err)
132 }
133 sessionPath := writeLegacySession(t, dir, "temporarily-unreadable.jsonl", "original prompt", time.Now())
134 topicID := "legacy_temporarily_unreadable"
135 if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
136 ID: agent.BranchID(sessionPath), Scope: "global", TopicID: topicID,
137 Revision: 7, ContentDigest: "pre-upgrade-digest", SchemaVersion: agent.BranchMetaCountsVersion,
138 }); err != nil {
139 t.Fatal(err)
140 }
141 if err := os.WriteFile(sessionPath, []byte("{not-json\n"), 0o644); err != nil {
142 t.Fatal(err)
143 }
144 markTopicMigrationDone(dir)
145
146 if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 0 {
147 t.Fatalf("unreadable transcript repaired topics = %v, want none", repaired)
148 }
149 if topicIndexRepairDone(dir) {
150 t.Fatal("unreadable transcript must leave the repair marker open for retry")
151 }
152 if got := loadTopicTitle("", topicID); got != "" {
153 t.Fatalf("unreadable transcript persisted fallback title %q", got)
154 }
155 if got := loadTopicTitleSource("", topicID); got != "" {
156 t.Fatalf("unreadable transcript persisted fallback title source %q", got)
157 }
158 if containsDesktopString(loadProjectsFile().GlobalTopics, topicID) {
159 t.Fatal("unreadable transcript was added to the topic index")
160 }
161
162 const recoveredPrompt = "recovered authoritative prompt"
163 if err := os.WriteFile(sessionPath, []byte("{\"role\":\"user\",\"content\":\""+recoveredPrompt+"\"}\n"), 0o644); err != nil {
164 t.Fatal(err)
165 }
166 if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != topicID {
167 t.Fatalf("retry repaired topics = %v, want %q", repaired, topicID)
168 }
169 if got, want := loadTopicTitle("", topicID), topicTitleFromText(recoveredPrompt); got != want {
170 t.Fatalf("retry title = %q, want %q", got, want)
171 }
172 if !topicIndexRepairDone(dir) {
173 t.Fatal("successful retry should complete the repair marker")
174 }
175 }
176
177 func TestRepairIndexedTopicDefersUnreadableTitleSidecar(t *testing.T) {
178 isolateDesktopUserDirs(t)
179 dir := config.SessionDir()
180 if err := os.MkdirAll(dir, 0o755); err != nil {
181 t.Fatal(err)
182 }
183 sessionPath := writeLegacySession(t, dir, "custom-title.jsonl", "transcript fallback", time.Now())
184 topicID := "legacy_custom_title"
185 if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
186 ID: agent.BranchID(sessionPath), Scope: "global", TopicID: topicID,
187 Revision: 7, ContentDigest: "pre-upgrade-digest", SchemaVersion: agent.BranchMetaCountsVersion,
188 }); err != nil {
189 t.Fatal(err)
190 }
191 if err := os.WriteFile(sessionTitlesPath(dir), []byte("{not-json\n"), 0o644); err != nil {
192 t.Fatal(err)
193 }
194 markTopicMigrationDone(dir)
195
196 if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 0 {
197 t.Fatalf("unreadable title sidecar repaired topics = %v, want none", repaired)
198 }
199 if topicIndexRepairDone(dir) || loadTopicTitle("", topicID) != "" || loadTopicTitleSource("", topicID) != "" || containsDesktopString(loadProjectsFile().GlobalTopics, topicID) {
200 t.Fatal("unreadable title sidecar finalized indexed-topic repair")
201 }
202
203 const customTitle = "Recovered custom title"
204 titleJSON, err := json.Marshal(map[string]string{filepath.Base(sessionPath): customTitle})
205 if err != nil {
206 t.Fatal(err)
207 }
208 if err := os.WriteFile(sessionTitlesPath(dir), titleJSON, 0o644); err != nil {
209 t.Fatal(err)
210 }
211 if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != topicID {
212 t.Fatalf("restored title sidecar repaired topics = %v, want %q", repaired, topicID)
213 }
214 if got, want := loadTopicTitle("", topicID), topicTitleFromText(customTitle); got != want {
215 t.Fatalf("restored custom title = %q, want %q", got, want)
216 }
217 }
218
218 lines GO