返回 DeepSeek-Reasonix
topic_state_hardening_test.go
根目录 / desktop / topic_state_hardening_test.go
1 package main
2
3 import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "reasonix/internal/agent"
15 "reasonix/internal/config"
16 "reasonix/internal/topicstate"
17 )
18
19 func TestTopicIndexRepairDoesNotOverwriteConcurrentManualRename(t *testing.T) {
20 isolateDesktopUserDirs(t)
21 workspaceRoot := t.TempDir()
22 seedLegacyTopicBridge(t, workspaceRoot)
23 if err := setTopicTitleWithSource(workspaceRoot, "topic-1", "Before repair", topicTitleSourceAuto); err != nil {
24 t.Fatal(err)
25 }
26 staleTitles := loadTopicTitles(workspaceRoot)
27 staleSources := loadTopicTitleSources(workspaceRoot)
28 staleTitles["topic-2"] = "Recovered topic"
29 staleSources["topic-2"] = topicTitleSourceManual
30
31 startSave := make(chan struct{})
32 done := make(chan error, 1)
33 go func() {
34 <-startSave
35 done <- saveTopicTitleIndex(workspaceRoot, staleTitles, staleSources)
36 }()
37 if err := setTopicTitle(workspaceRoot, "topic-1", "Manual rename wins"); err != nil {
38 t.Fatal(err)
39 }
40 close(startSave)
41 if err := <-done; err != nil {
42 t.Fatal(err)
43 }
44 if got := loadTopicTitle(workspaceRoot, "topic-1"); got != "Manual rename wins" {
45 t.Fatalf("manual rename was overwritten: %q", got)
46 }
47 if got := loadTopicTitle(workspaceRoot, "topic-2"); got != "Recovered topic" {
48 t.Fatalf("missing title was not repaired: %q", got)
49 }
50 legacy, err := loadLegacyStringMap(topicTitlesPath(workspaceRoot))
51 if err != nil {
52 t.Fatal(err)
53 }
54 if legacy["topic-1"] != "Manual rename wins" {
55 t.Fatalf("legacy mirror reverted manual rename: %#v", legacy)
56 }
57 }
58
59 func TestRestoreTombstonedTopicInLegacyBridgeScope(t *testing.T) {
60 isolateDesktopUserDirs(t)
61 workspaceRoot := t.TempDir()
62 if err := addProject(workspaceRoot, ""); err != nil {
63 t.Fatal(err)
64 }
65 seedLegacyTopicBridge(t, workspaceRoot)
66 topicID := "topic-restored"
67 if err := setTopicTitle(workspaceRoot, topicID, "Before delete"); err != nil {
68 t.Fatal(err)
69 }
70 if err := deleteTopicState(workspaceRoot, topicID); err != nil {
71 t.Fatal(err)
72 }
73 if err := removeTopicFromProjectsFile(topicID); err != nil {
74 t.Fatal(err)
75 }
76 dir := desktopSessionDir(workspaceRoot)
77 if err := os.MkdirAll(dir, 0o755); err != nil {
78 t.Fatal(err)
79 }
80 path := filepath.Join(dir, "restored.jsonl")
81 if err := os.WriteFile(path, []byte(`{"role":"user","content":"restore"}`+"\n"), 0o600); err != nil {
82 t.Fatal(err)
83 }
84 if err := agent.SaveBranchMeta(path, agent.BranchMeta{
85 CreatedAt: time.Now().Add(-time.Minute), Scope: "project", WorkspaceRoot: workspaceRoot,
86 TopicID: topicID, TopicTitle: "Restored title",
87 }); err != nil {
88 t.Fatal(err)
89 }
90
91 if err := restoreSessionTopicIndex(dir, path); err != nil {
92 t.Fatal(err)
93 }
94 projects := loadProjectsFile()
95 if containsDesktopString(projects.DeletedTopics, topicID) {
96 t.Fatalf("restore left tombstone behind: %#v", projects.DeletedTopics)
97 }
98 projectIndex := projectIndexByRoot(projects.Projects, workspaceRoot)
99 if projectIndex < 0 || !containsDesktopString(projects.Projects[projectIndex].Topics, topicID) {
100 t.Fatalf("restored topic is not indexed: %#v", projects.Projects)
101 }
102 if got := loadTopicTitle(workspaceRoot, topicID); got != "Restored title" {
103 t.Fatalf("authoritative restored title = %q", got)
104 }
105 legacy, err := loadLegacyStringMap(topicTitlesPath(workspaceRoot))
106 if err != nil {
107 t.Fatal(err)
108 }
109 if legacy[topicID] != "Restored title" {
110 t.Fatalf("legacy restored title = %q", legacy[topicID])
111 }
112 }
113
114 func TestLegacyAutoMetaMergePreservesDatabaseOnlyUnknownFields(t *testing.T) {
115 isolateDesktopUserDirs(t)
116 workspaceRoot := t.TempDir()
117 seedLegacyTopicBridge(t, workspaceRoot)
118 path := topicAutoTitleMetaPath(workspaceRoot)
119 if err := os.WriteFile(path, []byte(`{"topic-1":{"stage":1,"future":{"kept":true}}}`), 0o600); err != nil {
120 t.Fatal(err)
121 }
122 if got := loadTopicAutoTitleMeta(workspaceRoot)["topic-1"].Stage; got != 1 {
123 t.Fatalf("initial stage = %d", got)
124 }
125 if err := os.WriteFile(path, []byte(`{"topic-1":{"stage":3,"basisHash":"old-writer"}}`), 0o600); err != nil {
126 t.Fatal(err)
127 }
128 if err := setTopicCreatedAt(workspaceRoot, "topic-1", 1234); err != nil {
129 t.Fatal(err)
130 }
131 snapshot, err := desktopTopicState.snapshot(workspaceRoot)
132 if err != nil {
133 t.Fatal(err)
134 }
135 raw := snapshot.Records["topic-1"].AutoMeta
136 if !containsJSONKey(raw, "future") {
137 t.Fatalf("database-only unknown field was lost: %s", raw)
138 }
139 var meta topicAutoTitleMeta
140 if err := json.Unmarshal(raw, &meta); err != nil {
141 t.Fatal(err)
142 }
143 if meta.Stage != 3 || meta.BasisHash != "old-writer" {
144 t.Fatalf("legacy known fields were not merged: %+v", meta)
145 }
146 }
147
148 func TestRestartRepairsPendingMirrorBeforeLegacyReconciliation(t *testing.T) {
149 isolateDesktopUserDirs(t)
150 workspaceRoot := t.TempDir()
151 seedLegacyTopicBridge(t, workspaceRoot)
152 if err := createTopicState(workspaceRoot, "topic-1", defaultTopicTitle, topicTitleSourceAuto, 123); err != nil {
153 t.Fatal(err)
154 }
155 initial := autoTopicTitleProposal{Title: "Initial title", Stage: 1, UserTurns: 1, BasisHash: "initial"}
156 if applied, err := applyAutoTopicTitle(workspaceRoot, "topic-1", initial.Title, initial); err != nil || !applied {
157 t.Fatalf("apply initial title: applied=%v err=%v", applied, err)
158 }
159
160 topicLegacyWriteHookForTest = func(path string) error {
161 if path == topicAutoTitleMetaPath(workspaceRoot) {
162 return errors.New("injected partial legacy mirror")
163 }
164 return nil
165 }
166 t.Cleanup(func() { topicLegacyWriteHookForTest = nil })
167 updated := autoTopicTitleProposal{Title: "Updated title", Stage: 3, UserTurns: 4, BasisHash: "updated"}
168 if applied, err := applyAutoTopicTitle(workspaceRoot, "topic-1", updated.Title, updated); err != nil || !applied {
169 t.Fatalf("apply updated title: applied=%v err=%v", applied, err)
170 }
171 // A second write while the mirror is still failing must advance SQLite,
172 // not silently succeed through the legacy-only fallback.
173 if err := setTopicCreatedAt(workspaceRoot, "topic-1", 999); err != nil {
174 t.Fatal(err)
175 }
176 desktopTopicState.close()
177 topicLegacyWriteHookForTest = nil
178
179 snapshot, err := desktopTopicState.snapshot(workspaceRoot)
180 if err != nil {
181 t.Fatal(err)
182 }
183 record := snapshot.Records["topic-1"]
184 var meta topicAutoTitleMeta
185 if err := json.Unmarshal(record.AutoMeta, &meta); err != nil {
186 t.Fatal(err)
187 }
188 if record.Title != updated.Title || record.CreatedAtMS != 999 || meta.Stage != updated.Stage || meta.BasisHash != updated.BasisHash {
189 t.Fatalf("pending mirror rolled SQLite back: record=%+v meta=%+v", record, meta)
190 }
191 legacy, err := loadLegacyAutoMetaMap(topicAutoTitleMetaPath(workspaceRoot))
192 if err != nil {
193 t.Fatal(err)
194 }
195 if got := legacy["topic-1"]; got.Stage != updated.Stage || got.BasisHash != updated.BasisHash {
196 t.Fatalf("pending mirror was not repaired: %+v", got)
197 }
198 }
199
200 func TestCompoundTopicMutationsCommitOneRevision(t *testing.T) {
201 isolateDesktopUserDirs(t)
202 workspaceRoot := t.TempDir()
203 if err := createTopicState(workspaceRoot, "topic-1", defaultTopicTitle, topicTitleSourceAuto, 123); err != nil {
204 t.Fatal(err)
205 }
206 created, err := desktopTopicState.snapshot(workspaceRoot)
207 if err != nil {
208 t.Fatal(err)
209 }
210 if created.State.Revision != 1 {
211 t.Fatalf("create revision = %d, want 1", created.State.Revision)
212 }
213 record := created.Records["topic-1"]
214 if record.Title != defaultTopicTitle || record.TitleSource != topicTitleSourceAuto || record.CreatedAtMS != 123 {
215 t.Fatalf("created record is partial: %+v", record)
216 }
217 proposal := autoTopicTitleProposal{Title: "Generated title", Stage: 2, UserTurns: 3, BasisHash: "basis"}
218 if applied, err := applyAutoTopicTitle(workspaceRoot, "topic-1", proposal.Title, proposal); err != nil {
219 t.Fatal(err)
220 } else if !applied {
221 t.Fatal("automatic title was not applied")
222 }
223 updated, err := desktopTopicState.snapshot(workspaceRoot)
224 if err != nil {
225 t.Fatal(err)
226 }
227 if updated.State.Revision != 2 {
228 t.Fatalf("auto-title revision = %d, want 2", updated.State.Revision)
229 }
230 record = updated.Records["topic-1"]
231 var meta topicAutoTitleMeta
232 if err := json.Unmarshal(record.AutoMeta, &meta); err != nil {
233 t.Fatal(err)
234 }
235 if record.Title != proposal.Title || record.TitleSource != topicTitleSourceAuto || meta.Stage != proposal.Stage || meta.BasisHash != proposal.BasisHash {
236 t.Fatalf("auto-title record is partial: record=%+v meta=%+v", record, meta)
237 }
238 }
239
240 func TestAutomaticTitleDoesNotOverwriteConcurrentManualRename(t *testing.T) {
241 isolateDesktopUserDirs(t)
242 workspaceRoot := t.TempDir()
243 if err := createTopicState(workspaceRoot, "topic-1", defaultTopicTitle, topicTitleSourceAuto, 123); err != nil {
244 t.Fatal(err)
245 }
246 proposal := autoTopicTitleProposal{Title: "Stale automatic title", Stage: 2, UserTurns: 3, BasisHash: "basis"}
247 if err := setTopicTitle(workspaceRoot, "topic-1", "Manual rename wins"); err != nil {
248 t.Fatal(err)
249 }
250 if applied, err := applyAutoTopicTitle(workspaceRoot, "topic-1", proposal.Title, proposal); err != nil {
251 t.Fatal(err)
252 } else if applied {
253 t.Fatal("stale automatic title overwrote a manual rename")
254 }
255 snapshot, err := desktopTopicState.snapshot(workspaceRoot)
256 if err != nil {
257 t.Fatal(err)
258 }
259 record := snapshot.Records["topic-1"]
260 if record.Title != "Manual rename wins" || record.TitleSource != topicTitleSourceManual || len(record.AutoMeta) != 0 {
261 t.Fatalf("manual record changed: %+v", record)
262 }
263 }
264
265 func TestManualRenamePublishesAfterInFlightAutomaticTitle(t *testing.T) {
266 isolateDesktopUserDirs(t)
267 workspaceRoot := t.TempDir()
268 app := NewApp()
269 topic, err := app.CreateTopic("project", workspaceRoot, "")
270 if err != nil {
271 t.Fatal(err)
272 }
273 dir := desktopSessionDir(workspaceRoot)
274 if err := os.MkdirAll(dir, 0o755); err != nil {
275 t.Fatal(err)
276 }
277 path := writeTopicSessionWithPrompt(t, dir, "rename-race.jsonl", topic.ID, defaultTopicTitle, workspaceRoot, "automatic candidate", time.Now())
278 ctrl := controllerWithContent(t, path)
279 defer ctrl.Close()
280 tab := &WorkspaceTab{
281 ID: "rename-race", Scope: "project", WorkspaceRoot: workspaceRoot,
282 TopicID: topic.ID, TopicTitle: defaultTopicTitle, topicTitleSource: topicTitleSourceAuto,
283 SessionPath: path, Ctrl: ctrl,
284 }
285 app.tabs[tab.ID] = tab
286 app.tabOrder = []string{tab.ID}
287
288 autoCommitted := make(chan struct{})
289 releaseAuto := make(chan struct{})
290 topicAutoTitleCommittedHookForTest = func() {
291 close(autoCommitted)
292 <-releaseAuto
293 }
294 t.Cleanup(func() { topicAutoTitleCommittedHookForTest = nil })
295 autoDone := make(chan bool, 1)
296 go func() { autoDone <- app.maybeAutoTitleTopic(tab) }()
297 <-autoCommitted
298
299 renameDone := make(chan error, 1)
300 go func() { renameDone <- app.RenameTopic(topic.ID, "Manual title wins") }()
301 select {
302 case err := <-renameDone:
303 t.Fatalf("manual rename bypassed in-flight auto publication: %v", err)
304 case <-time.After(50 * time.Millisecond):
305 }
306 close(releaseAuto)
307 if updated := <-autoDone; !updated {
308 t.Fatal("automatic title was not applied before the manual rename")
309 }
310 if err := <-renameDone; err != nil {
311 t.Fatal(err)
312 }
313
314 if got := loadTopicTitle(workspaceRoot, topic.ID); got != "Manual title wins" {
315 t.Fatalf("authoritative title = %q", got)
316 }
317 if tab.TopicTitle != "Manual title wins" || tab.topicTitleSource != topicTitleSourceManual {
318 t.Fatalf("tab title publication = %q source=%q", tab.TopicTitle, tab.topicTitleSource)
319 }
320 meta, ok, err := agent.LoadBranchMeta(path)
321 if err != nil || !ok {
322 t.Fatalf("load branch metadata: ok=%v err=%v", ok, err)
323 }
324 if meta.TopicTitle != "Manual title wins" {
325 t.Fatalf("session metadata title = %q", meta.TopicTitle)
326 }
327 }
328
329 func TestFutureTopicSchemaWithoutLegacyReturnsVisibleReadError(t *testing.T) {
330 isolateDesktopUserDirs(t)
331 workspaceRoot := t.TempDir()
332 path := config.DesktopTopicStatePath(workspaceRoot)
333 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
334 t.Fatal(err)
335 }
336 db, err := sql.Open("sqlite", path)
337 if err != nil {
338 t.Fatal(err)
339 }
340 if _, err := db.Exec(`CREATE TABLE schema_migrations(version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`); err != nil {
341 t.Fatal(err)
342 }
343 if _, err := db.Exec(`INSERT INTO schema_migrations(version, applied_at) VALUES(2, 0)`); err != nil {
344 t.Fatal(err)
345 }
346 if err := db.Close(); err != nil {
347 t.Fatal(err)
348 }
349 if _, err := loadTopicTitlesForUpdate(workspaceRoot); err == nil {
350 t.Fatal("future schema without legacy unexpectedly became an empty title map")
351 } else {
352 var future *topicstate.FutureSchemaError
353 if !errors.As(err, &future) {
354 t.Fatalf("update read error = %v, want FutureSchemaError", err)
355 }
356 }
357 _, err = NewApp().ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: workspaceRoot})
358 if err == nil || !strings.Contains(err.Error(), "newer Reasonix version") {
359 t.Fatalf("bridge read error = %v", err)
360 }
361 if strings.Contains(err.Error(), workspaceRoot) {
362 t.Fatalf("bridge read error leaked workspace path: %v", err)
363 }
364 }
365
366 func TestCorruptTopicDatabaseRecoversFromSessionMetadataWithoutLegacy(t *testing.T) {
367 isolateDesktopUserDirs(t)
368 workspaceRoot := t.TempDir()
369 dir := desktopSessionDir(workspaceRoot)
370 if err := os.MkdirAll(dir, 0o755); err != nil {
371 t.Fatal(err)
372 }
373 liveID := "topic-live"
374 deletedID := "topic-deleted"
375 for _, entry := range []struct{ id, name, title string }{
376 {liveID, "live.jsonl", "Recovered title"},
377 {deletedID, "deleted.jsonl", "Must stay deleted"},
378 } {
379 path := filepath.Join(dir, entry.name)
380 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o600); err != nil {
381 t.Fatal(err)
382 }
383 if err := agent.SaveBranchMeta(path, agent.BranchMeta{
384 CreatedAt: time.Now().Add(-time.Hour), Scope: "project", WorkspaceRoot: workspaceRoot,
385 TopicID: entry.id, TopicTitle: entry.title,
386 }); err != nil {
387 t.Fatal(err)
388 }
389 }
390 if err := updateProjectsFile(func(file *desktopProjectFile) (bool, error) {
391 file.DeletedTopics = append(file.DeletedTopics, deletedID)
392 return true, nil
393 }); err != nil {
394 t.Fatal(err)
395 }
396 dbPath := config.DesktopTopicStatePath(workspaceRoot)
397 if err := os.MkdirAll(filepath.Dir(dbPath), 0o700); err != nil {
398 t.Fatal(err)
399 }
400 if err := os.WriteFile(dbPath, []byte("not a sqlite database"), 0o600); err != nil {
401 t.Fatal(err)
402 }
403
404 if err := setTopicCreatedAt(workspaceRoot, liveID, 5678); err != nil {
405 t.Fatal(err)
406 }
407 snapshot, err := desktopTopicState.snapshot(workspaceRoot)
408 if err != nil {
409 t.Fatal(err)
410 }
411 if got := snapshot.Records[liveID]; got.Title != "Recovered title" || got.CreatedAtMS != 5678 {
412 t.Fatalf("recovered record = %+v", got)
413 }
414 if _, ok := snapshot.Records[deletedID]; ok {
415 t.Fatalf("tombstoned topic was recovered: %+v", snapshot.Records[deletedID])
416 }
417 if matches, _ := filepath.Glob(dbPath + ".corrupt-*"); len(matches) != 1 {
418 t.Fatalf("corrupt backups = %#v", matches)
419 }
420 for _, legacyPath := range legacyTopicPaths(workspaceRoot) {
421 if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
422 t.Fatalf("session recovery created legacy file %s: %v", filepath.Base(legacyPath), err)
423 }
424 }
425 state, err := topicstate.Open(context.Background(), dbPath)
426 if err != nil {
427 t.Fatal(err)
428 }
429 _ = state.Close()
430 }
431
431 lines GO