1215 lines
43 KiB
Go
1215 lines
43 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/jobs"
|
|
"reasonix/internal/store"
|
|
)
|
|
|
|
func occupyReadFileWithTimeoutSlots(t *testing.T) func() {
|
|
t.Helper()
|
|
filled := 0
|
|
for filled < cap(readFileWithTimeoutSlots) {
|
|
readFileWithTimeoutSlots <- struct{}{}
|
|
filled++
|
|
}
|
|
released := false
|
|
release := func() {
|
|
if released {
|
|
return
|
|
}
|
|
released = true
|
|
for i := 0; i < filled; i++ {
|
|
<-readFileWithTimeoutSlots
|
|
}
|
|
}
|
|
t.Cleanup(release)
|
|
return release
|
|
}
|
|
|
|
// --- loadSessionTitles ---
|
|
|
|
func TestLoadSessionTitlesMissing(t *testing.T) {
|
|
dir := t.TempDir()
|
|
m := loadSessionTitles(dir)
|
|
if len(m) != 0 {
|
|
t.Errorf("missing file should return empty map, got %v", m)
|
|
}
|
|
}
|
|
|
|
func TestLoadSessionTitlesCorrupt(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(sessionTitlesPath(dir), []byte(`{not json`), 0o644)
|
|
m := loadSessionTitles(dir)
|
|
if len(m) != 0 {
|
|
t.Errorf("corrupt file should return empty map, got %v", m)
|
|
}
|
|
}
|
|
|
|
func TestLoadSessionTitlesValid(t *testing.T) {
|
|
dir := t.TempDir()
|
|
data := map[string]string{"session-1.jsonl": "My Session", "session-2.jsonl": "Another"}
|
|
b, _ := json.Marshal(data)
|
|
os.WriteFile(sessionTitlesPath(dir), b, 0o644)
|
|
m := loadSessionTitles(dir)
|
|
if m["session-1.jsonl"] != "My Session" || m["session-2.jsonl"] != "Another" {
|
|
t.Errorf("loaded = %v", m)
|
|
}
|
|
}
|
|
|
|
// --- saveSessionTitles ---
|
|
|
|
func TestSaveSessionTitlesCreatesDir(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "nested", "sessions")
|
|
m := map[string]string{"a.jsonl": "title A"}
|
|
if err := saveSessionTitles(dir, m); err != nil {
|
|
t.Fatalf("save: %v", err)
|
|
}
|
|
// Verify file exists and is valid JSON.
|
|
b, err := os.ReadFile(sessionTitlesPath(dir))
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
var decoded map[string]string
|
|
if err := json.Unmarshal(b, &decoded); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if decoded["a.jsonl"] != "title A" {
|
|
t.Errorf("decoded = %v", decoded)
|
|
}
|
|
}
|
|
|
|
func TestSaveSessionTitlesRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
original := map[string]string{"s1.jsonl": "First", "s2.jsonl": "Second"}
|
|
if err := saveSessionTitles(dir, original); err != nil {
|
|
t.Fatalf("save: %v", err)
|
|
}
|
|
loaded := loadSessionTitles(dir)
|
|
if loaded["s1.jsonl"] != "First" || loaded["s2.jsonl"] != "Second" {
|
|
t.Errorf("round-trip = %v", loaded)
|
|
}
|
|
}
|
|
|
|
// --- setSessionTitle ---
|
|
|
|
func TestSetSessionTitle(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "my-session.jsonl")
|
|
|
|
// Set a title.
|
|
if err := setSessionTitle(dir, sessionPath, "Custom Title"); err != nil {
|
|
t.Fatalf("set: %v", err)
|
|
}
|
|
m := loadSessionTitles(dir)
|
|
if m["my-session.jsonl"] != "Custom Title" {
|
|
t.Errorf("title = %q", m["my-session.jsonl"])
|
|
}
|
|
|
|
// Clear the title (empty string).
|
|
if err := setSessionTitle(dir, sessionPath, ""); err != nil {
|
|
t.Fatalf("clear: %v", err)
|
|
}
|
|
m = loadSessionTitles(dir)
|
|
if _, ok := m["my-session.jsonl"]; ok {
|
|
t.Error("cleared title should be removed from map")
|
|
}
|
|
}
|
|
|
|
func TestSetSessionTitleTrimsWhitespace(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "s.jsonl")
|
|
if err := setSessionTitle(dir, sessionPath, " trimmed "); err != nil {
|
|
t.Fatalf("set: %v", err)
|
|
}
|
|
m := loadSessionTitles(dir)
|
|
if m["s.jsonl"] != "trimmed" {
|
|
t.Errorf("title = %q, want trimmed", m["s.jsonl"])
|
|
}
|
|
}
|
|
|
|
func TestSetSessionTitlePreservesExistingTitlesWhenTimedReadSlotsFull(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := saveSessionTitles(dir, map[string]string{"old.jsonl": "Old"}); err != nil {
|
|
t.Fatalf("save old title: %v", err)
|
|
}
|
|
sessionPath := filepath.Join(dir, "new.jsonl")
|
|
release := occupyReadFileWithTimeoutSlots(t)
|
|
if err := setSessionTitle(dir, sessionPath, "New"); err != nil {
|
|
t.Fatalf("setSessionTitle: %v", err)
|
|
}
|
|
release()
|
|
|
|
m := loadSessionTitles(dir)
|
|
if got := m["old.jsonl"]; got != "Old" {
|
|
t.Fatalf("old title = %q, want Old (all titles: %v)", got, m)
|
|
}
|
|
if got := m["new.jsonl"]; got != "New" {
|
|
t.Fatalf("new title = %q, want New (all titles: %v)", got, m)
|
|
}
|
|
}
|
|
|
|
// --- deleteSessionFile ---
|
|
|
|
func TestDeleteSessionFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "session.jsonl")
|
|
os.WriteFile(sessionPath, []byte("data"), 0o644)
|
|
metaPath := sessionPath + ".meta"
|
|
os.WriteFile(metaPath, []byte("{}"), 0o644)
|
|
goalPath := store.SessionGoalState(sessionPath)
|
|
os.WriteFile(goalPath, []byte(`{"goal":"ship"}`), 0o644)
|
|
eventLogPath := store.SessionEventLog(sessionPath)
|
|
os.WriteFile(eventLogPath, []byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"event"}]}`+"\n"), 0o644)
|
|
eventIndexPath := store.SessionEventIndex(sessionPath)
|
|
os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644)
|
|
conflictLogPath := store.SessionConflictLog(sessionPath)
|
|
os.WriteFile(conflictLogPath, []byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644)
|
|
telemetryPath := sessionTelemetryPath(sessionPath)
|
|
os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644)
|
|
lockPath := store.SessionLockFile(sessionPath)
|
|
os.WriteFile(lockPath, nil, 0o644)
|
|
leaseLockPath := store.SessionLeaseLock(sessionPath)
|
|
os.WriteFile(leaseLockPath, nil, 0o644)
|
|
leaseInfoPath := store.SessionLeaseInfo(sessionPath)
|
|
os.WriteFile(leaseInfoPath, []byte(`{"writer_id":"stale"}`), 0o644)
|
|
ckptDir := filepath.Join(dir, "session.ckpt")
|
|
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir ckpt: %v", err)
|
|
}
|
|
os.WriteFile(filepath.Join(ckptDir, "1.json"), []byte("{}"), 0o644)
|
|
jobsDir := jobs.ArtifactDir(sessionPath)
|
|
if err := os.MkdirAll(jobsDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir jobs: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(jobsDir, "bash-1.log"), []byte("output"), 0o644); err != nil {
|
|
t.Fatalf("write job artifact: %v", err)
|
|
}
|
|
|
|
// Set a title first.
|
|
setSessionTitle(dir, sessionPath, "My Title")
|
|
if err := recordSessionDisplay(dir, sessionPath, "expanded prompt", "[Pasted text #1 · 5 lines]"); err != nil {
|
|
t.Fatalf("record display: %v", err)
|
|
}
|
|
|
|
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
|
|
trashMetaPath := trashPath + ".meta"
|
|
trashGoalPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.goal-state.json")
|
|
trashEventLogPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.events.jsonl")
|
|
trashEventIndexPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.event-index.json")
|
|
trashConflictLogPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.conflicts.jsonl")
|
|
trashTelemetryPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.telemetry.json")
|
|
trashCkptDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.ckpt")
|
|
trashJobsDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jobs")
|
|
|
|
// File should be moved out of the active session list.
|
|
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
|
|
t.Error("session file should be removed from active sessions")
|
|
}
|
|
if _, err := os.Stat(metaPath); !os.IsNotExist(err) {
|
|
t.Error("session meta should be removed from active sessions")
|
|
}
|
|
if _, err := os.Stat(goalPath); !os.IsNotExist(err) {
|
|
t.Error("session goal state should be removed from active sessions")
|
|
}
|
|
if _, err := os.Stat(eventLogPath); !os.IsNotExist(err) {
|
|
t.Error("session event log should be removed from active sessions")
|
|
}
|
|
if _, err := os.Stat(eventIndexPath); !os.IsNotExist(err) {
|
|
t.Error("session event index should be removed from active sessions")
|
|
}
|
|
if _, err := os.Stat(conflictLogPath); !os.IsNotExist(err) {
|
|
t.Error("session conflict log should be removed from active sessions")
|
|
}
|
|
if _, err := os.Stat(telemetryPath); !os.IsNotExist(err) {
|
|
t.Error("session telemetry should be removed from active sessions")
|
|
}
|
|
if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
|
|
t.Error("session lock should be removed from active sessions")
|
|
}
|
|
if _, err := os.Stat(leaseLockPath); !os.IsNotExist(err) {
|
|
t.Error("session lease lock should be removed from active sessions")
|
|
}
|
|
if _, err := os.Stat(leaseInfoPath); !os.IsNotExist(err) {
|
|
t.Error("session lease info should be removed from active sessions")
|
|
}
|
|
if _, err := os.Stat(ckptDir); !os.IsNotExist(err) {
|
|
t.Error("session checkpoints should be removed from active sessions")
|
|
}
|
|
if _, err := os.Stat(jobsDir); !os.IsNotExist(err) {
|
|
t.Error("session jobs should be removed from active sessions")
|
|
}
|
|
if _, err := os.Stat(trashPath); err != nil {
|
|
t.Fatalf("session file should be in trash: %v", err)
|
|
}
|
|
if _, err := os.Stat(trashMetaPath); err != nil {
|
|
t.Fatalf("session meta should be in trash: %v", err)
|
|
}
|
|
if _, err := os.Stat(trashGoalPath); err != nil {
|
|
t.Fatalf("session goal state should be in trash: %v", err)
|
|
}
|
|
if _, err := os.Stat(trashEventLogPath); err != nil {
|
|
t.Fatalf("session event log should be in trash: %v", err)
|
|
}
|
|
if _, err := os.Stat(trashEventIndexPath); err != nil {
|
|
t.Fatalf("session event index should be in trash: %v", err)
|
|
}
|
|
if _, err := os.Stat(trashConflictLogPath); err != nil {
|
|
t.Fatalf("session conflict log should be in trash: %v", err)
|
|
}
|
|
if _, err := os.Stat(trashTelemetryPath); err != nil {
|
|
t.Fatalf("session telemetry should be in trash: %v", err)
|
|
}
|
|
for _, p := range []string{
|
|
filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lock"),
|
|
filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lease.lock"),
|
|
filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lease.json"),
|
|
} {
|
|
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
|
t.Fatalf("ephemeral session artifact should not be moved to trash: %s (err=%v)", p, err)
|
|
}
|
|
}
|
|
if _, err := os.Stat(trashCkptDir); err != nil {
|
|
t.Fatalf("session checkpoints should be in trash: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(trashJobsDir, "bash-1.log")); err != nil {
|
|
t.Fatalf("session jobs should be in trash: %v", err)
|
|
}
|
|
// Title/display should be retained until permanent deletion.
|
|
m := loadSessionTitles(dir)
|
|
if m["session.jsonl"] != "My Title" {
|
|
t.Errorf("title should be retained in trash, got %q", m["session.jsonl"])
|
|
}
|
|
if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "[Pasted text #1 · 5 lines]" {
|
|
t.Errorf("display sidecar should be retained in trash, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestReconcileDesktopCleanupPendingDeleteMovesArtifactsToTrash(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "pending.jsonl")
|
|
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"pending"}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(jobs.ArtifactDir(sessionPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(jobs.ArtifactDir(sessionPath), "job.log"), []byte("job output"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := reconcileDesktopCleanupPending(dir); err != nil {
|
|
t.Fatalf("reconcileDesktopCleanupPending: %v", err)
|
|
}
|
|
|
|
trashPath := filepath.Join(dir, sessionTrashDir, "pending.jsonl", "pending.jsonl")
|
|
trashJobsDir := filepath.Join(dir, sessionTrashDir, "pending.jsonl", "pending.jobs")
|
|
for _, p := range []string{
|
|
trashPath,
|
|
filepath.Join(trashJobsDir, "job.log"),
|
|
} {
|
|
if _, err := os.Stat(p); err != nil {
|
|
t.Fatalf("expected trashed artifact %s: %v", p, err)
|
|
}
|
|
}
|
|
for _, p := range []string{sessionPath, jobs.ArtifactDir(sessionPath), agent.CleanupPendingPath(sessionPath)} {
|
|
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
|
t.Fatalf("%s still exists after reconciliation (err=%v)", p, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReconcileDesktopCleanupPendingDeleteReusesExistingTrashDir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "partial.jsonl")
|
|
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"partial"}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
itemDir := filepath.Join(sessionTrashPath(dir), "partial.jsonl")
|
|
if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := reconcileDesktopCleanupPending(dir); err != nil {
|
|
t.Fatalf("reconcileDesktopCleanupPending: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(itemDir, "partial.jsonl")); err != nil {
|
|
t.Fatalf("session should be moved into existing trash dir: %v", err)
|
|
}
|
|
if _, err := os.Stat(agent.CleanupPendingPath(sessionPath)); !os.IsNotExist(err) {
|
|
t.Fatalf("cleanup marker still exists after reconciliation (err=%v)", err)
|
|
}
|
|
}
|
|
|
|
func TestReconcileDesktopCleanupPendingDeleteMovesRemainingSidecars(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "sidecars.jsonl")
|
|
if err := os.WriteFile(sessionPath+".meta", []byte("{}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
goalPath := store.SessionGoalState(sessionPath)
|
|
if err := os.WriteFile(goalPath, []byte(`{"goal":"finish"}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
telemetryPath := sessionTelemetryPath(sessionPath)
|
|
if err := os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ckptDir := filepath.Join(dir, "sidecars.ckpt")
|
|
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(ckptDir, "1.json"), []byte("{}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(jobs.ArtifactDir(sessionPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(jobs.ArtifactDir(sessionPath), "job.log"), []byte("job output"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref := "sa_20260102_030405_000000000_aabbccddeeff"
|
|
writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))
|
|
itemDir := filepath.Join(sessionTrashPath(dir), "sidecars.jsonl")
|
|
if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(itemDir, "sidecars.jsonl"), []byte(`{"role":"user","content":"sidecars"}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := reconcileDesktopCleanupPending(dir); err != nil {
|
|
t.Fatalf("reconcileDesktopCleanupPending: %v", err)
|
|
}
|
|
|
|
for _, p := range []string{
|
|
filepath.Join(itemDir, "sidecars.jsonl.meta"),
|
|
filepath.Join(itemDir, "sidecars.goal-state.json"),
|
|
filepath.Join(itemDir, "sidecars.jsonl.telemetry.json"),
|
|
filepath.Join(itemDir, "sidecars.ckpt", "1.json"),
|
|
filepath.Join(itemDir, "sidecars.jobs", "job.log"),
|
|
filepath.Join(itemDir, "subagents", ref+".jsonl"),
|
|
filepath.Join(itemDir, "subagents", ref+".meta.json"),
|
|
} {
|
|
if _, err := os.Stat(p); err != nil {
|
|
t.Fatalf("expected remaining artifact %s to move into trash: %v", p, err)
|
|
}
|
|
}
|
|
for _, p := range []string{
|
|
sessionPath + ".meta",
|
|
goalPath,
|
|
telemetryPath,
|
|
ckptDir,
|
|
jobs.ArtifactDir(sessionPath),
|
|
filepath.Join(dir, "subagents", ref+".jsonl"),
|
|
filepath.Join(dir, "subagents", ref+".meta.json"),
|
|
agent.CleanupPendingPath(sessionPath),
|
|
} {
|
|
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
|
t.Fatalf("%s still exists after reconciliation (err=%v)", p, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeleteSessionFileMovesOwnedSubagentsToTrash(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "session.jsonl")
|
|
os.WriteFile(sessionPath, []byte("data"), 0o644)
|
|
writeSubagentArtifact(t, dir, "sa_20260102_030405_000000000_aabbccddeeff", agent.BranchID(sessionPath))
|
|
writeSubagentArtifact(t, dir, "sa_20260102_030405_000000000_112233445566", "other-parent")
|
|
|
|
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
|
|
ownedJSONL := filepath.Join(dir, "subagents", "sa_20260102_030405_000000000_aabbccddeeff.jsonl")
|
|
ownedMeta := filepath.Join(dir, "subagents", "sa_20260102_030405_000000000_aabbccddeeff.meta.json")
|
|
if _, err := os.Stat(ownedJSONL); !os.IsNotExist(err) {
|
|
t.Fatalf("owned subagent jsonl should be moved out of active dir, stat err = %v", err)
|
|
}
|
|
if _, err := os.Stat(ownedMeta); !os.IsNotExist(err) {
|
|
t.Fatalf("owned subagent meta should be moved out of active dir, stat err = %v", err)
|
|
}
|
|
trashSubagentDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "subagents")
|
|
if _, err := os.Stat(filepath.Join(trashSubagentDir, "sa_20260102_030405_000000000_aabbccddeeff.jsonl")); err != nil {
|
|
t.Fatalf("owned subagent jsonl should be in trash: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(trashSubagentDir, "sa_20260102_030405_000000000_aabbccddeeff.meta.json")); err != nil {
|
|
t.Fatalf("owned subagent meta should be in trash: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "subagents", "sa_20260102_030405_000000000_112233445566.jsonl")); err != nil {
|
|
t.Fatalf("unowned subagent jsonl should remain active: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "subagents", "sa_20260102_030405_000000000_112233445566.meta.json")); err != nil {
|
|
t.Fatalf("unowned subagent meta should remain active: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteSessionFileNoTitle(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "no-title.jsonl")
|
|
os.WriteFile(sessionPath, []byte("data"), 0o644)
|
|
|
|
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
|
|
t.Error("session file should be removed from active sessions")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, sessionTrashDir, "no-title.jsonl", "no-title.jsonl")); err != nil {
|
|
t.Fatalf("session file should be in trash: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRestoreTrashedSessionFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "session.jsonl")
|
|
if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(sessionPath+".meta", []byte("{}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
goalPath := store.SessionGoalState(sessionPath)
|
|
if err := os.WriteFile(goalPath, []byte(`{"goal":"restore"}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
eventLogPath := store.SessionEventLog(sessionPath)
|
|
if err := os.WriteFile(eventLogPath, []byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"event"}]}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
eventIndexPath := store.SessionEventIndex(sessionPath)
|
|
if err := os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conflictLogPath := store.SessionConflictLog(sessionPath)
|
|
if err := os.WriteFile(conflictLogPath, []byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
telemetryPath := sessionTelemetryPath(sessionPath)
|
|
if err := os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ckptDir := filepath.Join(dir, "session.ckpt")
|
|
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(ckptDir, "1.json"), []byte("{}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
jobsDir := jobs.ArtifactDir(sessionPath)
|
|
if err := os.MkdirAll(jobsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(jobsDir, "bash-1.log"), []byte("output"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := setSessionTitle(dir, sessionPath, "My Title"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
|
t.Fatalf("trash: %v", err)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
|
|
if err := restoreTrashedSessionFile(dir, trashPath); err != nil {
|
|
t.Fatalf("restore: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(sessionPath); err != nil {
|
|
t.Fatalf("session file should be restored: %v", err)
|
|
}
|
|
if _, err := os.Stat(sessionPath + ".meta"); err != nil {
|
|
t.Fatalf("session meta should be restored: %v", err)
|
|
}
|
|
if _, err := os.Stat(goalPath); err != nil {
|
|
t.Fatalf("session goal state should be restored: %v", err)
|
|
}
|
|
if _, err := os.Stat(eventLogPath); err != nil {
|
|
t.Fatalf("session event log should be restored: %v", err)
|
|
}
|
|
if _, err := os.Stat(eventIndexPath); err != nil {
|
|
t.Fatalf("session event index should be restored: %v", err)
|
|
}
|
|
if _, err := os.Stat(conflictLogPath); err != nil {
|
|
t.Fatalf("session conflict log should be restored: %v", err)
|
|
}
|
|
if _, err := os.Stat(telemetryPath); err != nil {
|
|
t.Fatalf("session telemetry should be restored: %v", err)
|
|
}
|
|
if _, err := os.Stat(ckptDir); err != nil {
|
|
t.Fatalf("session checkpoints should be restored: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(jobsDir, "bash-1.log")); err != nil {
|
|
t.Fatalf("session jobs should be restored: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Dir(trashPath)); !os.IsNotExist(err) {
|
|
t.Fatalf("trash item should be removed after restore, stat err = %v", err)
|
|
}
|
|
if got := loadSessionTitles(dir)["session.jsonl"]; got != "My Title" {
|
|
t.Fatalf("title should survive restore, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRestoreTrashedSessionFileWithEmptyLiveStub(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "restore-stub.jsonl")
|
|
if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
|
t.Fatalf("trash: %v", err)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
|
|
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
|
|
t.Fatalf("write live stub: %v", err)
|
|
}
|
|
|
|
if err := restoreTrashedSessionFile(dir, trashPath); err != nil {
|
|
t.Fatalf("restore should replace empty live stub: %v", err)
|
|
}
|
|
if got, err := os.ReadFile(sessionPath); err != nil || string(got) != "data" {
|
|
t.Fatalf("restored session = %q, %v; want trash data", string(got), err)
|
|
}
|
|
if _, err := os.Stat(filepath.Dir(trashPath)); !os.IsNotExist(err) {
|
|
t.Fatalf("trash item should be removed after restore, err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestRestoreTrashedSessionFileFromUniqueTrashItem(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "restore-unique.jsonl")
|
|
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
|
t.Fatalf("trash old: %v", err)
|
|
}
|
|
fixedTrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
|
|
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"new"}`+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write new live session: %v", err)
|
|
}
|
|
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
|
t.Fatalf("trash new with fixed trash conflict: %v", err)
|
|
}
|
|
|
|
trashed, err := listTrashedSessionFiles(dir)
|
|
if err != nil {
|
|
t.Fatalf("list trash: %v", err)
|
|
}
|
|
var uniqueTrashPath string
|
|
for _, candidate := range trashed {
|
|
if candidate != fixedTrashPath && filepath.Base(candidate) == filepath.Base(sessionPath) {
|
|
uniqueTrashPath = candidate
|
|
break
|
|
}
|
|
}
|
|
if uniqueTrashPath == "" {
|
|
t.Fatalf("unique trash path not listed, got %#v", trashed)
|
|
}
|
|
if err := restoreTrashedSessionFile(dir, uniqueTrashPath); err != nil {
|
|
t.Fatalf("restore unique trash item: %v", err)
|
|
}
|
|
if got, err := os.ReadFile(sessionPath); err != nil || !strings.Contains(string(got), "new") {
|
|
t.Fatalf("restored session = %q err=%v, want new content", string(got), err)
|
|
}
|
|
if _, err := os.Stat(filepath.Dir(uniqueTrashPath)); !os.IsNotExist(err) {
|
|
t.Fatalf("unique trash item should be removed after restore, stat err = %v", err)
|
|
}
|
|
if _, err := os.Stat(fixedTrashPath); err != nil {
|
|
t.Fatalf("original fixed trash item should remain: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateSessionTrashTargetKeepsDiscardableLiveStub(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "discardable-live.jsonl")
|
|
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
|
|
t.Fatalf("write live stub: %v", err)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
|
|
if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
|
|
t.Fatalf("create trash dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write trash session: %v", err)
|
|
}
|
|
|
|
if err := validateSessionTrashTarget(dir, sessionPath, filepath.Base(sessionPath)); err != nil {
|
|
t.Fatalf("validateSessionTrashTarget: %v", err)
|
|
}
|
|
if _, err := os.Stat(sessionPath); err != nil {
|
|
t.Fatalf("validation should not remove live stub: %v", err)
|
|
}
|
|
if _, err := os.Stat(trashPath); err != nil {
|
|
t.Fatalf("validation should keep existing trash: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRestoreTrashedSessionFileRejectsNonEmptyLiveConflict(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "restore-conflict.jsonl")
|
|
if err := os.WriteFile(sessionPath, []byte("trash data"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
|
t.Fatalf("trash: %v", err)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
|
|
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"live"}`+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write live session: %v", err)
|
|
}
|
|
|
|
err := restoreTrashedSessionFile(dir, trashPath)
|
|
if err == nil || !strings.Contains(err.Error(), "session already exists") {
|
|
t.Fatalf("restore conflict error = %v, want session already exists", err)
|
|
}
|
|
if got, err := os.ReadFile(sessionPath); err != nil || !strings.Contains(string(got), "live") {
|
|
t.Fatalf("live session should remain, got %q err=%v", string(got), err)
|
|
}
|
|
if _, err := os.Stat(trashPath); err != nil {
|
|
t.Fatalf("trash session should remain after rejected restore: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRestoreTrashedSessionFileRestoresSubagents(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "session.jsonl")
|
|
if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref := "sa_20260102_030405_000000000_aabbccddeeff"
|
|
writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))
|
|
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
|
t.Fatalf("trash: %v", err)
|
|
}
|
|
|
|
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
|
|
if err := restoreTrashedSessionFile(dir, trashPath); err != nil {
|
|
t.Fatalf("restore: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".jsonl")); err != nil {
|
|
t.Fatalf("subagent jsonl should be restored: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".meta.json")); err != nil {
|
|
t.Fatalf("subagent meta should be restored: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRestoreTrashedSessionFileRejectsSubagentConflict(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "session.jsonl")
|
|
if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref := "sa_20260102_030405_000000000_aabbccddeeff"
|
|
writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))
|
|
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
|
t.Fatalf("trash: %v", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(dir, "subagents"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "subagents", ref+".jsonl"), []byte("conflict"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
|
|
if err := restoreTrashedSessionFile(dir, trashPath); err == nil {
|
|
t.Fatal("restore should fail on subagent conflict")
|
|
}
|
|
if _, err := os.Stat(trashPath); err != nil {
|
|
t.Fatalf("trash item should remain after failed restore: %v", err)
|
|
}
|
|
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
|
|
t.Fatalf("parent session should not be restored after conflict, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPurgeTrashedSessionFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "session.jsonl")
|
|
os.WriteFile(sessionPath, []byte("data"), 0o644)
|
|
goalPath := store.SessionGoalState(sessionPath)
|
|
os.WriteFile(goalPath, []byte(`{"goal":"purge"}`), 0o644)
|
|
telemetryPath := sessionTelemetryPath(sessionPath)
|
|
os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644)
|
|
jobsDir := jobs.ArtifactDir(sessionPath)
|
|
if err := os.MkdirAll(jobsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(jobsDir, "bash-1.log"), []byte("output"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := setSessionTitle(dir, sessionPath, "My Title"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := recordSessionDisplay(dir, sessionPath, "expanded prompt", "[Pasted text #1 · 5 lines]"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
|
t.Fatalf("trash: %v", err)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
|
|
if err := purgeTrashedSessionFile(dir, trashPath); err != nil {
|
|
t.Fatalf("purge: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Dir(trashPath)); !os.IsNotExist(err) {
|
|
t.Fatalf("trash item should be removed after purge, stat err = %v", err)
|
|
}
|
|
if _, err := os.Stat(jobsDir); !os.IsNotExist(err) {
|
|
t.Fatalf("session jobs should be removed after purge, stat err = %v", err)
|
|
}
|
|
if _, err := os.Stat(goalPath); !os.IsNotExist(err) {
|
|
t.Fatalf("session goal state should be removed after purge, stat err = %v", err)
|
|
}
|
|
if _, err := os.Stat(telemetryPath); !os.IsNotExist(err) {
|
|
t.Fatalf("session telemetry should be removed after purge, stat err = %v", err)
|
|
}
|
|
if _, ok := loadSessionTitles(dir)["session.jsonl"]; ok {
|
|
t.Fatal("title should be removed after purge")
|
|
}
|
|
if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "expanded prompt" {
|
|
t.Fatalf("display sidecar should be removed after purge, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPurgeTrashedSessionFileRemovesSubagents(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "session.jsonl")
|
|
os.WriteFile(sessionPath, []byte("data"), 0o644)
|
|
ref := "sa_20260102_030405_000000000_aabbccddeeff"
|
|
writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))
|
|
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
|
t.Fatalf("trash: %v", err)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
|
|
if err := purgeTrashedSessionFile(dir, trashPath); err != nil {
|
|
t.Fatalf("purge: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, sessionTrashDir, "session.jsonl", "subagents", ref+".jsonl")); !os.IsNotExist(err) {
|
|
t.Fatalf("trashed subagent should be removed by purge, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestListTrashedSessionFilesRejectsSymlinkEscape(t *testing.T) {
|
|
dir := t.TempDir()
|
|
outside := filepath.Join(t.TempDir(), "outside.jsonl")
|
|
os.WriteFile(outside, []byte("data"), 0o644)
|
|
itemDir := filepath.Join(dir, sessionTrashDir, "outside.jsonl")
|
|
if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(outside, filepath.Join(itemDir, "outside.jsonl")); err != nil {
|
|
t.Skipf("symlink unavailable: %v", err)
|
|
}
|
|
|
|
got, err := listTrashedSessionFiles(dir)
|
|
if err != nil {
|
|
t.Fatalf("list trash: %v", err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("trash listing should skip symlink escape, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestDeleteSessionFileMissing(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Deleting a non-existent file should not error.
|
|
if err := deleteSessionFile(dir, filepath.Join(dir, "missing.jsonl")); err != nil {
|
|
t.Fatalf("delete missing: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "session.jsonl")
|
|
for _, p := range []string{
|
|
sessionPath,
|
|
store.SessionMeta(sessionPath),
|
|
store.SessionGoalState(sessionPath),
|
|
store.SessionEventLog(sessionPath),
|
|
store.SessionEventIndex(sessionPath),
|
|
store.SessionConflictLog(sessionPath),
|
|
sessionTelemetryPath(sessionPath),
|
|
store.SessionLockFile(sessionPath),
|
|
store.SessionLeaseLock(sessionPath),
|
|
store.SessionLeaseInfo(sessionPath),
|
|
} {
|
|
if err := os.WriteFile(p, []byte("{}"), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", p, err)
|
|
}
|
|
}
|
|
if err := recordSessionDisplay(dir, sessionPath, "expanded prompt", "[Pasted text #1 · 2 lines]"); err != nil {
|
|
t.Fatalf("record display: %v", err)
|
|
}
|
|
ckptDir := store.SessionCheckpointDir(sessionPath)
|
|
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(ckptDir, "1.json"), []byte("{}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
jobsDir := jobs.ArtifactDir(sessionPath)
|
|
if err := os.MkdirAll(jobsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(jobsDir, "job.log"), []byte("output"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := removeDesktopSessionArtifacts(sessionPath); err != nil {
|
|
t.Fatalf("removeDesktopSessionArtifacts: %v", err)
|
|
}
|
|
|
|
for _, p := range []string{
|
|
sessionPath,
|
|
store.SessionMeta(sessionPath),
|
|
store.SessionGoalState(sessionPath),
|
|
store.SessionEventLog(sessionPath),
|
|
store.SessionEventIndex(sessionPath),
|
|
store.SessionConflictLog(sessionPath),
|
|
sessionTelemetryPath(sessionPath),
|
|
store.SessionLockFile(sessionPath),
|
|
store.SessionLeaseLock(sessionPath),
|
|
store.SessionLeaseInfo(sessionPath),
|
|
ckptDir,
|
|
jobsDir,
|
|
} {
|
|
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
|
t.Fatalf("%s should be removed, stat err = %v", p, err)
|
|
}
|
|
}
|
|
if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "expanded prompt" {
|
|
t.Fatalf("session display key should be removed, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDeleteSessionFileRejectsOutsideDir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
outside := filepath.Join(t.TempDir(), "outside.jsonl")
|
|
os.WriteFile(outside, []byte("data"), 0o644)
|
|
|
|
if err := deleteSessionFile(dir, outside); err == nil {
|
|
t.Fatal("delete outside dir should fail")
|
|
}
|
|
if _, err := os.Stat(outside); err != nil {
|
|
t.Fatalf("outside file should remain: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteSessionFileRejectsNonJSONL(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "session.meta")
|
|
os.WriteFile(path, []byte("data"), 0o644)
|
|
|
|
if err := deleteSessionFile(dir, path); err == nil {
|
|
t.Fatal("delete non-jsonl should fail")
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("non-jsonl file should remain: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteSessionFileRejectsSymlinkEscape(t *testing.T) {
|
|
dir := t.TempDir()
|
|
outside := filepath.Join(t.TempDir(), "outside.jsonl")
|
|
os.WriteFile(outside, []byte("data"), 0o644)
|
|
link := filepath.Join(dir, "link.jsonl")
|
|
if err := os.Symlink(outside, link); err != nil {
|
|
t.Skipf("symlink unavailable: %v", err)
|
|
}
|
|
|
|
if err := deleteSessionFile(dir, link); err == nil {
|
|
t.Fatal("delete symlink escape should fail")
|
|
}
|
|
if _, err := os.Stat(outside); err != nil {
|
|
t.Fatalf("outside target should remain: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMovePathIfExistsCopyFallback(t *testing.T) {
|
|
dir := t.TempDir()
|
|
src := filepath.Join(dir, "src.txt")
|
|
if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dst := filepath.Join(dir, "dst.txt")
|
|
|
|
// Test normal move.
|
|
if err := movePathIfExists(src, dst); err != nil {
|
|
t.Fatalf("move: %v", err)
|
|
}
|
|
if _, err := os.Stat(src); !os.IsNotExist(err) {
|
|
t.Fatal("src should be removed")
|
|
}
|
|
if got, err := os.ReadFile(dst); err != nil || string(got) != "hello" {
|
|
t.Fatalf("dst content = %q, err = %v", got, err)
|
|
}
|
|
|
|
// Test non-existent source is no-op.
|
|
if err := movePathIfExists(filepath.Join(dir, "missing.txt"), filepath.Join(dir, "other.txt")); err != nil {
|
|
t.Fatalf("move missing: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCopyFallbackTreatsMissingSourceAsAlreadyMoved(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := copyAndRemove(filepath.Join(dir, "missing.txt"), filepath.Join(dir, "dst.txt")); err != nil {
|
|
t.Fatalf("copy missing source: %v", err)
|
|
}
|
|
|
|
dstDir := filepath.Join(dir, "dst-dir")
|
|
if err := copyDir(filepath.Join(dir, "missing-dir"), dstDir, 0o755); err != nil {
|
|
t.Fatalf("copy missing dir: %v", err)
|
|
}
|
|
if _, err := os.Stat(dstDir); !os.IsNotExist(err) {
|
|
t.Fatalf("copy missing dir should not leave an empty target, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCopyFallbackRemovesPartialTargetWhenSourceVanishesMidCopy(t *testing.T) {
|
|
dir := t.TempDir()
|
|
src := filepath.Join(dir, "src.txt")
|
|
dst := filepath.Join(dir, "dst.txt")
|
|
if err := os.WriteFile(src, []byte("session transcript"), 0o644); err != nil {
|
|
t.Fatalf("write source: %v", err)
|
|
}
|
|
|
|
restore := copyPathFn
|
|
copyPathFn = func(copySrc, copyDst string) error {
|
|
// Simulate the source vanishing mid-copy: a truncated destination has
|
|
// already been written when the copy fails.
|
|
if err := os.WriteFile(copyDst, []byte("session tra"), 0o644); err != nil {
|
|
t.Fatalf("write partial destination: %v", err)
|
|
}
|
|
if err := os.Remove(copySrc); err != nil {
|
|
t.Fatalf("remove source: %v", err)
|
|
}
|
|
return errors.New("simulated read failure")
|
|
}
|
|
t.Cleanup(func() { copyPathFn = restore })
|
|
|
|
if err := copyAndRemove(src, dst); err != nil {
|
|
t.Fatalf("copyAndRemove should treat vanished source as already moved: %v", err)
|
|
}
|
|
if _, err := os.Stat(dst); !os.IsNotExist(err) {
|
|
t.Fatalf("partial destination should be removed, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCopyFallbackKeepsErrorWhenSourceStillExists(t *testing.T) {
|
|
dir := t.TempDir()
|
|
src := filepath.Join(dir, "src.txt")
|
|
dst := filepath.Join(dir, "dst.txt")
|
|
if err := os.WriteFile(src, []byte("session transcript"), 0o644); err != nil {
|
|
t.Fatalf("write source: %v", err)
|
|
}
|
|
|
|
restore := copyPathFn
|
|
simulated := errors.New("simulated copy failure")
|
|
copyPathFn = func(copySrc, copyDst string) error {
|
|
return simulated
|
|
}
|
|
t.Cleanup(func() { copyPathFn = restore })
|
|
|
|
if err := copyAndRemove(src, dst); !errors.Is(err, simulated) {
|
|
t.Fatalf("copyAndRemove error = %v, want simulated copy failure", err)
|
|
}
|
|
if _, err := os.Stat(src); err != nil {
|
|
t.Fatalf("source should be untouched on real copy failure: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCopyAndRemoveDirectory(t *testing.T) {
|
|
dir := t.TempDir()
|
|
srcDir := filepath.Join(dir, "src")
|
|
if err := os.MkdirAll(filepath.Join(srcDir, "sub"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("file a"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(srcDir, "sub", "b.txt"), []byte("file b"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dstDir := filepath.Join(dir, "dst")
|
|
|
|
if err := copyAndRemove(srcDir, dstDir); err != nil {
|
|
t.Fatalf("copyAndRemove: %v", err)
|
|
}
|
|
if _, err := os.Stat(srcDir); !os.IsNotExist(err) {
|
|
t.Fatal("src dir should be removed")
|
|
}
|
|
if got, err := os.ReadFile(filepath.Join(dstDir, "a.txt")); err != nil || string(got) != "file a" {
|
|
t.Fatalf("dst a.txt = %q, err = %v", got, err)
|
|
}
|
|
if got, err := os.ReadFile(filepath.Join(dstDir, "sub", "b.txt")); err != nil || string(got) != "file b" {
|
|
t.Fatalf("dst sub/b.txt = %q, err = %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestCopyAndRemoveDirectoryPreservesSymlinks(t *testing.T) {
|
|
dir := t.TempDir()
|
|
outside := filepath.Join(t.TempDir(), "outside.txt")
|
|
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srcDir := filepath.Join(dir, "src")
|
|
if err := os.MkdirAll(srcDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
link := filepath.Join(srcDir, "link.txt")
|
|
if err := os.Symlink(outside, link); err != nil {
|
|
t.Skipf("symlink unavailable: %v", err)
|
|
}
|
|
dstDir := filepath.Join(dir, "dst")
|
|
|
|
if err := copyAndRemove(srcDir, dstDir); err != nil {
|
|
t.Fatalf("copyAndRemove: %v", err)
|
|
}
|
|
if _, err := os.Stat(srcDir); !os.IsNotExist(err) {
|
|
t.Fatal("src dir should be removed")
|
|
}
|
|
dstLink := filepath.Join(dstDir, "link.txt")
|
|
info, err := os.Lstat(dstLink)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode()&os.ModeSymlink == 0 {
|
|
t.Fatalf("dst link should remain a symlink, mode=%v", info.Mode())
|
|
}
|
|
target, err := os.Readlink(dstLink)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if target != outside {
|
|
t.Fatalf("dst link target = %q, want %q", target, outside)
|
|
}
|
|
}
|
|
|
|
func writeSubagentArtifact(t *testing.T, dir, ref, parentSession string) {
|
|
t.Helper()
|
|
subagentDir := filepath.Join(dir, "subagents")
|
|
if err := os.MkdirAll(subagentDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(subagentDir, ref+".jsonl"), []byte(`{"role":"user","content":"sub"}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
meta := agent.SubagentMeta{
|
|
Ref: ref,
|
|
Status: agent.SubagentCompleted,
|
|
Kind: "task",
|
|
Name: "task",
|
|
ParentSession: parentSession,
|
|
}
|
|
data, err := json.Marshal(meta)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(subagentDir, ref+".meta.json"), data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// --- sessionTitlesPath ---
|
|
|
|
func TestSessionTitlesPath(t *testing.T) {
|
|
got := sessionTitlesPath("/sessions")
|
|
want := filepath.Join("/sessions", ".titles.json")
|
|
if got != want {
|
|
t.Errorf("sessionTitlesPath = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSessionDisplayRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "s.jsonl")
|
|
content := "prefix\n--- Begin [Pasted text #1 · 5 lines] ---\nfull text\n--- End [Pasted text #1 · 5 lines] ---"
|
|
display := "[Pasted text #1 · 5 lines]"
|
|
if err := recordSessionDisplay(dir, sessionPath, content, display); err != nil {
|
|
t.Fatalf("record display: %v", err)
|
|
}
|
|
if got := resolveSessionDisplay(dir, sessionPath, content); got != display {
|
|
t.Fatalf("display = %q, want %q", got, display)
|
|
}
|
|
if got := resolveSessionDisplay(dir, sessionPath, "other"); got != "other" {
|
|
t.Fatalf("unknown content should pass through, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPruneSessionDisplaysRemovesOnlyOrphans(t *testing.T) {
|
|
dir := t.TempDir()
|
|
content := "expanded prompt"
|
|
if err := saveSessionDisplays(dir, sessionDisplayMap{
|
|
"live.jsonl": map[string]string{messageDisplayKey(content): "live display"},
|
|
"trashed.jsonl": map[string]string{messageDisplayKey(content): "trash display"},
|
|
"protected.jsonl": map[string]string{messageDisplayKey(content): "protected display"},
|
|
"missing.jsonl": map[string]string{messageDisplayKey(content): "missing display"},
|
|
"sidecar.events.jsonl": map[string]string{
|
|
messageDisplayKey(content): "invalid display",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save displays: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "live.jsonl"), []byte("data"), 0o644); err != nil {
|
|
t.Fatalf("write live session: %v", err)
|
|
}
|
|
trashDir := filepath.Join(dir, sessionTrashDir, "trashed.jsonl")
|
|
if err := os.MkdirAll(trashDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir trash: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(trashDir, "trashed.jsonl"), []byte("data"), 0o644); err != nil {
|
|
t.Fatalf("write trashed session: %v", err)
|
|
}
|
|
|
|
if err := pruneSessionDisplays(dir, map[string]struct{}{"protected.jsonl": {}}); err != nil {
|
|
t.Fatalf("prune displays: %v", err)
|
|
}
|
|
|
|
got := loadSessionDisplays(dir)
|
|
for _, key := range []string{"live.jsonl", "trashed.jsonl", "protected.jsonl"} {
|
|
if got[key] == nil {
|
|
t.Fatalf("%s display should be retained; got %#v", key, got)
|
|
}
|
|
}
|
|
for _, key := range []string{"missing.jsonl", "sidecar.events.jsonl"} {
|
|
if got[key] != nil {
|
|
t.Fatalf("%s display should be pruned; got %#v", key, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRecordSessionDisplaySkipsNoop(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "s.jsonl")
|
|
if err := recordSessionDisplay(dir, sessionPath, "same", "same"); err != nil {
|
|
t.Fatalf("record display: %v", err)
|
|
}
|
|
if _, err := os.Stat(sessionDisplayPath(dir)); !os.IsNotExist(err) {
|
|
t.Fatalf("noop display should not create sidecar, stat err = %v", err)
|
|
}
|
|
}
|