package history import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "reasonix/internal/agent" "reasonix/internal/provider" ) func TestSearchRanksSavedSessionHistory(t *testing.T) { sessionDir := t.TempDir() archiveDir := t.TempDir() writeSession(t, filepath.Join(sessionDir, "first.jsonl"), []provider.Message{ {Role: provider.RoleUser, Content: "We need a cache-first implementation."}, {Role: provider.RoleAssistant, Content: "Decision: keep the prefix stable and avoid CGO SQLite for Reasonix history retrieval."}, }) writeSession(t, filepath.Join(sessionDir, "second.jsonl"), []provider.Message{ {Role: provider.RoleUser, Content: "Talk about dashboard colors."}, {Role: provider.RoleAssistant, Content: "No database decision here."}, }) searcher := NewSearcher(Options{SessionDir: sessionDir, ArchiveDir: archiveDir}) hits, err := searcher.Search(context.Background(), SearchRequest{Query: "SQLite CGO cache", Limit: 5}) if err != nil { t.Fatalf("Search() error = %v", err) } if len(hits) == 0 { t.Fatal("Search() returned no hits") } if got := filepath.Base(hits[0].SessionPath); got != "first.jsonl" { t.Fatalf("top hit path = %q, want first.jsonl", got) } if hits[0].Kind != KindAssistantText { t.Fatalf("top hit kind = %q, want %q", hits[0].Kind, KindAssistantText) } } func TestSearchGlobalIncludesArchives(t *testing.T) { sessionDir := t.TempDir() archiveDir := t.TempDir() writeSession(t, filepath.Join(archiveDir, "archive.jsonl"), []provider.Message{ {Role: provider.RoleUser, Content: "Old decision: Obelisk retrieval query runtime stays code-driven."}, }) searcher := NewSearcher(Options{SessionDir: sessionDir, ArchiveDir: archiveDir}) projectHits, err := searcher.Search(context.Background(), SearchRequest{Query: "Obelisk runtime", Scope: "project"}) if err != nil { t.Fatalf("project Search() error = %v", err) } if len(projectHits) != 0 { t.Fatalf("project Search() hits = %d, want 0", len(projectHits)) } globalHits, err := searcher.Search(context.Background(), SearchRequest{Query: "Obelisk runtime", Scope: "global"}) if err != nil { t.Fatalf("global Search() error = %v", err) } if len(globalHits) != 1 { t.Fatalf("global Search() hits = %d, want 1", len(globalHits)) } if globalHits[0].Source != "archive" { t.Fatalf("global hit source = %q, want archive", globalHits[0].Source) } } func TestSearchGlobalIncludesGlobalSessionDir(t *testing.T) { projectDir := t.TempDir() globalDir := t.TempDir() writeSession(t, filepath.Join(projectDir, "project.jsonl"), []provider.Message{ {Role: provider.RoleUser, Content: "Project-only decision about local UI spacing."}, }) globalPath := filepath.Join(globalDir, "global.jsonl") writeSession(t, globalPath, []provider.Message{ {Role: provider.RoleUser, Content: "Global-only decision about synthesis cache reuse."}, }) searcher := NewSearcher(Options{SessionDir: projectDir, GlobalSessionDir: globalDir}) projectHits, err := searcher.Search(context.Background(), SearchRequest{Query: "synthesis cache reuse", Scope: "project"}) if err != nil { t.Fatalf("project Search() error = %v", err) } if len(projectHits) != 0 { t.Fatalf("project Search() hits = %d, want 0", len(projectHits)) } globalHits, err := searcher.Search(context.Background(), SearchRequest{Query: "synthesis cache reuse", Scope: "global"}) if err != nil { t.Fatalf("global Search() error = %v", err) } if len(globalHits) != 1 { t.Fatalf("global Search() hits = %d, want 1", len(globalHits)) } if globalHits[0].Source != "global" || globalHits[0].SessionPath != globalPath { t.Fatalf("global hit = %+v, want source=global path=%s", globalHits[0], globalPath) } if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: globalPath, MessageIndex: 0}); err != nil { t.Fatalf("Around() for global session path failed: %v", err) } } func TestSearchIndexesToolInputsAndErrors(t *testing.T) { sessionDir := t.TempDir() writeSession(t, filepath.Join(sessionDir, "tools.jsonl"), []provider.Message{ {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "bash", Arguments: `{"cmd":"go test ./internal/history"}`}}}, {Role: provider.RoleTool, ToolCallID: "1", Name: "bash", Content: "error: command exited: exit status 1\nFAIL"}, }) searcher := NewSearcher(Options{SessionDir: sessionDir}) hits, err := searcher.Search(context.Background(), SearchRequest{Query: "go test fail", ToolName: "bash", Limit: 5}) if err != nil { t.Fatalf("Search() error = %v", err) } if len(hits) < 2 { t.Fatalf("Search() hits = %d, want at least 2", len(hits)) } kinds := map[Kind]bool{} for _, hit := range hits { kinds[hit.Kind] = true } if !kinds[KindToolInput] || !kinds[KindToolError] { t.Fatalf("hits kinds = %#v, want tool input and tool error", kinds) } } func TestSearchDropsCommonWordNoise(t *testing.T) { sessionDir := t.TempDir() writeSession(t, filepath.Join(sessionDir, "rare.jsonl"), []provider.Message{ {Role: provider.RoleUser, Content: "rareterm common common common"}, }) for i := 0; i < 12; i++ { writeSession(t, filepath.Join(sessionDir, "common-"+string(rune('a'+i))+".jsonl"), []provider.Message{ {Role: provider.RoleUser, Content: "common"}, }) } searcher := NewSearcher(Options{SessionDir: sessionDir}) hits, err := searcher.Search(context.Background(), SearchRequest{Query: "rareterm common", Limit: 20}) if err != nil { t.Fatalf("Search() error = %v", err) } if len(hits) != 1 { t.Fatalf("Search() hits = %d, want only rare hit: %+v", len(hits), hits) } if got := filepath.Base(hits[0].SessionPath); got != "rare.jsonl" { t.Fatalf("hit path = %q, want rare.jsonl", got) } } func TestSearchSkipsCleanupPending(t *testing.T) { sessionDir := t.TempDir() visiblePath := filepath.Join(sessionDir, "visible.jsonl") pendingPath := filepath.Join(sessionDir, "pending.jsonl") writeSession(t, visiblePath, []provider.Message{ {Role: provider.RoleUser, Content: "ordinary alpha visible decision"}, }) writeSession(t, pendingPath, []provider.Message{ {Role: provider.RoleUser, Content: "hidden alpha cleanup pending secret"}, }) if err := agent.MarkCleanupPending(pendingPath, "delete"); err != nil { t.Fatal(err) } searcher := NewSearcher(Options{SessionDir: sessionDir}) hits, err := searcher.Search(context.Background(), SearchRequest{Query: "alpha", Limit: 5}) if err != nil { t.Fatalf("Search() error = %v", err) } if len(hits) != 1 || hits[0].SessionPath != visiblePath { t.Fatalf("Search() hits = %+v, want only visible path %s", hits, visiblePath) } hits, err = searcher.Search(context.Background(), SearchRequest{Query: "cleanup pending secret", Limit: 5}) if err != nil { t.Fatalf("Search() pending-only query error = %v", err) } if len(hits) != 0 { t.Fatalf("Search() pending-only hits = %+v, want none", hits) } } func TestAroundRejectsCleanupPending(t *testing.T) { sessionDir := t.TempDir() path := filepath.Join(sessionDir, "pending.jsonl") writeSession(t, path, []provider.Message{{Role: provider.RoleUser, Content: "pending secret"}}) if err := agent.MarkCleanupPending(path, "delete"); err != nil { t.Fatal(err) } searcher := NewSearcher(Options{SessionDir: sessionDir}) if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 0}); err == nil { t.Fatal("Around() cleanup-pending error = nil, want rejection") } } func TestHistorySkipsSubagentsOwnedByCleanupPendingParent(t *testing.T) { sessionDir := t.TempDir() parentPath := filepath.Join(sessionDir, "parent.jsonl") writeSession(t, parentPath, []provider.Message{{Role: provider.RoleUser, Content: "parent prompt"}}) visibleParentPath := filepath.Join(sessionDir, "visible-parent.jsonl") writeSession(t, visibleParentPath, []provider.Message{{Role: provider.RoleUser, Content: "visible parent prompt"}}) pendingSubagentPath := writeSubagentSession(t, sessionDir, "sa_pending", agent.BranchID(parentPath), "subagent hidden orchid result") visibleSubagentPath := writeSubagentSession(t, sessionDir, "sa_visible", agent.BranchID(visibleParentPath), "subagent visible orchid result") if err := agent.MarkCleanupPending(parentPath, "delete"); err != nil { t.Fatal(err) } searcher := NewSearcher(Options{SessionDir: sessionDir}) hits, err := searcher.Search(context.Background(), SearchRequest{Query: "orchid result", Limit: 5}) if err != nil { t.Fatalf("Search() error = %v", err) } if len(hits) != 1 || hits[0].SessionPath != visibleSubagentPath { t.Fatalf("Search() hits = %+v, want only visible subagent %s", hits, visibleSubagentPath) } if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: pendingSubagentPath, MessageIndex: 0}); err == nil { t.Fatal("Around() cleanup-pending parent subagent error = nil, want rejection") } if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: visibleSubagentPath, MessageIndex: 0}); err != nil { t.Fatalf("Around() visible subagent error = %v", err) } } func TestAroundRequiresPathUnderHistoryRoots(t *testing.T) { sessionDir := t.TempDir() outside := t.TempDir() path := filepath.Join(outside, "outside.jsonl") writeSession(t, path, []provider.Message{{Role: provider.RoleUser, Content: "secret"}}) searcher := NewSearcher(Options{SessionDir: sessionDir}) if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 0}); err == nil { t.Fatal("Around() error = nil, want path confinement error") } } func TestAroundRendersNearbyMessages(t *testing.T) { sessionDir := t.TempDir() path := filepath.Join(sessionDir, "nearby.jsonl") writeSession(t, path, []provider.Message{ {Role: provider.RoleUser, Content: "first"}, {Role: provider.RoleAssistant, Content: "second"}, {Role: provider.RoleUser, Content: "third"}, }) searcher := NewSearcher(Options{SessionDir: sessionDir}) msgs, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 1, Before: 1, After: 1}) if err != nil { t.Fatalf("Around() error = %v", err) } if len(msgs) != 3 { t.Fatalf("Around() returned %d messages, want 3", len(msgs)) } joined := msgs[0].Text + "\n" + msgs[1].Text + "\n" + msgs[2].Text for _, want := range []string{"[0 user]", "[1 assistant]", "[2 user]"} { if !strings.Contains(joined, want) { t.Fatalf("Around() output missing %q:\n%s", want, joined) } } } func TestAroundClampsLargeAfterWithoutOverflow(t *testing.T) { sessionDir := t.TempDir() path := filepath.Join(sessionDir, "nearby.jsonl") writeSession(t, path, []provider.Message{ {Role: provider.RoleUser, Content: "first"}, {Role: provider.RoleAssistant, Content: "second"}, }) searcher := NewSearcher(Options{SessionDir: sessionDir}) msgs, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 0, Before: 0, After: maxAround * 10}) if err != nil { t.Fatalf("Around() error = %v", err) } if len(msgs) != 2 { t.Fatalf("Around() returned %d messages, want 2", len(msgs)) } if msgs[0].Index != 0 || msgs[1].Index != 1 { t.Fatalf("Around() message indexes = [%d, %d], want [0, 1]", msgs[0].Index, msgs[1].Index) } } func writeSession(t *testing.T, path string, msgs []provider.Message) { t.Helper() sess := agent.NewSession("") for _, msg := range msgs { sess.Add(msg) } if err := sess.Save(path); err != nil { t.Fatalf("Save(%s) error = %v", path, err) } } func writeSubagentSession(t *testing.T, sessionDir, ref, parentSession, content string) string { t.Helper() dir := filepath.Join(sessionDir, "subagents") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } path := filepath.Join(dir, ref+".jsonl") writeSession(t, path, []provider.Message{{Role: provider.RoleUser, Content: content}}) meta := agent.SubagentMeta{Ref: ref, ParentSession: parentSession} b, err := json.Marshal(meta) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, ref+".meta.json"), b, 0o644); err != nil { t.Fatal(err) } return path }