package main import ( "errors" "os" "path/filepath" "strings" "testing" "time" "reasonix/internal/agent" "reasonix/internal/config" "reasonix/internal/provider" "reasonix/internal/store" ) func saveSnapshotTurns(t *testing.T, path string, turns int) *agent.Session { t.Helper() s := agent.NewSession("sys") for i := 0; i < turns; i++ { s.Add(provider.Message{Role: provider.RoleUser, Content: "prompt " + string(rune('a'+i))}) s.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"}) if err := s.SaveSnapshot(path); err != nil { t.Fatalf("SaveSnapshot turn %d: %v", i, err) } } return s } func forkDesktopRecoveryBranch(t *testing.T, dir, name string) (parentPath, branchPath string, branchMsgs []provider.Message) { t.Helper() parentPath = filepath.Join(dir, name+".jsonl") parent := agent.NewSession("sys") parent.Add(provider.Message{Role: provider.RoleUser, Content: "first"}) parent.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"}) parent.Add(provider.Message{Role: provider.RoleUser, Content: "disk " + name}) if err := parent.Save(parentPath); err != nil { t.Fatalf("Save recovery parent: %v", err) } branch := agent.NewSession("sys") branch.Add(provider.Message{Role: provider.RoleUser, Content: "first"}) branch.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"}) branch.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name}) info, err := branch.SaveRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: parentPath}) if err != nil { t.Fatalf("SaveRecoveryBranch: %v", err) } return parentPath, info.Path, branch.Snapshot() } func coverDesktopRecoveryParent(t *testing.T, parentPath string, branchMsgs []provider.Message) { t.Helper() parent := agent.NewSession("") parent.Messages = append([]provider.Message(nil), branchMsgs...) parent.Add(provider.Message{Role: provider.RoleAssistant, Content: "parent kept the recovery content"}) if err := parent.Save(parentPath); err != nil { t.Fatalf("Save covering recovery parent: %v", err) } } func TestMergeSessionInfosCountsRecoveryActivity(t *testing.T) { dir := t.TempDir() parentPath, branchPath, branchMsgs := forkDesktopRecoveryBranch(t, dir, "covered") coverDesktopRecoveryParent(t, parentPath, branchMsgs) summaries := map[string]topicSummary{} now := time.Now() infos := []agent.SessionInfo{ { Path: parentPath, Turns: 3, LastActivityAt: now.Add(-time.Hour), Scope: "global", TopicID: "topic-1", }, { Path: branchPath, Turns: 5, LastActivityAt: now, Scope: "global", TopicID: "topic-1", Recovered: true, }, } mergeSessionInfos(dir, infos, map[string]string{}, map[string]agent.SessionInfo{}, map[string]string{}, summaries) summary := summaries[topicSummaryKey("global", "", "topic-1")] if !summary.hasNormalSession || !summary.hasRecoveryOnly { t.Fatalf("summary flags = %+v, want both normal and recovery seen", summary) } if summary.turns != 3 { t.Fatalf("turns = %d, want 3 (recovery copies must not double-count)", summary.turns) } // The copy is the live transcript after recovery: its newer activity must // drive topic recency, unread state, and time filters. if summary.lastActivityAt != now.UnixMilli() { t.Fatalf("lastActivityAt = %d, want recovery activity %d", summary.lastActivityAt, now.UnixMilli()) } } func TestMergeSessionInfosKeepsContinuedRecoveryVisible(t *testing.T) { dir := t.TempDir() _, branchPath, _ := forkDesktopRecoveryBranch(t, dir, "diverged") summaries := map[string]topicSummary{} now := time.Now() infos := []agent.SessionInfo{{ Path: branchPath, Turns: 5, LastActivityAt: now, Scope: "global", TopicID: "topic-continued", Recovered: true, }} mergeSessionInfos(dir, infos, map[string]string{}, map[string]agent.SessionInfo{}, map[string]string{}, summaries) summary := summaries[topicSummaryKey("global", "", "topic-continued")] if !summary.hasAdoptedRecovery || summary.hasRecoveryOnly { t.Fatalf("summary flags = %+v, want adopted recovery only", summary) } if topicHiddenAsRecoveryOnly(summary, false, nil) { t.Fatal("continued recovery was hidden after its tab closed") } if got := summary.displayTurns(); got != 5 { t.Fatalf("display turns = %d, want 5", got) } } func TestSessionMetaSeparatesRecoveryProvenanceFromCleanupCopy(t *testing.T) { dir := t.TempDir() coveredParent, coveredBranch, coveredMsgs := forkDesktopRecoveryBranch(t, dir, "meta-covered") coverDesktopRecoveryParent(t, coveredParent, coveredMsgs) info := agent.SessionInfo{ Path: coveredBranch, Recovered: true, } meta := sessionMetaFromInfo(info, "", false, false, 0, dir) if !meta.Recovered || !meta.RecoveryCopy { t.Fatalf("covered recovery meta = %+v, want provenance and cleanup-copy flags", meta) } _, divergedBranch, _ := forkDesktopRecoveryBranch(t, dir, "meta-diverged") info.Path = divergedBranch meta = sessionMetaFromInfo(info, "", false, false, 0, dir) if !meta.Recovered || meta.RecoveryCopy { t.Fatalf("diverged recovery meta = %+v, want provenance without cleanup-copy flag", meta) } } func TestRecoveryCopyCleanupRevalidatesInBackend(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } app := NewApp() parentPath, branchPath, branchMsgs := forkDesktopRecoveryBranch(t, dir, "delete-guard") if err := app.DeleteRecoveryCopy(branchPath); err == nil { t.Fatal("DeleteRecoveryCopy accepted a branch with unique content") } if _, err := os.Stat(branchPath); err != nil { t.Fatalf("rejected recovery branch was not preserved: %v", err) } coverDesktopRecoveryParent(t, parentPath, branchMsgs) if err := app.DeleteRecoveryCopy(branchPath); err != nil { t.Fatalf("DeleteRecoveryCopy covered branch: %v", err) } trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(branchPath), filepath.Base(branchPath)) if _, err := os.Stat(trashPath); err != nil { t.Fatalf("covered recovery branch was not moved to trash: %v", err) } purgeParent, purgeBranch, purgeMsgs := forkDesktopRecoveryBranch(t, dir, "purge-guard") if err := app.DeleteSession(purgeBranch); err != nil { t.Fatalf("DeleteSession divergent branch: %v", err) } purgeTrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(purgeBranch), filepath.Base(purgeBranch)) if err := app.PurgeRecoveryCopy(purgeTrashPath); err == nil { t.Fatal("PurgeRecoveryCopy accepted a trashed branch with unique content") } if _, err := os.Stat(purgeTrashPath); err != nil { t.Fatalf("rejected trashed recovery branch was not preserved: %v", err) } coverDesktopRecoveryParent(t, purgeParent, purgeMsgs) parentLease, err := agent.TryAcquireSessionLease(purgeParent) if err != nil { t.Fatalf("TryAcquireSessionLease parent: %v", err) } if err := app.PurgeRecoveryCopy(purgeTrashPath); !errors.Is(err, errSessionBusyElsewhere) { parentLease.Release() t.Fatalf("PurgeRecoveryCopy while parent is live err = %v, want errSessionBusyElsewhere", err) } if _, err := os.Stat(purgeTrashPath); err != nil { parentLease.Release() t.Fatalf("busy-parent purge did not preserve recovery branch: %v", err) } parentLease.Release() if err := app.PurgeRecoveryCopy(purgeTrashPath); err != nil { t.Fatalf("PurgeRecoveryCopy covered branch: %v", err) } if _, err := os.Stat(purgeTrashPath); !os.IsNotExist(err) { t.Fatalf("covered recovery branch survived permanent purge: %v", err) } } func TestTopicHiddenAsRecoveryOnly(t *testing.T) { recoveryOnly := topicSummary{hasRecoveryOnly: true} cases := []struct { name string summary topicSummary pinned bool sessions []runtimeSessionStatus want bool }{ {"recovery-only idle", recoveryOnly, false, nil, true}, {"normal session present", topicSummary{hasRecoveryOnly: true, hasNormalSession: true}, false, nil, false}, {"continued recovery present", topicSummary{hasRecoveryOnly: true, hasAdoptedRecovery: true}, false, nil, false}, {"pinned stays visible", recoveryOnly, true, nil, false}, {"single open runtime", recoveryOnly, false, []runtimeSessionStatus{{open: true}}, false}, // topicRuntimeStatus reports open/running only for single-session // topics; the hide rule must still see a two-session topic as live. {"two runtime sessions one open", recoveryOnly, false, []runtimeSessionStatus{{open: true}, {running: false}}, false}, {"detached running runtime", recoveryOnly, false, []runtimeSessionStatus{{running: true}, {}}, false}, {"idle runtime entries only", recoveryOnly, false, []runtimeSessionStatus{{}, {}}, true}, } for _, c := range cases { if got := topicHiddenAsRecoveryOnly(c.summary, c.pinned, c.sessions); got != c.want { t.Errorf("%s: hidden = %v, want %v", c.name, got, c.want) } } } func TestTrashSessionMatchesLiveSeesEventLogDivergence(t *testing.T) { dir := t.TempDir() live := filepath.Join(dir, "session.jsonl") s := saveSnapshotTurns(t, live, 1) // Simulate an old trash copy taken at checkpoint time: same anchor bytes, // same event log state. trashDir := filepath.Join(dir, "trash") if err := os.MkdirAll(trashDir, 0o755); err != nil { t.Fatal(err) } trashPath := filepath.Join(trashDir, "session.jsonl") for _, pair := range [][2]string{ {live, trashPath}, {store.SessionEventLog(live), store.SessionEventLog(trashPath)}, } { b, err := os.ReadFile(pair[0]) if err != nil { t.Fatal(err) } if err := os.WriteFile(pair[1], b, 0o644); err != nil { t.Fatal(err) } } same, err := trashSessionMatchesLive(live, trashPath) if err != nil { t.Fatalf("trashSessionMatchesLive identical: %v", err) } if !same { t.Fatal("identical live/trash reported as different") } // The live session keeps chatting: growth lands in the event log only, so // the two .jsonl checkpoints stay byte-identical. Byte comparison would // call this a duplicate and delete the live session's newer history. s.Add(provider.Message{Role: provider.RoleUser, Content: "newer work"}) if err := s.SaveSnapshot(live); err != nil { t.Fatalf("SaveSnapshot diverge: %v", err) } liveAnchor, _ := os.ReadFile(live) trashAnchor, _ := os.ReadFile(trashPath) if string(liveAnchor) != string(trashAnchor) { t.Skip("checkpoints diverged on disk; byte-compare trap not reproducible here") } same, err = trashSessionMatchesLive(live, trashPath) if err != nil { t.Fatalf("trashSessionMatchesLive diverged: %v", err) } if same { t.Fatal("live session with newer event log reported as duplicate of trash copy") } } func TestTrashPathsBlockedWhileLeaseHeld(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "session.jsonl") saveSnapshotTurns(t, path, 1) // A live owner (any runtime — this process or another) holds the lease // lock on an open handle for its whole hold. Every destructive path must // refuse while it is held: probing once and deleting later would let the // owner's freshly locked lease file be unlinked out from under it. lease, err := agent.TryAcquireSessionLease(path) if err != nil { t.Fatalf("TryAcquireSessionLease: %v", err) } released := false defer func() { if !released { lease.Release() } }() if err := trashSessionArtifactsBeforeMove(dir, path, "session.jsonl", nil); !errors.Is(err, errSessionBusyElsewhere) { t.Fatalf("trashSessionArtifactsBeforeMove err = %v, want errSessionBusyElsewhere", err) } if err := reconcileDesktopTrashSessionArtifacts(dir, path, "session.jsonl"); !errors.Is(err, errSessionBusyElsewhere) { t.Fatalf("reconcileDesktopTrashSessionArtifacts err = %v, want errSessionBusyElsewhere", err) } if err := removeDesktopSessionArtifacts(path); !errors.Is(err, errSessionBusyElsewhere) { t.Fatalf("removeDesktopSessionArtifacts err = %v, want errSessionBusyElsewhere", err) } if _, err := os.Stat(path); err != nil { t.Fatalf("session file touched despite live owner: %v", err) } if _, err := os.Stat(store.SessionEventLog(path)); err != nil { t.Fatalf("event log touched despite live owner: %v", err) } if _, err := os.Stat(store.SessionLeaseLock(path)); err != nil { t.Fatalf("lease lock deleted while held: %v", err) } // Once the owner releases, the same trash call succeeds and the lock // sidecars are gone with it. lease.Release() released = true if err := trashSessionArtifactsBeforeMove(dir, path, "session.jsonl", nil); err != nil { t.Fatalf("trashSessionArtifactsBeforeMove after release: %v", err) } for _, p := range []string{ path, store.SessionLockFile(path), store.SessionLeaseLock(path), store.SessionLeaseInfo(path), } { if _, err := os.Stat(p); !os.IsNotExist(err) { t.Errorf("artifact survived trash: %s (err=%v)", p, err) } } } func TestPromptHistorySeesEventLogPrompts(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "session.jsonl") s := agent.NewSession("sys") s.Add(provider.Message{Role: provider.RoleUser, Content: "first prompt"}) if err := s.SaveSnapshot(path); err != nil { t.Fatalf("SaveSnapshot: %v", err) } s.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"}) s.Add(provider.Message{Role: provider.RoleUser, Content: "second prompt"}) if err := s.SaveSnapshot(path); err != nil { t.Fatalf("SaveSnapshot append: %v", err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } entries, err := collectPromptHistoryEntries(path, info, func(s string) string { return s }) if err != nil { t.Fatalf("collectPromptHistoryEntries: %v", err) } if len(entries) != 2 { t.Fatalf("prompt history entries = %d, want 2 (event-log prompts must appear)", len(entries)) } if entries[0].Text != "first prompt" || entries[1].Text != "second prompt" { t.Fatalf("prompt history texts = %q, %q", entries[0].Text, entries[1].Text) } if entries[1].At == 0 { t.Fatal("appended prompt lost its timestamp") } } func TestTopicTitleUserTurnsSeesEventLogTurns(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "session.jsonl") saveSnapshotTurns(t, path, 3) users := topicTitleUserTurnsFromSession(path) if len(users) != 3 { t.Fatalf("user turns = %d, want 3 (≥3-turn title upgrade depends on this)", len(users)) } } func TestTopicTitleUserTurnsSkipHostFraming(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "session.jsonl") s := agent.NewSession("sys") // Delivery-mode first turn: user text with the trailing runtime marker. // Built from the exported constant — the preview strip is byte-exact, so a // paraphrased marker would (correctly) not be stripped. s.Add(provider.Message{Role: provider.RoleUser, Content: "你是谁?\n\n" + agent.DeliveryRuntimeMarker}) s.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"}) // Host-injected readiness nudge, persisted as role user. s.Add(provider.Message{Role: provider.RoleUser, Content: "Host final-answer readiness check failed. Before giving a final answer, address the missing host-observable receipts: x"}) s.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"}) s.Add(provider.Message{Role: provider.RoleUser, Content: "帮我写一个魂斗罗游戏"}) if err := s.SaveSnapshot(path); err != nil { t.Fatalf("SaveSnapshot: %v", err) } users := topicTitleUserTurnsFromSession(path) if len(users) != 2 { t.Fatalf("user turns = %d, want 2 (readiness nudge must not count)", len(users)) } if users[0] != "你是谁?" { t.Fatalf("first turn = %q, want the marker stripped", users[0]) } if title := topicTitleFromText(users[0]); strings.Contains(title, "