| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/internal/agent" |
| 11 | "reasonix/internal/config" |
| 12 | "reasonix/internal/sessioninbox" |
| 13 | "reasonix/internal/store" |
| 14 | ) |
| 15 | |
| 16 | func TestRestoreTrashedSessionFileRejectsActiveInboxConflict(t *testing.T) { |
| 17 | dir := t.TempDir() |
| 18 | sessionPath := filepath.Join(dir, "session.jsonl") |
| 19 | if err := os.WriteFile(sessionPath, []byte("trash data"), 0o644); err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | oldReceipt := enqueueSessionInboxForRestoreTest(t, sessionPath, "old queued work") |
| 23 | if err := deleteSessionFile(dir, sessionPath); err != nil { |
| 24 | t.Fatalf("trash: %v", err) |
| 25 | } |
| 26 | |
| 27 | trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath)) |
| 28 | if err := os.WriteFile(sessionPath, nil, 0o644); err != nil { |
| 29 | t.Fatalf("write live stub: %v", err) |
| 30 | } |
| 31 | activeReceipt := enqueueSessionInboxForRestoreTest(t, sessionPath, "active queued work") |
| 32 | |
| 33 | err := restoreTrashedSessionFile(dir, trashPath) |
| 34 | if err == nil || !strings.Contains(err.Error(), "session artifact already exists: session.inbox") { |
| 35 | t.Fatalf("restore conflict error = %v, want active inbox conflict", err) |
| 36 | } |
| 37 | if info, err := os.Stat(sessionPath); err != nil || info.Size() != 0 { |
| 38 | t.Fatalf("active transcript stub should remain empty, info=%v err=%v", info, err) |
| 39 | } |
| 40 | assertSessionInboxItemForRestoreTest(t, sessionPath, activeReceipt.ItemID, "active queued work") |
| 41 | if _, err := os.Stat(trashPath); err != nil { |
| 42 | t.Fatalf("trash transcript should remain after rejected restore: %v", err) |
| 43 | } |
| 44 | assertSessionInboxItemForRestoreTest(t, trashPath, oldReceipt.ItemID, "old queued work") |
| 45 | } |
| 46 | |
| 47 | func TestRestoreTrashedSessionFileRejectsOrphanSidecarConflict(t *testing.T) { |
| 48 | dir := t.TempDir() |
| 49 | sessionPath := filepath.Join(dir, "orphan-sidecar.jsonl") |
| 50 | if err := os.WriteFile(sessionPath, []byte("trash data"), 0o644); err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | if err := deleteSessionFile(dir, sessionPath); err != nil { |
| 54 | t.Fatalf("trash: %v", err) |
| 55 | } |
| 56 | trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath)) |
| 57 | if err := os.WriteFile(sessionPath, nil, 0o644); err != nil { |
| 58 | t.Fatalf("write live stub: %v", err) |
| 59 | } |
| 60 | metaPath := store.SessionMeta(sessionPath) |
| 61 | if err := os.WriteFile(metaPath, []byte("active metadata"), 0o600); err != nil { |
| 62 | t.Fatalf("write active sidecar: %v", err) |
| 63 | } |
| 64 | |
| 65 | err := restoreTrashedSessionFile(dir, trashPath) |
| 66 | if err == nil || !strings.Contains(err.Error(), filepath.Base(metaPath)) { |
| 67 | t.Fatalf("restore conflict error = %v, want orphan sidecar conflict", err) |
| 68 | } |
| 69 | if got, err := os.ReadFile(metaPath); err != nil || string(got) != "active metadata" { |
| 70 | t.Fatalf("active sidecar = %q, %v; want preserved metadata", got, err) |
| 71 | } |
| 72 | if _, err := os.Stat(sessionPath); err != nil { |
| 73 | t.Fatalf("active transcript stub should remain: %v", err) |
| 74 | } |
| 75 | if _, err := os.Stat(trashPath); err != nil { |
| 76 | t.Fatalf("trash transcript should remain after rejected restore: %v", err) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | func TestRestoreTrashedSessionFilePreservesStubOnSubagentConflict(t *testing.T) { |
| 81 | dir := t.TempDir() |
| 82 | sessionPath := filepath.Join(dir, "subagent-conflict.jsonl") |
| 83 | if err := os.WriteFile(sessionPath, []byte("trash data"), 0o644); err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | ref := "sa_20260102_030405_000000000_aabbccddeeff" |
| 87 | writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath)) |
| 88 | if err := deleteSessionFile(dir, sessionPath); err != nil { |
| 89 | t.Fatalf("trash: %v", err) |
| 90 | } |
| 91 | trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath)) |
| 92 | if err := os.WriteFile(sessionPath, nil, 0o644); err != nil { |
| 93 | t.Fatalf("write live stub: %v", err) |
| 94 | } |
| 95 | activeSubagent := filepath.Join(dir, "subagents", ref+".jsonl") |
| 96 | if err := os.MkdirAll(filepath.Dir(activeSubagent), 0o755); err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | if err := os.WriteFile(activeSubagent, []byte("active subagent"), 0o600); err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | |
| 103 | if err := restoreTrashedSessionFile(dir, trashPath); err == nil { |
| 104 | t.Fatal("restore should reject an active subagent conflict") |
| 105 | } |
| 106 | if _, err := os.Stat(sessionPath); err != nil { |
| 107 | t.Fatalf("active transcript stub should survive preflight failure: %v", err) |
| 108 | } |
| 109 | if got, err := os.ReadFile(activeSubagent); err != nil || string(got) != "active subagent" { |
| 110 | t.Fatalf("active subagent = %q, %v; want preserved data", got, err) |
| 111 | } |
| 112 | if _, err := os.Stat(trashPath); err != nil { |
| 113 | t.Fatalf("trash transcript should remain after rejected restore: %v", err) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | func TestLegacyRestoreSessionFinishesCommittedPartialTrashMove(t *testing.T) { |
| 118 | isolateDesktopUserDirs(t) |
| 119 | |
| 120 | dir := config.SessionDir() |
| 121 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 122 | t.Fatalf("mkdir sessions: %v", err) |
| 123 | } |
| 124 | sessionPath := filepath.Join(dir, "partial-restore.jsonl") |
| 125 | itemDir := filepath.Join(sessionTrashPath(dir), filepath.Base(sessionPath)) |
| 126 | if err := os.MkdirAll(itemDir, 0o755); err != nil { |
| 127 | t.Fatalf("mkdir trash item: %v", err) |
| 128 | } |
| 129 | trashPath := filepath.Join(itemDir, filepath.Base(sessionPath)) |
| 130 | const transcript = `{"role":"user","content":"trashed transcript"}` + "\n" |
| 131 | if err := os.WriteFile(trashPath, []byte(transcript), 0o600); err != nil { |
| 132 | t.Fatalf("write trashed transcript: %v", err) |
| 133 | } |
| 134 | metaPath := store.SessionMeta(sessionPath) |
| 135 | if err := os.WriteFile(metaPath, []byte(`{"scope":"global","topic_id":"topic_partial","topic_title":"Partial"}`), 0o600); err != nil { |
| 136 | t.Fatalf("write leftover live metadata: %v", err) |
| 137 | } |
| 138 | if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil { |
| 139 | t.Fatalf("mark cleanup pending: %v", err) |
| 140 | } |
| 141 | |
| 142 | app := NewApp() |
| 143 | t.Cleanup(func() { app.stopSessionCatalog(time.Second) }) |
| 144 | if err := app.restoreSession(trashPath); err != nil { |
| 145 | t.Fatalf("restore partial trash move: %v", err) |
| 146 | } |
| 147 | if got, err := os.ReadFile(sessionPath); err != nil || string(got) != transcript { |
| 148 | t.Fatalf("restored transcript = %q, %v", got, err) |
| 149 | } |
| 150 | if meta, ok, err := agent.LoadBranchMeta(sessionPath); err != nil || !ok || meta.TopicID != "topic_partial" { |
| 151 | t.Fatalf("restored metadata = %+v, %v, %v", meta, ok, err) |
| 152 | } |
| 153 | if agent.IsCleanupPending(sessionPath) { |
| 154 | t.Fatal("restore retained cleanup-pending marker") |
| 155 | } |
| 156 | if _, err := os.Stat(itemDir); !os.IsNotExist(err) { |
| 157 | t.Fatalf("trash item survived restore: %v", err) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | func enqueueSessionInboxForRestoreTest(t *testing.T, sessionPath, text string) sessioninbox.InboxReceipt { |
| 162 | t.Helper() |
| 163 | inbox, err := sessioninbox.Open(sessionPath, sessioninbox.Limits{}) |
| 164 | if err != nil { |
| 165 | t.Fatalf("open inbox: %v", err) |
| 166 | } |
| 167 | receipt, err := inbox.Enqueue(sessioninbox.EnqueueRequest{ |
| 168 | Envelope: sessioninbox.PromptEnvelope{DisplayText: text, SubmitText: text}, |
| 169 | }) |
| 170 | inbox.Close() |
| 171 | if err != nil { |
| 172 | t.Fatalf("enqueue inbox item: %v", err) |
| 173 | } |
| 174 | return receipt |
| 175 | } |
| 176 | |
| 177 | func assertSessionInboxItemForRestoreTest(t *testing.T, sessionPath, itemID, want string) { |
| 178 | t.Helper() |
| 179 | inbox, err := sessioninbox.Open(sessionPath, sessioninbox.Limits{}) |
| 180 | if err != nil { |
| 181 | t.Fatalf("reopen inbox: %v", err) |
| 182 | } |
| 183 | defer inbox.Close() |
| 184 | _, envelope, err := inbox.ReadItem(itemID) |
| 185 | if err != nil { |
| 186 | t.Fatalf("read inbox item %s: %v", itemID, err) |
| 187 | } |
| 188 | if envelope.SubmitText != want { |
| 189 | t.Fatalf("inbox item %s = %q, want %q", itemID, envelope.SubmitText, want) |
| 190 | } |
| 191 | } |
| 192 |