返回 DeepSeek-Reasonix
sessions_test.go
根目录 / desktop / sessions_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 "reasonix/internal/agent"
16 "reasonix/internal/jobs"
17 "reasonix/internal/store"
18 )
19
20 func occupyReadFileWithTimeoutSlots(t *testing.T) func() {
21 t.Helper()
22 filled := 0
23 for filled < cap(readFileWithTimeoutSlots) {
24 readFileWithTimeoutSlots <- struct{}{}
25 filled++
26 }
27 released := false
28 release := func() {
29 if released {
30 return
31 }
32 released = true
33 for i := 0; i < filled; i++ {
34 <-readFileWithTimeoutSlots
35 }
36 }
37 t.Cleanup(release)
38 return release
39 }
40
41 // --- loadSessionTitles ---
42
43 func TestLoadSessionTitlesMissing(t *testing.T) {
44 dir := t.TempDir()
45 m := loadSessionTitles(dir)
46 if len(m) != 0 {
47 t.Errorf("missing file should return empty map, got %v", m)
48 }
49 }
50
51 func TestLoadSessionTitlesCorrupt(t *testing.T) {
52 dir := t.TempDir()
53 os.WriteFile(sessionTitlesPath(dir), []byte(`{not json`), 0o644)
54 m := loadSessionTitles(dir)
55 if len(m) != 0 {
56 t.Errorf("corrupt file should return empty map, got %v", m)
57 }
58 }
59
60 func TestLoadSessionTitlesValid(t *testing.T) {
61 dir := t.TempDir()
62 data := map[string]string{"session-1.jsonl": "My Session", "session-2.jsonl": "Another"}
63 b, _ := json.Marshal(data)
64 os.WriteFile(sessionTitlesPath(dir), b, 0o644)
65 m := loadSessionTitles(dir)
66 if m["session-1.jsonl"] != "My Session" || m["session-2.jsonl"] != "Another" {
67 t.Errorf("loaded = %v", m)
68 }
69 }
70
71 // --- saveSessionTitles ---
72
73 func TestSaveSessionTitlesCreatesDir(t *testing.T) {
74 dir := filepath.Join(t.TempDir(), "nested", "sessions")
75 m := map[string]string{"a.jsonl": "title A"}
76 if err := saveSessionTitles(dir, m); err != nil {
77 t.Fatalf("save: %v", err)
78 }
79 // Verify file exists and is valid JSON.
80 b, err := os.ReadFile(sessionTitlesPath(dir))
81 if err != nil {
82 t.Fatalf("read: %v", err)
83 }
84 var decoded map[string]string
85 if err := json.Unmarshal(b, &decoded); err != nil {
86 t.Fatalf("unmarshal: %v", err)
87 }
88 if decoded["a.jsonl"] != "title A" {
89 t.Errorf("decoded = %v", decoded)
90 }
91 }
92
93 func TestSaveSessionTitlesRoundTrip(t *testing.T) {
94 dir := t.TempDir()
95 original := map[string]string{"s1.jsonl": "First", "s2.jsonl": "Second"}
96 if err := saveSessionTitles(dir, original); err != nil {
97 t.Fatalf("save: %v", err)
98 }
99 loaded := loadSessionTitles(dir)
100 if loaded["s1.jsonl"] != "First" || loaded["s2.jsonl"] != "Second" {
101 t.Errorf("round-trip = %v", loaded)
102 }
103 }
104
105 // --- setSessionTitle ---
106
107 func TestSetSessionTitle(t *testing.T) {
108 dir := t.TempDir()
109 sessionPath := filepath.Join(dir, "my-session.jsonl")
110
111 // Set a title.
112 if err := setSessionTitle(dir, sessionPath, "Custom Title"); err != nil {
113 t.Fatalf("set: %v", err)
114 }
115 m := loadSessionTitles(dir)
116 if m["my-session.jsonl"] != "Custom Title" {
117 t.Errorf("title = %q", m["my-session.jsonl"])
118 }
119
120 // Clear the title (empty string).
121 if err := setSessionTitle(dir, sessionPath, ""); err != nil {
122 t.Fatalf("clear: %v", err)
123 }
124 m = loadSessionTitles(dir)
125 if _, ok := m["my-session.jsonl"]; ok {
126 t.Error("cleared title should be removed from map")
127 }
128 }
129
130 func TestSetSessionTitleTrimsWhitespace(t *testing.T) {
131 dir := t.TempDir()
132 sessionPath := filepath.Join(dir, "s.jsonl")
133 if err := setSessionTitle(dir, sessionPath, " trimmed "); err != nil {
134 t.Fatalf("set: %v", err)
135 }
136 m := loadSessionTitles(dir)
137 if m["s.jsonl"] != "trimmed" {
138 t.Errorf("title = %q, want trimmed", m["s.jsonl"])
139 }
140 }
141
142 func TestSetSessionTitlePreservesExistingTitlesWhenTimedReadSlotsFull(t *testing.T) {
143 dir := t.TempDir()
144 if err := saveSessionTitles(dir, map[string]string{"old.jsonl": "Old"}); err != nil {
145 t.Fatalf("save old title: %v", err)
146 }
147 sessionPath := filepath.Join(dir, "new.jsonl")
148 release := occupyReadFileWithTimeoutSlots(t)
149 if err := setSessionTitle(dir, sessionPath, "New"); err != nil {
150 t.Fatalf("setSessionTitle: %v", err)
151 }
152 release()
153
154 m := loadSessionTitles(dir)
155 if got := m["old.jsonl"]; got != "Old" {
156 t.Fatalf("old title = %q, want Old (all titles: %v)", got, m)
157 }
158 if got := m["new.jsonl"]; got != "New" {
159 t.Fatalf("new title = %q, want New (all titles: %v)", got, m)
160 }
161 }
162
163 // --- deleteSessionFile ---
164
165 func TestDeleteSessionFile(t *testing.T) {
166 dir := t.TempDir()
167 sessionPath := filepath.Join(dir, "session.jsonl")
168 os.WriteFile(sessionPath, []byte("data"), 0o644)
169 metaPath := sessionPath + ".meta"
170 os.WriteFile(metaPath, []byte("{}"), 0o644)
171 goalPath := store.SessionGoalState(sessionPath)
172 os.WriteFile(goalPath, []byte(`{"goal":"ship"}`), 0o644)
173 eventLogPath := store.SessionEventLog(sessionPath)
174 os.WriteFile(eventLogPath, []byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"event"}]}`+"\n"), 0o644)
175 eventIndexPath := store.SessionEventIndex(sessionPath)
176 os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644)
177 conflictLogPath := store.SessionConflictLog(sessionPath)
178 os.WriteFile(conflictLogPath, []byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644)
179 recoveryPath := store.SessionRecoveryState(sessionPath)
180 os.WriteFile(recoveryPath, []byte(`{"tasks":{"root":{"phase":"diagnosing"}}}`+"\n"), 0o600)
181 telemetryPath := sessionTelemetryPath(sessionPath)
182 os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644)
183 lockPath := store.SessionLockFile(sessionPath)
184 os.WriteFile(lockPath, nil, 0o644)
185 leaseLockPath := store.SessionLeaseLock(sessionPath)
186 os.WriteFile(leaseLockPath, nil, 0o644)
187 leaseInfoPath := store.SessionLeaseInfo(sessionPath)
188 os.WriteFile(leaseInfoPath, []byte(`{"writer_id":"stale"}`), 0o644)
189 ckptDir := filepath.Join(dir, "session.ckpt")
190 if err := os.MkdirAll(ckptDir, 0o755); err != nil {
191 t.Fatalf("mkdir ckpt: %v", err)
192 }
193 os.WriteFile(filepath.Join(ckptDir, "1.json"), []byte("{}"), 0o644)
194 jobsDir := jobs.ArtifactDir(sessionPath)
195 if err := os.MkdirAll(jobsDir, 0o755); err != nil {
196 t.Fatalf("mkdir jobs: %v", err)
197 }
198 if err := os.WriteFile(filepath.Join(jobsDir, "bash-1.log"), []byte("output"), 0o644); err != nil {
199 t.Fatalf("write job artifact: %v", err)
200 }
201
202 // Set a title first.
203 setSessionTitle(dir, sessionPath, "My Title")
204 if err := recordSessionDisplay(dir, sessionPath, "expanded prompt", "[Pasted text #1 · 5 lines]"); err != nil {
205 t.Fatalf("record display: %v", err)
206 }
207
208 if err := deleteSessionFile(dir, sessionPath); err != nil {
209 t.Fatalf("delete: %v", err)
210 }
211 trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
212 trashMetaPath := trashPath + ".meta"
213 trashGoalPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.goal-state.json")
214 trashEventLogPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.events.jsonl")
215 trashEventIndexPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.event-index.json")
216 trashConflictLogPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.conflicts.jsonl")
217 trashRecoveryPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.recovery.json")
218 trashTelemetryPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.telemetry.json")
219 trashCkptDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.ckpt")
220 trashJobsDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jobs")
221
222 // File should be moved out of the active session list.
223 if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
224 t.Error("session file should be removed from active sessions")
225 }
226 if _, err := os.Stat(metaPath); !os.IsNotExist(err) {
227 t.Error("session meta should be removed from active sessions")
228 }
229 if _, err := os.Stat(goalPath); !os.IsNotExist(err) {
230 t.Error("session goal state should be removed from active sessions")
231 }
232 if _, err := os.Stat(eventLogPath); !os.IsNotExist(err) {
233 t.Error("session event log should be removed from active sessions")
234 }
235 if _, err := os.Stat(eventIndexPath); !os.IsNotExist(err) {
236 t.Error("session event index should be removed from active sessions")
237 }
238 if _, err := os.Stat(conflictLogPath); !os.IsNotExist(err) {
239 t.Error("session conflict log should be removed from active sessions")
240 }
241 if _, err := os.Stat(recoveryPath); !os.IsNotExist(err) {
242 t.Error("session recovery state should be removed from active sessions")
243 }
244 if _, err := os.Stat(telemetryPath); !os.IsNotExist(err) {
245 t.Error("session telemetry should be removed from active sessions")
246 }
247 if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
248 t.Error("session lock should be removed from active sessions")
249 }
250 if _, err := os.Stat(leaseLockPath); !os.IsNotExist(err) {
251 t.Error("session lease lock should be removed from active sessions")
252 }
253 if _, err := os.Stat(leaseInfoPath); !os.IsNotExist(err) {
254 t.Error("session lease info should be removed from active sessions")
255 }
256 if _, err := os.Stat(ckptDir); !os.IsNotExist(err) {
257 t.Error("session checkpoints should be removed from active sessions")
258 }
259 if _, err := os.Stat(jobsDir); !os.IsNotExist(err) {
260 t.Error("session jobs should be removed from active sessions")
261 }
262 if _, err := os.Stat(trashPath); err != nil {
263 t.Fatalf("session file should be in trash: %v", err)
264 }
265 if _, err := os.Stat(trashMetaPath); err != nil {
266 t.Fatalf("session meta should be in trash: %v", err)
267 }
268 if _, err := os.Stat(trashGoalPath); err != nil {
269 t.Fatalf("session goal state should be in trash: %v", err)
270 }
271 if _, err := os.Stat(trashEventLogPath); err != nil {
272 t.Fatalf("session event log should be in trash: %v", err)
273 }
274 if _, err := os.Stat(trashEventIndexPath); err != nil {
275 t.Fatalf("session event index should be in trash: %v", err)
276 }
277 if _, err := os.Stat(trashConflictLogPath); err != nil {
278 t.Fatalf("session conflict log should be in trash: %v", err)
279 }
280 if _, err := os.Stat(trashRecoveryPath); err != nil {
281 t.Fatalf("session recovery state should be in trash: %v", err)
282 }
283 if _, err := os.Stat(trashTelemetryPath); err != nil {
284 t.Fatalf("session telemetry should be in trash: %v", err)
285 }
286 for _, p := range []string{
287 filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lock"),
288 filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lease.lock"),
289 filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lease.json"),
290 } {
291 if _, err := os.Stat(p); !os.IsNotExist(err) {
292 t.Fatalf("ephemeral session artifact should not be moved to trash: %s (err=%v)", p, err)
293 }
294 }
295 if _, err := os.Stat(trashCkptDir); err != nil {
296 t.Fatalf("session checkpoints should be in trash: %v", err)
297 }
298 if _, err := os.Stat(filepath.Join(trashJobsDir, "bash-1.log")); err != nil {
299 t.Fatalf("session jobs should be in trash: %v", err)
300 }
301 // Title/display should be retained until permanent deletion.
302 m := loadSessionTitles(dir)
303 if m["session.jsonl"] != "My Title" {
304 t.Errorf("title should be retained in trash, got %q", m["session.jsonl"])
305 }
306 if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "[Pasted text #1 · 5 lines]" {
307 t.Errorf("display sidecar should be retained in trash, got %q", got)
308 }
309 }
310
311 func TestReconcileDesktopCleanupPendingDeleteMovesArtifactsToTrash(t *testing.T) {
312 dir := t.TempDir()
313 sessionPath := filepath.Join(dir, "pending.jsonl")
314 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"pending"}`+"\n"), 0o644); err != nil {
315 t.Fatal(err)
316 }
317 if err := os.MkdirAll(jobs.ArtifactDir(sessionPath), 0o755); err != nil {
318 t.Fatal(err)
319 }
320 if err := os.WriteFile(filepath.Join(jobs.ArtifactDir(sessionPath), "job.log"), []byte("job output"), 0o644); err != nil {
321 t.Fatal(err)
322 }
323 if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
324 t.Fatal(err)
325 }
326
327 if err := reconcileDesktopCleanupPending(dir); err != nil {
328 t.Fatalf("reconcileDesktopCleanupPending: %v", err)
329 }
330
331 trashPath := filepath.Join(dir, sessionTrashDir, "pending.jsonl", "pending.jsonl")
332 trashJobsDir := filepath.Join(dir, sessionTrashDir, "pending.jsonl", "pending.jobs")
333 for _, p := range []string{
334 trashPath,
335 filepath.Join(trashJobsDir, "job.log"),
336 } {
337 if _, err := os.Stat(p); err != nil {
338 t.Fatalf("expected trashed artifact %s: %v", p, err)
339 }
340 }
341 for _, p := range []string{sessionPath, jobs.ArtifactDir(sessionPath), agent.CleanupPendingPath(sessionPath)} {
342 if _, err := os.Stat(p); !os.IsNotExist(err) {
343 t.Fatalf("%s still exists after reconciliation (err=%v)", p, err)
344 }
345 }
346 }
347
348 func TestReconcileDesktopCleanupPendingDeleteReusesExistingTrashDir(t *testing.T) {
349 dir := t.TempDir()
350 sessionPath := filepath.Join(dir, "partial.jsonl")
351 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"partial"}`+"\n"), 0o644); err != nil {
352 t.Fatal(err)
353 }
354 itemDir := filepath.Join(sessionTrashPath(dir), "partial.jsonl")
355 if err := os.MkdirAll(itemDir, 0o755); err != nil {
356 t.Fatal(err)
357 }
358 if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
359 t.Fatal(err)
360 }
361
362 if err := reconcileDesktopCleanupPending(dir); err != nil {
363 t.Fatalf("reconcileDesktopCleanupPending: %v", err)
364 }
365
366 if _, err := os.Stat(filepath.Join(itemDir, "partial.jsonl")); err != nil {
367 t.Fatalf("session should be moved into existing trash dir: %v", err)
368 }
369 if _, err := os.Stat(agent.CleanupPendingPath(sessionPath)); !os.IsNotExist(err) {
370 t.Fatalf("cleanup marker still exists after reconciliation (err=%v)", err)
371 }
372 }
373
374 func TestReconcileDesktopCleanupPendingDeleteMovesRemainingSidecars(t *testing.T) {
375 dir := t.TempDir()
376 sessionPath := filepath.Join(dir, "sidecars.jsonl")
377 if err := os.WriteFile(sessionPath+".meta", []byte("{}"), 0o644); err != nil {
378 t.Fatal(err)
379 }
380 goalPath := store.SessionGoalState(sessionPath)
381 if err := os.WriteFile(goalPath, []byte(`{"goal":"finish"}`), 0o644); err != nil {
382 t.Fatal(err)
383 }
384 telemetryPath := sessionTelemetryPath(sessionPath)
385 if err := os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644); err != nil {
386 t.Fatal(err)
387 }
388 ckptDir := filepath.Join(dir, "sidecars.ckpt")
389 if err := os.MkdirAll(ckptDir, 0o755); err != nil {
390 t.Fatal(err)
391 }
392 if err := os.WriteFile(filepath.Join(ckptDir, "1.json"), []byte("{}"), 0o644); err != nil {
393 t.Fatal(err)
394 }
395 if err := os.MkdirAll(jobs.ArtifactDir(sessionPath), 0o755); err != nil {
396 t.Fatal(err)
397 }
398 if err := os.WriteFile(filepath.Join(jobs.ArtifactDir(sessionPath), "job.log"), []byte("job output"), 0o644); err != nil {
399 t.Fatal(err)
400 }
401 ref := "sa_20260102_030405_000000000_aabbccddeeff"
402 writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))
403 itemDir := filepath.Join(sessionTrashPath(dir), "sidecars.jsonl")
404 if err := os.MkdirAll(itemDir, 0o755); err != nil {
405 t.Fatal(err)
406 }
407 if err := os.WriteFile(filepath.Join(itemDir, "sidecars.jsonl"), []byte(`{"role":"user","content":"sidecars"}`+"\n"), 0o644); err != nil {
408 t.Fatal(err)
409 }
410 if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
411 t.Fatal(err)
412 }
413
414 if err := reconcileDesktopCleanupPending(dir); err != nil {
415 t.Fatalf("reconcileDesktopCleanupPending: %v", err)
416 }
417
418 for _, p := range []string{
419 filepath.Join(itemDir, "sidecars.jsonl.meta"),
420 filepath.Join(itemDir, "sidecars.goal-state.json"),
421 filepath.Join(itemDir, "sidecars.jsonl.telemetry.json"),
422 filepath.Join(itemDir, "sidecars.ckpt", "1.json"),
423 filepath.Join(itemDir, "sidecars.jobs", "job.log"),
424 filepath.Join(itemDir, "subagents", ref+".jsonl"),
425 filepath.Join(itemDir, "subagents", ref+".meta.json"),
426 } {
427 if _, err := os.Stat(p); err != nil {
428 t.Fatalf("expected remaining artifact %s to move into trash: %v", p, err)
429 }
430 }
431 for _, p := range []string{
432 sessionPath + ".meta",
433 goalPath,
434 telemetryPath,
435 ckptDir,
436 jobs.ArtifactDir(sessionPath),
437 filepath.Join(dir, "subagents", ref+".jsonl"),
438 filepath.Join(dir, "subagents", ref+".meta.json"),
439 agent.CleanupPendingPath(sessionPath),
440 } {
441 if _, err := os.Stat(p); !os.IsNotExist(err) {
442 t.Fatalf("%s still exists after reconciliation (err=%v)", p, err)
443 }
444 }
445 }
446
447 func TestDeleteSessionFileMovesOwnedSubagentsToTrash(t *testing.T) {
448 dir := t.TempDir()
449 sessionPath := filepath.Join(dir, "session.jsonl")
450 os.WriteFile(sessionPath, []byte("data"), 0o644)
451 writeSubagentArtifact(t, dir, "sa_20260102_030405_000000000_aabbccddeeff", agent.BranchID(sessionPath))
452 writeSubagentArtifact(t, dir, "sa_20260102_030405_000000000_112233445566", "other-parent")
453
454 if err := deleteSessionFile(dir, sessionPath); err != nil {
455 t.Fatalf("delete: %v", err)
456 }
457
458 ownedJSONL := filepath.Join(dir, "subagents", "sa_20260102_030405_000000000_aabbccddeeff.jsonl")
459 ownedMeta := filepath.Join(dir, "subagents", "sa_20260102_030405_000000000_aabbccddeeff.meta.json")
460 if _, err := os.Stat(ownedJSONL); !os.IsNotExist(err) {
461 t.Fatalf("owned subagent jsonl should be moved out of active dir, stat err = %v", err)
462 }
463 if _, err := os.Stat(ownedMeta); !os.IsNotExist(err) {
464 t.Fatalf("owned subagent meta should be moved out of active dir, stat err = %v", err)
465 }
466 trashSubagentDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "subagents")
467 if _, err := os.Stat(filepath.Join(trashSubagentDir, "sa_20260102_030405_000000000_aabbccddeeff.jsonl")); err != nil {
468 t.Fatalf("owned subagent jsonl should be in trash: %v", err)
469 }
470 if _, err := os.Stat(filepath.Join(trashSubagentDir, "sa_20260102_030405_000000000_aabbccddeeff.meta.json")); err != nil {
471 t.Fatalf("owned subagent meta should be in trash: %v", err)
472 }
473 if _, err := os.Stat(filepath.Join(dir, "subagents", "sa_20260102_030405_000000000_112233445566.jsonl")); err != nil {
474 t.Fatalf("unowned subagent jsonl should remain active: %v", err)
475 }
476 if _, err := os.Stat(filepath.Join(dir, "subagents", "sa_20260102_030405_000000000_112233445566.meta.json")); err != nil {
477 t.Fatalf("unowned subagent meta should remain active: %v", err)
478 }
479 }
480
481 func TestDeleteSessionFileNoTitle(t *testing.T) {
482 dir := t.TempDir()
483 sessionPath := filepath.Join(dir, "no-title.jsonl")
484 os.WriteFile(sessionPath, []byte("data"), 0o644)
485
486 if err := deleteSessionFile(dir, sessionPath); err != nil {
487 t.Fatalf("delete: %v", err)
488 }
489 if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
490 t.Error("session file should be removed from active sessions")
491 }
492 if _, err := os.Stat(filepath.Join(dir, sessionTrashDir, "no-title.jsonl", "no-title.jsonl")); err != nil {
493 t.Fatalf("session file should be in trash: %v", err)
494 }
495 }
496
497 func TestRestoreTrashedSessionFile(t *testing.T) {
498 dir := t.TempDir()
499 sessionPath := filepath.Join(dir, "session.jsonl")
500 if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil {
501 t.Fatal(err)
502 }
503 if err := os.WriteFile(sessionPath+".meta", []byte("{}"), 0o644); err != nil {
504 t.Fatal(err)
505 }
506 goalPath := store.SessionGoalState(sessionPath)
507 if err := os.WriteFile(goalPath, []byte(`{"goal":"restore"}`), 0o644); err != nil {
508 t.Fatal(err)
509 }
510 eventLogPath := store.SessionEventLog(sessionPath)
511 if err := os.WriteFile(eventLogPath, []byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"event"}]}`+"\n"), 0o644); err != nil {
512 t.Fatal(err)
513 }
514 eventIndexPath := store.SessionEventIndex(sessionPath)
515 if err := os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644); err != nil {
516 t.Fatal(err)
517 }
518 damagedPath := store.SessionEventLogDamaged(sessionPath)
519 if err := os.WriteFile(damagedPath, []byte(`{"damaged_tail":true}`+"\ntorn bytes\n"), 0o644); err != nil {
520 t.Fatal(err)
521 }
522 conflictLogPath := store.SessionConflictLog(sessionPath)
523 if err := os.WriteFile(conflictLogPath, []byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644); err != nil {
524 t.Fatal(err)
525 }
526 telemetryPath := sessionTelemetryPath(sessionPath)
527 if err := os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644); err != nil {
528 t.Fatal(err)
529 }
530 ckptDir := filepath.Join(dir, "session.ckpt")
531 if err := os.MkdirAll(ckptDir, 0o755); err != nil {
532 t.Fatal(err)
533 }
534 if err := os.WriteFile(filepath.Join(ckptDir, "1.json"), []byte("{}"), 0o644); err != nil {
535 t.Fatal(err)
536 }
537 jobsDir := jobs.ArtifactDir(sessionPath)
538 if err := os.MkdirAll(jobsDir, 0o755); err != nil {
539 t.Fatal(err)
540 }
541 if err := os.WriteFile(filepath.Join(jobsDir, "bash-1.log"), []byte("output"), 0o644); err != nil {
542 t.Fatal(err)
543 }
544 if err := setSessionTitle(dir, sessionPath, "My Title"); err != nil {
545 t.Fatal(err)
546 }
547
548 if err := deleteSessionFile(dir, sessionPath); err != nil {
549 t.Fatalf("trash: %v", err)
550 }
551 // The raw salvage sidecar holds session content; deletion must move it out
552 // of the live directory with the rest of the transcript artifacts (#6613
553 // review: it used to be left behind, and purging the trash never removed it).
554 if _, err := os.Stat(damagedPath); !os.IsNotExist(err) {
555 t.Fatalf("damaged salvage sidecar should leave the live dir on delete, stat err = %v", err)
556 }
557 trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
558 if _, err := os.Stat(filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.events.jsonl.damaged")); err != nil {
559 t.Fatalf("damaged salvage sidecar should be in the trash: %v", err)
560 }
561 if err := restoreTrashedSessionFile(dir, trashPath); err != nil {
562 t.Fatalf("restore: %v", err)
563 }
564
565 if _, err := os.Stat(sessionPath); err != nil {
566 t.Fatalf("session file should be restored: %v", err)
567 }
568 if _, err := os.Stat(sessionPath + ".meta"); err != nil {
569 t.Fatalf("session meta should be restored: %v", err)
570 }
571 if _, err := os.Stat(goalPath); err != nil {
572 t.Fatalf("session goal state should be restored: %v", err)
573 }
574 if _, err := os.Stat(eventLogPath); err != nil {
575 t.Fatalf("session event log should be restored: %v", err)
576 }
577 if _, err := os.Stat(eventIndexPath); err != nil {
578 t.Fatalf("session event index should be restored: %v", err)
579 }
580 if _, err := os.Stat(damagedPath); err != nil {
581 t.Fatalf("damaged salvage sidecar should be restored: %v", err)
582 }
583 if _, err := os.Stat(conflictLogPath); err != nil {
584 t.Fatalf("session conflict log should be restored: %v", err)
585 }
586 if _, err := os.Stat(telemetryPath); err != nil {
587 t.Fatalf("session telemetry should be restored: %v", err)
588 }
589 if _, err := os.Stat(ckptDir); err != nil {
590 t.Fatalf("session checkpoints should be restored: %v", err)
591 }
592 if _, err := os.Stat(filepath.Join(jobsDir, "bash-1.log")); err != nil {
593 t.Fatalf("session jobs should be restored: %v", err)
594 }
595 if _, err := os.Stat(filepath.Dir(trashPath)); !os.IsNotExist(err) {
596 t.Fatalf("trash item should be removed after restore, stat err = %v", err)
597 }
598 if got := loadSessionTitles(dir)["session.jsonl"]; got != "My Title" {
599 t.Fatalf("title should survive restore, got %q", got)
600 }
601 }
602
603 func TestRestoreTrashedSessionFileWithEmptyLiveStub(t *testing.T) {
604 dir := t.TempDir()
605 sessionPath := filepath.Join(dir, "restore-stub.jsonl")
606 if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil {
607 t.Fatal(err)
608 }
609 if err := deleteSessionFile(dir, sessionPath); err != nil {
610 t.Fatalf("trash: %v", err)
611 }
612 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
613 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
614 t.Fatalf("write live stub: %v", err)
615 }
616
617 if err := restoreTrashedSessionFile(dir, trashPath); err != nil {
618 t.Fatalf("restore should replace empty live stub: %v", err)
619 }
620 if got, err := os.ReadFile(sessionPath); err != nil || string(got) != "data" {
621 t.Fatalf("restored session = %q, %v; want trash data", string(got), err)
622 }
623 if _, err := os.Stat(filepath.Dir(trashPath)); !os.IsNotExist(err) {
624 t.Fatalf("trash item should be removed after restore, err=%v", err)
625 }
626 }
627
628 func TestRestoreTrashedSessionFileFromUniqueTrashItem(t *testing.T) {
629 dir := t.TempDir()
630 sessionPath := filepath.Join(dir, "restore-unique.jsonl")
631 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
632 t.Fatal(err)
633 }
634 if err := deleteSessionFile(dir, sessionPath); err != nil {
635 t.Fatalf("trash old: %v", err)
636 }
637 fixedTrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
638 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"new"}`+"\n"), 0o644); err != nil {
639 t.Fatalf("write new live session: %v", err)
640 }
641 if err := deleteSessionFile(dir, sessionPath); err != nil {
642 t.Fatalf("trash new with fixed trash conflict: %v", err)
643 }
644
645 trashed, err := listTrashedSessionFiles(dir)
646 if err != nil {
647 t.Fatalf("list trash: %v", err)
648 }
649 var uniqueTrashPath string
650 for _, candidate := range trashed {
651 if candidate != fixedTrashPath && filepath.Base(candidate) == filepath.Base(sessionPath) {
652 uniqueTrashPath = candidate
653 break
654 }
655 }
656 if uniqueTrashPath == "" {
657 t.Fatalf("unique trash path not listed, got %#v", trashed)
658 }
659 if err := restoreTrashedSessionFile(dir, uniqueTrashPath); err != nil {
660 t.Fatalf("restore unique trash item: %v", err)
661 }
662 if got, err := os.ReadFile(sessionPath); err != nil || !strings.Contains(string(got), "new") {
663 t.Fatalf("restored session = %q err=%v, want new content", string(got), err)
664 }
665 if _, err := os.Stat(filepath.Dir(uniqueTrashPath)); !os.IsNotExist(err) {
666 t.Fatalf("unique trash item should be removed after restore, stat err = %v", err)
667 }
668 if _, err := os.Stat(fixedTrashPath); err != nil {
669 t.Fatalf("original fixed trash item should remain: %v", err)
670 }
671 }
672
673 func TestValidateSessionTrashTargetKeepsDiscardableLiveStub(t *testing.T) {
674 dir := t.TempDir()
675 sessionPath := filepath.Join(dir, "discardable-live.jsonl")
676 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
677 t.Fatalf("write live stub: %v", err)
678 }
679 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
680 if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
681 t.Fatalf("create trash dir: %v", err)
682 }
683 if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
684 t.Fatalf("write trash session: %v", err)
685 }
686
687 if err := validateSessionTrashTarget(dir, sessionPath, filepath.Base(sessionPath)); err != nil {
688 t.Fatalf("validateSessionTrashTarget: %v", err)
689 }
690 if _, err := os.Stat(sessionPath); err != nil {
691 t.Fatalf("validation should not remove live stub: %v", err)
692 }
693 if _, err := os.Stat(trashPath); err != nil {
694 t.Fatalf("validation should keep existing trash: %v", err)
695 }
696 }
697
698 func TestRestoreTrashedSessionFileRejectsNonEmptyLiveConflict(t *testing.T) {
699 dir := t.TempDir()
700 sessionPath := filepath.Join(dir, "restore-conflict.jsonl")
701 if err := os.WriteFile(sessionPath, []byte("trash data"), 0o644); err != nil {
702 t.Fatal(err)
703 }
704 if err := deleteSessionFile(dir, sessionPath); err != nil {
705 t.Fatalf("trash: %v", err)
706 }
707 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
708 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"live"}`+"\n"), 0o644); err != nil {
709 t.Fatalf("write live session: %v", err)
710 }
711
712 err := restoreTrashedSessionFile(dir, trashPath)
713 if err == nil || !strings.Contains(err.Error(), "session already exists") {
714 t.Fatalf("restore conflict error = %v, want session already exists", err)
715 }
716 if got, err := os.ReadFile(sessionPath); err != nil || !strings.Contains(string(got), "live") {
717 t.Fatalf("live session should remain, got %q err=%v", string(got), err)
718 }
719 if _, err := os.Stat(trashPath); err != nil {
720 t.Fatalf("trash session should remain after rejected restore: %v", err)
721 }
722 }
723
724 func TestRestoreTrashedSessionFileRestoresSubagents(t *testing.T) {
725 dir := t.TempDir()
726 sessionPath := filepath.Join(dir, "session.jsonl")
727 if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil {
728 t.Fatal(err)
729 }
730 ref := "sa_20260102_030405_000000000_aabbccddeeff"
731 writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))
732 if err := deleteSessionFile(dir, sessionPath); err != nil {
733 t.Fatalf("trash: %v", err)
734 }
735
736 trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
737 if err := restoreTrashedSessionFile(dir, trashPath); err != nil {
738 t.Fatalf("restore: %v", err)
739 }
740
741 if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".jsonl")); err != nil {
742 t.Fatalf("subagent jsonl should be restored: %v", err)
743 }
744 if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".meta.json")); err != nil {
745 t.Fatalf("subagent meta should be restored: %v", err)
746 }
747 }
748
749 func TestRestoreTrashedSessionFileRejectsSubagentConflict(t *testing.T) {
750 dir := t.TempDir()
751 sessionPath := filepath.Join(dir, "session.jsonl")
752 if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil {
753 t.Fatal(err)
754 }
755 ref := "sa_20260102_030405_000000000_aabbccddeeff"
756 writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))
757 if err := deleteSessionFile(dir, sessionPath); err != nil {
758 t.Fatalf("trash: %v", err)
759 }
760 if err := os.MkdirAll(filepath.Join(dir, "subagents"), 0o755); err != nil {
761 t.Fatal(err)
762 }
763 if err := os.WriteFile(filepath.Join(dir, "subagents", ref+".jsonl"), []byte("conflict"), 0o644); err != nil {
764 t.Fatal(err)
765 }
766
767 trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
768 if err := restoreTrashedSessionFile(dir, trashPath); err == nil {
769 t.Fatal("restore should fail on subagent conflict")
770 }
771 if _, err := os.Stat(trashPath); err != nil {
772 t.Fatalf("trash item should remain after failed restore: %v", err)
773 }
774 if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
775 t.Fatalf("parent session should not be restored after conflict, stat err = %v", err)
776 }
777 }
778
779 func TestPurgeTrashedSessionFile(t *testing.T) {
780 dir := t.TempDir()
781 sessionPath := filepath.Join(dir, "session.jsonl")
782 os.WriteFile(sessionPath, []byte("data"), 0o644)
783 goalPath := store.SessionGoalState(sessionPath)
784 os.WriteFile(goalPath, []byte(`{"goal":"purge"}`), 0o644)
785 telemetryPath := sessionTelemetryPath(sessionPath)
786 os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644)
787 jobsDir := jobs.ArtifactDir(sessionPath)
788 if err := os.MkdirAll(jobsDir, 0o755); err != nil {
789 t.Fatal(err)
790 }
791 if err := os.WriteFile(filepath.Join(jobsDir, "bash-1.log"), []byte("output"), 0o644); err != nil {
792 t.Fatal(err)
793 }
794 if err := setSessionTitle(dir, sessionPath, "My Title"); err != nil {
795 t.Fatal(err)
796 }
797 if err := recordSessionDisplay(dir, sessionPath, "expanded prompt", "[Pasted text #1 · 5 lines]"); err != nil {
798 t.Fatal(err)
799 }
800 if err := recordSessionPlannerDisplay(dir, sessionPath, "prompt", []HistoryMessage{{
801 Role: "tool", ToolName: "read_file", Content: "sensitive cancelled output",
802 }}); err != nil {
803 t.Fatal(err)
804 }
805 if err := deleteSessionFile(dir, sessionPath); err != nil {
806 t.Fatalf("trash: %v", err)
807 }
808 if got := sessionPlannerDisplayTurns(dir, sessionPath); len(got) != 1 {
809 t.Fatalf("planner display should remain available while session is in trash: %+v", got)
810 }
811 trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
812 if err := purgeTrashedSessionFile(dir, trashPath); err != nil {
813 t.Fatalf("purge: %v", err)
814 }
815 if _, err := os.Stat(filepath.Dir(trashPath)); !os.IsNotExist(err) {
816 t.Fatalf("trash item should be removed after purge, stat err = %v", err)
817 }
818 if _, err := os.Stat(jobsDir); !os.IsNotExist(err) {
819 t.Fatalf("session jobs should be removed after purge, stat err = %v", err)
820 }
821 if _, err := os.Stat(goalPath); !os.IsNotExist(err) {
822 t.Fatalf("session goal state should be removed after purge, stat err = %v", err)
823 }
824 if _, err := os.Stat(telemetryPath); !os.IsNotExist(err) {
825 t.Fatalf("session telemetry should be removed after purge, stat err = %v", err)
826 }
827 if _, ok := loadSessionTitles(dir)["session.jsonl"]; ok {
828 t.Fatal("title should be removed after purge")
829 }
830 if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "expanded prompt" {
831 t.Fatalf("display sidecar should be removed after purge, got %q", got)
832 }
833 if got := sessionPlannerDisplayTurns(dir, sessionPath); len(got) != 0 {
834 t.Fatalf("planner display sidecar should be removed after purge: %+v", got)
835 }
836 }
837
838 func TestPurgeTrashedSessionFileRemovesSubagents(t *testing.T) {
839 dir := t.TempDir()
840 sessionPath := filepath.Join(dir, "session.jsonl")
841 os.WriteFile(sessionPath, []byte("data"), 0o644)
842 ref := "sa_20260102_030405_000000000_aabbccddeeff"
843 writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))
844 if err := deleteSessionFile(dir, sessionPath); err != nil {
845 t.Fatalf("trash: %v", err)
846 }
847 trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
848 if err := purgeTrashedSessionFile(dir, trashPath); err != nil {
849 t.Fatalf("purge: %v", err)
850 }
851 if _, err := os.Stat(filepath.Join(dir, sessionTrashDir, "session.jsonl", "subagents", ref+".jsonl")); !os.IsNotExist(err) {
852 t.Fatalf("trashed subagent should be removed by purge, stat err = %v", err)
853 }
854 }
855
856 func TestListTrashedSessionFilesRejectsSymlinkEscape(t *testing.T) {
857 dir := t.TempDir()
858 outside := filepath.Join(t.TempDir(), "outside.jsonl")
859 os.WriteFile(outside, []byte("data"), 0o644)
860 itemDir := filepath.Join(dir, sessionTrashDir, "outside.jsonl")
861 if err := os.MkdirAll(itemDir, 0o755); err != nil {
862 t.Fatal(err)
863 }
864 if err := os.Symlink(outside, filepath.Join(itemDir, "outside.jsonl")); err != nil {
865 t.Skipf("symlink unavailable: %v", err)
866 }
867
868 got, err := listTrashedSessionFiles(dir)
869 if err != nil {
870 t.Fatalf("list trash: %v", err)
871 }
872 if len(got) != 0 {
873 t.Fatalf("trash listing should skip symlink escape, got %v", got)
874 }
875 }
876
877 func TestDeleteSessionFileMissing(t *testing.T) {
878 dir := t.TempDir()
879 // Deleting a non-existent file should not error.
880 if err := deleteSessionFile(dir, filepath.Join(dir, "missing.jsonl")); err != nil {
881 t.Fatalf("delete missing: %v", err)
882 }
883 }
884
885 func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
886 dir := t.TempDir()
887 sessionPath := filepath.Join(dir, "session.jsonl")
888 for _, p := range []string{
889 sessionPath,
890 store.SessionMeta(sessionPath),
891 store.SessionGoalState(sessionPath),
892 store.SessionEventLog(sessionPath),
893 store.SessionEventIndex(sessionPath),
894 store.SessionConflictLog(sessionPath),
895 store.SessionRecoveryState(sessionPath),
896 sessionTelemetryPath(sessionPath),
897 store.SessionLockFile(sessionPath),
898 store.SessionLeaseLock(sessionPath),
899 store.SessionLeaseInfo(sessionPath),
900 } {
901 if err := os.WriteFile(p, []byte("{}"), 0o644); err != nil {
902 t.Fatalf("write %s: %v", p, err)
903 }
904 }
905 if err := recordSessionDisplay(dir, sessionPath, "expanded prompt", "[Pasted text #1 · 2 lines]"); err != nil {
906 t.Fatalf("record display: %v", err)
907 }
908 ckptDir := store.SessionCheckpointDir(sessionPath)
909 if err := os.MkdirAll(ckptDir, 0o755); err != nil {
910 t.Fatal(err)
911 }
912 if err := os.WriteFile(filepath.Join(ckptDir, "1.json"), []byte("{}"), 0o644); err != nil {
913 t.Fatal(err)
914 }
915 jobsDir := jobs.ArtifactDir(sessionPath)
916 if err := os.MkdirAll(jobsDir, 0o755); err != nil {
917 t.Fatal(err)
918 }
919 if err := os.WriteFile(filepath.Join(jobsDir, "job.log"), []byte("output"), 0o644); err != nil {
920 t.Fatal(err)
921 }
922
923 if err := removeDesktopSessionArtifacts(sessionPath); err != nil {
924 t.Fatalf("removeDesktopSessionArtifacts: %v", err)
925 }
926
927 for _, p := range []string{
928 sessionPath,
929 store.SessionMeta(sessionPath),
930 store.SessionGoalState(sessionPath),
931 store.SessionEventLog(sessionPath),
932 store.SessionEventIndex(sessionPath),
933 store.SessionConflictLog(sessionPath),
934 store.SessionRecoveryState(sessionPath),
935 sessionTelemetryPath(sessionPath),
936 store.SessionLockFile(sessionPath),
937 store.SessionLeaseLock(sessionPath),
938 store.SessionLeaseInfo(sessionPath),
939 ckptDir,
940 jobsDir,
941 } {
942 if _, err := os.Stat(p); !os.IsNotExist(err) {
943 t.Fatalf("%s should be removed, stat err = %v", p, err)
944 }
945 }
946 if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "expanded prompt" {
947 t.Fatalf("session display key should be removed, got %q", got)
948 }
949 }
950
951 func TestDeleteSessionFileRejectsOutsideDir(t *testing.T) {
952 dir := t.TempDir()
953 outside := filepath.Join(t.TempDir(), "outside.jsonl")
954 os.WriteFile(outside, []byte("data"), 0o644)
955
956 if err := deleteSessionFile(dir, outside); err == nil {
957 t.Fatal("delete outside dir should fail")
958 }
959 if _, err := os.Stat(outside); err != nil {
960 t.Fatalf("outside file should remain: %v", err)
961 }
962 }
963
964 func TestDeleteSessionFileRejectsNonJSONL(t *testing.T) {
965 dir := t.TempDir()
966 path := filepath.Join(dir, "session.meta")
967 os.WriteFile(path, []byte("data"), 0o644)
968
969 if err := deleteSessionFile(dir, path); err == nil {
970 t.Fatal("delete non-jsonl should fail")
971 }
972 if _, err := os.Stat(path); err != nil {
973 t.Fatalf("non-jsonl file should remain: %v", err)
974 }
975 }
976
977 func TestDeleteSessionFileRejectsSymlinkEscape(t *testing.T) {
978 dir := t.TempDir()
979 outside := filepath.Join(t.TempDir(), "outside.jsonl")
980 os.WriteFile(outside, []byte("data"), 0o644)
981 link := filepath.Join(dir, "link.jsonl")
982 if err := os.Symlink(outside, link); err != nil {
983 t.Skipf("symlink unavailable: %v", err)
984 }
985
986 if err := deleteSessionFile(dir, link); err == nil {
987 t.Fatal("delete symlink escape should fail")
988 }
989 if _, err := os.Stat(outside); err != nil {
990 t.Fatalf("outside target should remain: %v", err)
991 }
992 }
993
994 func TestMovePathIfExistsCopyFallback(t *testing.T) {
995 dir := t.TempDir()
996 src := filepath.Join(dir, "src.txt")
997 if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil {
998 t.Fatal(err)
999 }
1000 dst := filepath.Join(dir, "dst.txt")
1001
1002 // Test normal move.
1003 if err := movePathIfExists(src, dst); err != nil {
1004 t.Fatalf("move: %v", err)
1005 }
1006 if _, err := os.Stat(src); !os.IsNotExist(err) {
1007 t.Fatal("src should be removed")
1008 }
1009 if got, err := os.ReadFile(dst); err != nil || string(got) != "hello" {
1010 t.Fatalf("dst content = %q, err = %v", got, err)
1011 }
1012
1013 // Test non-existent source is no-op.
1014 if err := movePathIfExists(filepath.Join(dir, "missing.txt"), filepath.Join(dir, "other.txt")); err != nil {
1015 t.Fatalf("move missing: %v", err)
1016 }
1017 }
1018
1019 func TestCopyFallbackTreatsMissingSourceAsAlreadyMoved(t *testing.T) {
1020 dir := t.TempDir()
1021 if err := copyAndRemove(filepath.Join(dir, "missing.txt"), filepath.Join(dir, "dst.txt")); err != nil {
1022 t.Fatalf("copy missing source: %v", err)
1023 }
1024
1025 dstDir := filepath.Join(dir, "dst-dir")
1026 if err := copyDir(filepath.Join(dir, "missing-dir"), dstDir, 0o755); err != nil {
1027 t.Fatalf("copy missing dir: %v", err)
1028 }
1029 if _, err := os.Stat(dstDir); !os.IsNotExist(err) {
1030 t.Fatalf("copy missing dir should not leave an empty target, stat err = %v", err)
1031 }
1032 }
1033
1034 func TestCopyFallbackRemovesPartialTargetWhenSourceVanishesMidCopy(t *testing.T) {
1035 dir := t.TempDir()
1036 src := filepath.Join(dir, "src.txt")
1037 dst := filepath.Join(dir, "dst.txt")
1038 if err := os.WriteFile(src, []byte("session transcript"), 0o644); err != nil {
1039 t.Fatalf("write source: %v", err)
1040 }
1041
1042 restore := copyPathFn
1043 copyPathFn = func(copySrc, copyDst string) error {
1044 // Simulate the source vanishing mid-copy: a truncated destination has
1045 // already been written when the copy fails.
1046 if err := os.WriteFile(copyDst, []byte("session tra"), 0o644); err != nil {
1047 t.Fatalf("write partial destination: %v", err)
1048 }
1049 if err := os.Remove(copySrc); err != nil {
1050 t.Fatalf("remove source: %v", err)
1051 }
1052 return errors.New("simulated read failure")
1053 }
1054 t.Cleanup(func() { copyPathFn = restore })
1055
1056 if err := copyAndRemove(src, dst); err != nil {
1057 t.Fatalf("copyAndRemove should treat vanished source as already moved: %v", err)
1058 }
1059 if _, err := os.Stat(dst); !os.IsNotExist(err) {
1060 t.Fatalf("partial destination should be removed, stat err = %v", err)
1061 }
1062 }
1063
1064 func TestCopyFallbackKeepsErrorWhenSourceStillExists(t *testing.T) {
1065 dir := t.TempDir()
1066 src := filepath.Join(dir, "src.txt")
1067 dst := filepath.Join(dir, "dst.txt")
1068 if err := os.WriteFile(src, []byte("session transcript"), 0o644); err != nil {
1069 t.Fatalf("write source: %v", err)
1070 }
1071
1072 restore := copyPathFn
1073 simulated := errors.New("simulated copy failure")
1074 copyPathFn = func(copySrc, copyDst string) error {
1075 return simulated
1076 }
1077 t.Cleanup(func() { copyPathFn = restore })
1078
1079 if err := copyAndRemove(src, dst); !errors.Is(err, simulated) {
1080 t.Fatalf("copyAndRemove error = %v, want simulated copy failure", err)
1081 }
1082 if _, err := os.Stat(src); err != nil {
1083 t.Fatalf("source should be untouched on real copy failure: %v", err)
1084 }
1085 }
1086
1087 func TestCopyAndRemoveDirectory(t *testing.T) {
1088 dir := t.TempDir()
1089 srcDir := filepath.Join(dir, "src")
1090 if err := os.MkdirAll(filepath.Join(srcDir, "sub"), 0o755); err != nil {
1091 t.Fatal(err)
1092 }
1093 if err := os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("file a"), 0o644); err != nil {
1094 t.Fatal(err)
1095 }
1096 if err := os.WriteFile(filepath.Join(srcDir, "sub", "b.txt"), []byte("file b"), 0o644); err != nil {
1097 t.Fatal(err)
1098 }
1099 dstDir := filepath.Join(dir, "dst")
1100
1101 if err := copyAndRemove(srcDir, dstDir); err != nil {
1102 t.Fatalf("copyAndRemove: %v", err)
1103 }
1104 if _, err := os.Stat(srcDir); !os.IsNotExist(err) {
1105 t.Fatal("src dir should be removed")
1106 }
1107 if got, err := os.ReadFile(filepath.Join(dstDir, "a.txt")); err != nil || string(got) != "file a" {
1108 t.Fatalf("dst a.txt = %q, err = %v", got, err)
1109 }
1110 if got, err := os.ReadFile(filepath.Join(dstDir, "sub", "b.txt")); err != nil || string(got) != "file b" {
1111 t.Fatalf("dst sub/b.txt = %q, err = %v", got, err)
1112 }
1113 }
1114
1115 func TestCopyAndRemoveDirectoryPreservesSymlinks(t *testing.T) {
1116 dir := t.TempDir()
1117 outside := filepath.Join(t.TempDir(), "outside.txt")
1118 if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
1119 t.Fatal(err)
1120 }
1121 srcDir := filepath.Join(dir, "src")
1122 if err := os.MkdirAll(srcDir, 0o755); err != nil {
1123 t.Fatal(err)
1124 }
1125 link := filepath.Join(srcDir, "link.txt")
1126 if err := os.Symlink(outside, link); err != nil {
1127 t.Skipf("symlink unavailable: %v", err)
1128 }
1129 dstDir := filepath.Join(dir, "dst")
1130
1131 if err := copyAndRemove(srcDir, dstDir); err != nil {
1132 t.Fatalf("copyAndRemove: %v", err)
1133 }
1134 if _, err := os.Stat(srcDir); !os.IsNotExist(err) {
1135 t.Fatal("src dir should be removed")
1136 }
1137 dstLink := filepath.Join(dstDir, "link.txt")
1138 info, err := os.Lstat(dstLink)
1139 if err != nil {
1140 t.Fatal(err)
1141 }
1142 if info.Mode()&os.ModeSymlink == 0 {
1143 t.Fatalf("dst link should remain a symlink, mode=%v", info.Mode())
1144 }
1145 target, err := os.Readlink(dstLink)
1146 if err != nil {
1147 t.Fatal(err)
1148 }
1149 if target != outside {
1150 t.Fatalf("dst link target = %q, want %q", target, outside)
1151 }
1152 }
1153
1154 func writeSubagentArtifact(t *testing.T, dir, ref, parentSession string) {
1155 t.Helper()
1156 subagentDir := filepath.Join(dir, "subagents")
1157 if err := os.MkdirAll(subagentDir, 0o755); err != nil {
1158 t.Fatal(err)
1159 }
1160 if err := os.WriteFile(filepath.Join(subagentDir, ref+".jsonl"), []byte(`{"role":"user","content":"sub"}`+"\n"), 0o644); err != nil {
1161 t.Fatal(err)
1162 }
1163 meta := agent.SubagentMeta{
1164 Ref: ref,
1165 Status: agent.SubagentCompleted,
1166 Kind: "task",
1167 Name: "task",
1168 ParentSession: parentSession,
1169 }
1170 data, err := json.Marshal(meta)
1171 if err != nil {
1172 t.Fatal(err)
1173 }
1174 if err := os.WriteFile(filepath.Join(subagentDir, ref+".meta.json"), data, 0o644); err != nil {
1175 t.Fatal(err)
1176 }
1177 }
1178
1179 // --- sessionTitlesPath ---
1180
1181 func TestSessionTitlesPath(t *testing.T) {
1182 got := sessionTitlesPath("/sessions")
1183 want := filepath.Join("/sessions", ".titles.json")
1184 if got != want {
1185 t.Errorf("sessionTitlesPath = %q, want %q", got, want)
1186 }
1187 }
1188
1189 func TestSessionDisplayRoundTrip(t *testing.T) {
1190 dir := t.TempDir()
1191 sessionPath := filepath.Join(dir, "s.jsonl")
1192 content := "prefix\n--- Begin [Pasted text #1 · 5 lines] ---\nfull text\n--- End [Pasted text #1 · 5 lines] ---"
1193 display := "[Pasted text #1 · 5 lines]"
1194 if err := recordSessionDisplay(dir, sessionPath, content, display); err != nil {
1195 t.Fatalf("record display: %v", err)
1196 }
1197 if got := resolveSessionDisplay(dir, sessionPath, content); got != display {
1198 t.Fatalf("display = %q, want %q", got, display)
1199 }
1200 if got := resolveSessionDisplay(dir, sessionPath, "other"); got != "other" {
1201 t.Fatalf("unknown content should pass through, got %q", got)
1202 }
1203 }
1204
1205 func TestRecordSessionPlannerDisplayConcurrentPreservesEverySession(t *testing.T) {
1206 dir := t.TempDir()
1207 const writers = 32
1208 start := make(chan struct{})
1209 errs := make(chan error, writers)
1210 var wg sync.WaitGroup
1211 for i := 0; i < writers; i++ {
1212 wg.Add(1)
1213 go func(i int) {
1214 defer wg.Done()
1215 <-start
1216 path := filepath.Join(dir, fmt.Sprintf("session-%02d.jsonl", i))
1217 errs <- recordSessionPlannerDisplay(dir, path, fmt.Sprintf("prompt-%02d", i), []HistoryMessage{{
1218 Role: "assistant", Content: fmt.Sprintf("answer-%02d", i),
1219 }})
1220 }(i)
1221 }
1222 close(start)
1223 wg.Wait()
1224 close(errs)
1225 for err := range errs {
1226 if err != nil {
1227 t.Fatalf("record planner display: %v", err)
1228 }
1229 }
1230
1231 got := loadSessionPlannerDisplays(dir)
1232 if len(got) != writers {
1233 t.Fatalf("planner display sessions = %d, want %d", len(got), writers)
1234 }
1235 for i := 0; i < writers; i++ {
1236 key := fmt.Sprintf("session-%02d.jsonl", i)
1237 if len(got[key]) != 1 || len(got[key][0].Messages) != 1 || got[key][0].Messages[0].Content != fmt.Sprintf("answer-%02d", i) {
1238 t.Fatalf("planner display %s = %+v", key, got[key])
1239 }
1240 }
1241 }
1242
1243 func TestRecordSessionPlannerDisplayCrossProcessPreservesEverySession(t *testing.T) {
1244 if role := os.Getenv("REASONIX_PLANNER_DISPLAY_HELPER"); role != "" {
1245 dir := os.Getenv("REASONIX_PLANNER_DISPLAY_DIR")
1246 sessionPlannerDisplayLockTimeout = 5 * time.Second
1247 attempted := filepath.Join(dir, role+".attempted")
1248 loaded := filepath.Join(dir, role+".loaded")
1249 release := filepath.Join(dir, role+".release")
1250 if err := os.WriteFile(attempted, []byte("ready"), 0o600); err != nil {
1251 t.Fatal(err)
1252 }
1253 if role == "second" && !waitForPlannerDisplayTestFile(filepath.Join(dir, "second.begin"), 10*time.Second) {
1254 t.Fatal("timed out waiting to begin second update")
1255 }
1256 sessionPlannerDisplayUpdateAfterLoad = func() {
1257 if err := os.WriteFile(loaded, []byte("loaded"), 0o600); err != nil {
1258 t.Fatal(err)
1259 }
1260 if !waitForPlannerDisplayTestFile(release, 10*time.Second) {
1261 t.Fatalf("timed out waiting for %s release", role)
1262 }
1263 }
1264 err := recordSessionPlannerDisplay(dir, filepath.Join(dir, role+".jsonl"), role+" prompt", []HistoryMessage{{
1265 Role: "assistant", Content: role + " answer",
1266 }})
1267 if err != nil {
1268 t.Fatal(err)
1269 }
1270 return
1271 }
1272
1273 dir := t.TempDir()
1274 startHelper := func(role string, output *strings.Builder) *exec.Cmd {
1275 cmd := exec.Command(os.Args[0], "-test.run=^TestRecordSessionPlannerDisplayCrossProcessPreservesEverySession$")
1276 cmd.Env = append(os.Environ(),
1277 "REASONIX_PLANNER_DISPLAY_HELPER="+role,
1278 "REASONIX_PLANNER_DISPLAY_DIR="+dir,
1279 )
1280 cmd.Stdout = output
1281 cmd.Stderr = output
1282 if err := cmd.Start(); err != nil {
1283 t.Fatalf("start %s helper: %v", role, err)
1284 }
1285 return cmd
1286 }
1287 releaseHelper := func(role string) {
1288 if err := os.WriteFile(filepath.Join(dir, role+".release"), []byte("release"), 0o600); err != nil {
1289 t.Fatalf("release %s helper: %v", role, err)
1290 }
1291 }
1292
1293 var firstOutput, secondOutput strings.Builder
1294 first := startHelper("first", &firstOutput)
1295 if !waitForPlannerDisplayTestFile(filepath.Join(dir, "first.loaded"), 5*time.Second) {
1296 releaseHelper("first")
1297 _ = first.Wait()
1298 t.Fatalf("first helper did not load sidecar: %s", firstOutput.String())
1299 }
1300 second := startHelper("second", &secondOutput)
1301 if !waitForPlannerDisplayTestFile(filepath.Join(dir, "second.attempted"), 5*time.Second) {
1302 releaseHelper("first")
1303 _ = os.WriteFile(filepath.Join(dir, "second.begin"), []byte("begin"), 0o600)
1304 releaseHelper("second")
1305 _ = first.Wait()
1306 _ = second.Wait()
1307 t.Fatalf("second helper did not attempt update: %s", secondOutput.String())
1308 }
1309 if err := os.WriteFile(filepath.Join(dir, "second.begin"), []byte("begin"), 0o600); err != nil {
1310 t.Fatal(err)
1311 }
1312 if waitForPlannerDisplayTestFile(filepath.Join(dir, "second.loaded"), time.Second) {
1313 releaseHelper("first")
1314 releaseHelper("second")
1315 _ = first.Wait()
1316 _ = second.Wait()
1317 t.Fatal("second process loaded the stale sidecar while the first update still held its transaction lock")
1318 }
1319
1320 releaseHelper("first")
1321 if err := first.Wait(); err != nil {
1322 releaseHelper("second")
1323 _ = second.Wait()
1324 t.Fatalf("first helper failed: %v\n%s", err, firstOutput.String())
1325 }
1326 if !waitForPlannerDisplayTestFile(filepath.Join(dir, "second.loaded"), 5*time.Second) {
1327 releaseHelper("second")
1328 _ = second.Wait()
1329 t.Fatalf("second helper did not enter transaction after release: %s", secondOutput.String())
1330 }
1331 releaseHelper("second")
1332 if err := second.Wait(); err != nil {
1333 t.Fatalf("second helper failed: %v\n%s", err, secondOutput.String())
1334 }
1335
1336 got := loadSessionPlannerDisplays(dir)
1337 for _, role := range []string{"first", "second"} {
1338 turns := got[role+".jsonl"]
1339 if len(turns) != 1 || len(turns[0].Messages) != 1 || turns[0].Messages[0].Content != role+" answer" {
1340 t.Fatalf("%s planner display lost after cross-process updates: %#v", role, got)
1341 }
1342 }
1343 }
1344
1345 func TestRecordSessionPlannerDisplayDoesNotOverwriteCorruptSidecar(t *testing.T) {
1346 dir := t.TempDir()
1347 corrupt := []byte(`{"session.jsonl":[`)
1348 if err := os.WriteFile(sessionPlannerDisplayPath(dir), corrupt, 0o600); err != nil {
1349 t.Fatal(err)
1350 }
1351 err := recordSessionPlannerDisplay(dir, filepath.Join(dir, "new.jsonl"), "prompt", []HistoryMessage{{Role: "assistant", Content: "answer"}})
1352 if err == nil {
1353 t.Fatal("record should reject a corrupt sidecar instead of replacing it with an empty map")
1354 }
1355 got, readErr := os.ReadFile(sessionPlannerDisplayPath(dir))
1356 if readErr != nil {
1357 t.Fatal(readErr)
1358 }
1359 if string(got) != string(corrupt) {
1360 t.Fatalf("corrupt sidecar was overwritten: %q", got)
1361 }
1362 }
1363
1364 func TestRemoveSessionPlannerDisplayRetiresCorruptSidecar(t *testing.T) {
1365 dir := t.TempDir()
1366 if err := os.WriteFile(sessionPlannerDisplayPath(dir), []byte(`{"session.jsonl":[`), 0o600); err != nil {
1367 t.Fatal(err)
1368 }
1369 if err := removeSessionPlannerDisplay(dir, filepath.Join(dir, "session.jsonl")); err != nil {
1370 t.Fatalf("remove display from corrupt sidecar: %v", err)
1371 }
1372 if _, err := os.Stat(sessionPlannerDisplayPath(dir)); !os.IsNotExist(err) {
1373 t.Fatalf("corrupt sidecar should be retired during destructive cleanup, stat err = %v", err)
1374 }
1375 }
1376
1377 func waitForPlannerDisplayTestFile(path string, timeout time.Duration) bool {
1378 deadline := time.Now().Add(timeout)
1379 for time.Now().Before(deadline) {
1380 if _, err := os.Stat(path); err == nil {
1381 return true
1382 }
1383 time.Sleep(5 * time.Millisecond)
1384 }
1385 return false
1386 }
1387
1388 func TestRemoveDesktopSessionArtifactsPrunesPlannerDisplay(t *testing.T) {
1389 dir := t.TempDir()
1390 path := filepath.Join(dir, "session.jsonl")
1391 if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil {
1392 t.Fatal(err)
1393 }
1394 if err := recordSessionPlannerDisplay(dir, path, "prompt", []HistoryMessage{{
1395 Role: "tool", ToolName: "read_file", Content: "sensitive cancelled output",
1396 }}); err != nil {
1397 t.Fatal(err)
1398 }
1399 if err := removeDesktopSessionArtifacts(path); err != nil {
1400 t.Fatal(err)
1401 }
1402 if got := sessionPlannerDisplayTurns(dir, path); len(got) != 0 {
1403 t.Fatalf("planner display retained after permanent session removal: %+v", got)
1404 }
1405 if _, err := os.Stat(sessionPlannerDisplayPath(dir)); !os.IsNotExist(err) {
1406 t.Fatalf("empty planner display sidecar should be removed, stat err = %v", err)
1407 }
1408 }
1409
1410 func TestPruneSessionPlannerDisplaysRemovesOnlyOrphans(t *testing.T) {
1411 dir := t.TempDir()
1412 turn := []plannerDisplayTurn{{UserHash: messageDisplayKey("prompt"), Messages: []HistoryMessage{{Role: "assistant", Content: "display"}}}}
1413 if err := saveSessionPlannerDisplays(dir, sessionPlannerDisplayMap{
1414 "live.jsonl": turn,
1415 "trashed.jsonl": turn,
1416 "protected.jsonl": turn,
1417 "missing.jsonl": turn,
1418 "sidecar.events.jsonl": turn,
1419 }); err != nil {
1420 t.Fatalf("save planner displays: %v", err)
1421 }
1422 if err := os.WriteFile(filepath.Join(dir, "live.jsonl"), []byte("data"), 0o644); err != nil {
1423 t.Fatalf("write live session: %v", err)
1424 }
1425 trashDir := filepath.Join(dir, sessionTrashDir, "trashed.jsonl")
1426 if err := os.MkdirAll(trashDir, 0o755); err != nil {
1427 t.Fatalf("mkdir trash: %v", err)
1428 }
1429 if err := os.WriteFile(filepath.Join(trashDir, "trashed.jsonl"), []byte("data"), 0o644); err != nil {
1430 t.Fatalf("write trashed session: %v", err)
1431 }
1432
1433 if err := pruneSessionPlannerDisplays(dir, map[string]struct{}{"protected.jsonl": {}}); err != nil {
1434 t.Fatalf("prune planner displays: %v", err)
1435 }
1436 got := loadSessionPlannerDisplays(dir)
1437 for _, key := range []string{"live.jsonl", "trashed.jsonl", "protected.jsonl"} {
1438 if got[key] == nil {
1439 t.Fatalf("%s planner display should be retained; got %#v", key, got)
1440 }
1441 }
1442 for _, key := range []string{"missing.jsonl", "sidecar.events.jsonl"} {
1443 if got[key] != nil {
1444 t.Fatalf("%s planner display should be pruned; got %#v", key, got)
1445 }
1446 }
1447 }
1448
1449 func TestPruneSessionDisplaysRemovesOnlyOrphans(t *testing.T) {
1450 dir := t.TempDir()
1451 content := "expanded prompt"
1452 if err := saveSessionDisplays(dir, sessionDisplayMap{
1453 "live.jsonl": map[string]string{messageDisplayKey(content): "live display"},
1454 "trashed.jsonl": map[string]string{messageDisplayKey(content): "trash display"},
1455 "protected.jsonl": map[string]string{messageDisplayKey(content): "protected display"},
1456 "missing.jsonl": map[string]string{messageDisplayKey(content): "missing display"},
1457 "sidecar.events.jsonl": map[string]string{
1458 messageDisplayKey(content): "invalid display",
1459 },
1460 }); err != nil {
1461 t.Fatalf("save displays: %v", err)
1462 }
1463 if err := os.WriteFile(filepath.Join(dir, "live.jsonl"), []byte("data"), 0o644); err != nil {
1464 t.Fatalf("write live session: %v", err)
1465 }
1466 trashDir := filepath.Join(dir, sessionTrashDir, "trashed.jsonl")
1467 if err := os.MkdirAll(trashDir, 0o755); err != nil {
1468 t.Fatalf("mkdir trash: %v", err)
1469 }
1470 if err := os.WriteFile(filepath.Join(trashDir, "trashed.jsonl"), []byte("data"), 0o644); err != nil {
1471 t.Fatalf("write trashed session: %v", err)
1472 }
1473
1474 if err := pruneSessionDisplays(dir, map[string]struct{}{"protected.jsonl": {}}); err != nil {
1475 t.Fatalf("prune displays: %v", err)
1476 }
1477
1478 got := loadSessionDisplays(dir)
1479 for _, key := range []string{"live.jsonl", "trashed.jsonl", "protected.jsonl"} {
1480 if got[key] == nil {
1481 t.Fatalf("%s display should be retained; got %#v", key, got)
1482 }
1483 }
1484 for _, key := range []string{"missing.jsonl", "sidecar.events.jsonl"} {
1485 if got[key] != nil {
1486 t.Fatalf("%s display should be pruned; got %#v", key, got)
1487 }
1488 }
1489 }
1490
1491 func TestRecordSessionDisplaySkipsNoop(t *testing.T) {
1492 dir := t.TempDir()
1493 sessionPath := filepath.Join(dir, "s.jsonl")
1494 if err := recordSessionDisplay(dir, sessionPath, "same", "same"); err != nil {
1495 t.Fatalf("record display: %v", err)
1496 }
1497 if _, err := os.Stat(sessionDisplayPath(dir)); !os.IsNotExist(err) {
1498 t.Fatalf("noop display should not create sidecar, stat err = %v", err)
1499 }
1500 }
1501
1502 func TestRecordSessionDisplaySerializesConcurrentTabs(t *testing.T) {
1503 dir := t.TempDir()
1504 const tabs = 32
1505 errs := make(chan error, tabs)
1506 var wg sync.WaitGroup
1507 for i := 0; i < tabs; i++ {
1508 wg.Add(1)
1509 go func(i int) {
1510 defer wg.Done()
1511 path := filepath.Join(dir, fmt.Sprintf("tab-%02d.jsonl", i))
1512 content := fmt.Sprintf("expanded-%02d", i)
1513 display := fmt.Sprintf("display-%02d", i)
1514 errs <- recordSessionDisplay(dir, path, content, display)
1515 }(i)
1516 }
1517 wg.Wait()
1518 close(errs)
1519 for err := range errs {
1520 if err != nil {
1521 t.Fatalf("recordSessionDisplay: %v", err)
1522 }
1523 }
1524
1525 got := loadSessionDisplays(dir)
1526 for i := 0; i < tabs; i++ {
1527 key := fmt.Sprintf("tab-%02d.jsonl", i)
1528 content := fmt.Sprintf("expanded-%02d", i)
1529 if display := got[key][messageDisplayKey(content)]; display != fmt.Sprintf("display-%02d", i) {
1530 t.Fatalf("%s display = %q, want retained concurrent value", key, display)
1531 }
1532 }
1533 }
1534
1534 lines GO