| 1 | package history |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "reasonix/internal/agent" |
| 12 | "reasonix/internal/provider" |
| 13 | ) |
| 14 | |
| 15 | func TestSearchRanksSavedSessionHistory(t *testing.T) { |
| 16 | sessionDir := t.TempDir() |
| 17 | archiveDir := t.TempDir() |
| 18 | |
| 19 | writeSession(t, filepath.Join(sessionDir, "first.jsonl"), []provider.Message{ |
| 20 | {Role: provider.RoleUser, Content: "We need a cache-first implementation."}, |
| 21 | {Role: provider.RoleAssistant, Content: "Decision: keep the prefix stable and avoid CGO SQLite for Reasonix history retrieval."}, |
| 22 | }) |
| 23 | writeSession(t, filepath.Join(sessionDir, "second.jsonl"), []provider.Message{ |
| 24 | {Role: provider.RoleUser, Content: "Talk about dashboard colors."}, |
| 25 | {Role: provider.RoleAssistant, Content: "No database decision here."}, |
| 26 | }) |
| 27 | |
| 28 | searcher := NewSearcher(Options{SessionDir: sessionDir, ArchiveDir: archiveDir}) |
| 29 | hits, err := searcher.Search(context.Background(), SearchRequest{Query: "SQLite CGO cache", Limit: 5}) |
| 30 | if err != nil { |
| 31 | t.Fatalf("Search() error = %v", err) |
| 32 | } |
| 33 | if len(hits) == 0 { |
| 34 | t.Fatal("Search() returned no hits") |
| 35 | } |
| 36 | if got := filepath.Base(hits[0].SessionPath); got != "first.jsonl" { |
| 37 | t.Fatalf("top hit path = %q, want first.jsonl", got) |
| 38 | } |
| 39 | if hits[0].Kind != KindAssistantText { |
| 40 | t.Fatalf("top hit kind = %q, want %q", hits[0].Kind, KindAssistantText) |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | func TestSearchGlobalIncludesArchives(t *testing.T) { |
| 45 | sessionDir := t.TempDir() |
| 46 | archiveDir := t.TempDir() |
| 47 | writeSession(t, filepath.Join(archiveDir, "archive.jsonl"), []provider.Message{ |
| 48 | {Role: provider.RoleUser, Content: "Old decision: Obelisk retrieval query runtime stays code-driven."}, |
| 49 | }) |
| 50 | |
| 51 | searcher := NewSearcher(Options{SessionDir: sessionDir, ArchiveDir: archiveDir}) |
| 52 | projectHits, err := searcher.Search(context.Background(), SearchRequest{Query: "Obelisk runtime", Scope: "project"}) |
| 53 | if err != nil { |
| 54 | t.Fatalf("project Search() error = %v", err) |
| 55 | } |
| 56 | if len(projectHits) != 0 { |
| 57 | t.Fatalf("project Search() hits = %d, want 0", len(projectHits)) |
| 58 | } |
| 59 | globalHits, err := searcher.Search(context.Background(), SearchRequest{Query: "Obelisk runtime", Scope: "global"}) |
| 60 | if err != nil { |
| 61 | t.Fatalf("global Search() error = %v", err) |
| 62 | } |
| 63 | if len(globalHits) != 1 { |
| 64 | t.Fatalf("global Search() hits = %d, want 1", len(globalHits)) |
| 65 | } |
| 66 | if globalHits[0].Source != "archive" { |
| 67 | t.Fatalf("global hit source = %q, want archive", globalHits[0].Source) |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func TestSearchGlobalIncludesGlobalSessionDir(t *testing.T) { |
| 72 | projectDir := t.TempDir() |
| 73 | globalDir := t.TempDir() |
| 74 | writeSession(t, filepath.Join(projectDir, "project.jsonl"), []provider.Message{ |
| 75 | {Role: provider.RoleUser, Content: "Project-only decision about local UI spacing."}, |
| 76 | }) |
| 77 | globalPath := filepath.Join(globalDir, "global.jsonl") |
| 78 | writeSession(t, globalPath, []provider.Message{ |
| 79 | {Role: provider.RoleUser, Content: "Global-only decision about synthesis cache reuse."}, |
| 80 | }) |
| 81 | |
| 82 | searcher := NewSearcher(Options{SessionDir: projectDir, GlobalSessionDir: globalDir}) |
| 83 | projectHits, err := searcher.Search(context.Background(), SearchRequest{Query: "synthesis cache reuse", Scope: "project"}) |
| 84 | if err != nil { |
| 85 | t.Fatalf("project Search() error = %v", err) |
| 86 | } |
| 87 | if len(projectHits) != 0 { |
| 88 | t.Fatalf("project Search() hits = %d, want 0", len(projectHits)) |
| 89 | } |
| 90 | globalHits, err := searcher.Search(context.Background(), SearchRequest{Query: "synthesis cache reuse", Scope: "global"}) |
| 91 | if err != nil { |
| 92 | t.Fatalf("global Search() error = %v", err) |
| 93 | } |
| 94 | if len(globalHits) != 1 { |
| 95 | t.Fatalf("global Search() hits = %d, want 1", len(globalHits)) |
| 96 | } |
| 97 | if globalHits[0].Source != "global" || globalHits[0].SessionPath != globalPath { |
| 98 | t.Fatalf("global hit = %+v, want source=global path=%s", globalHits[0], globalPath) |
| 99 | } |
| 100 | if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: globalPath, MessageIndex: 0}); err != nil { |
| 101 | t.Fatalf("Around() for global session path failed: %v", err) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func TestSearchIndexesToolInputsAndErrors(t *testing.T) { |
| 106 | sessionDir := t.TempDir() |
| 107 | writeSession(t, filepath.Join(sessionDir, "tools.jsonl"), []provider.Message{ |
| 108 | {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "bash", Arguments: `{"cmd":"go test ./internal/history"}`}}}, |
| 109 | {Role: provider.RoleTool, ToolCallID: "1", Name: "bash", Content: "error: command exited: exit status 1\nFAIL"}, |
| 110 | }) |
| 111 | |
| 112 | searcher := NewSearcher(Options{SessionDir: sessionDir}) |
| 113 | hits, err := searcher.Search(context.Background(), SearchRequest{Query: "go test fail", ToolName: "bash", Limit: 5}) |
| 114 | if err != nil { |
| 115 | t.Fatalf("Search() error = %v", err) |
| 116 | } |
| 117 | if len(hits) < 2 { |
| 118 | t.Fatalf("Search() hits = %d, want at least 2", len(hits)) |
| 119 | } |
| 120 | kinds := map[Kind]bool{} |
| 121 | for _, hit := range hits { |
| 122 | kinds[hit.Kind] = true |
| 123 | } |
| 124 | if !kinds[KindToolInput] || !kinds[KindToolError] { |
| 125 | t.Fatalf("hits kinds = %#v, want tool input and tool error", kinds) |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | func TestSearchDropsCommonWordNoise(t *testing.T) { |
| 130 | sessionDir := t.TempDir() |
| 131 | writeSession(t, filepath.Join(sessionDir, "rare.jsonl"), []provider.Message{ |
| 132 | {Role: provider.RoleUser, Content: "rareterm common common common"}, |
| 133 | }) |
| 134 | for i := 0; i < 12; i++ { |
| 135 | writeSession(t, filepath.Join(sessionDir, "common-"+string(rune('a'+i))+".jsonl"), []provider.Message{ |
| 136 | {Role: provider.RoleUser, Content: "common"}, |
| 137 | }) |
| 138 | } |
| 139 | |
| 140 | searcher := NewSearcher(Options{SessionDir: sessionDir}) |
| 141 | hits, err := searcher.Search(context.Background(), SearchRequest{Query: "rareterm common", Limit: 20}) |
| 142 | if err != nil { |
| 143 | t.Fatalf("Search() error = %v", err) |
| 144 | } |
| 145 | if len(hits) != 1 { |
| 146 | t.Fatalf("Search() hits = %d, want only rare hit: %+v", len(hits), hits) |
| 147 | } |
| 148 | if got := filepath.Base(hits[0].SessionPath); got != "rare.jsonl" { |
| 149 | t.Fatalf("hit path = %q, want rare.jsonl", got) |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | func TestSearchSkipsCleanupPending(t *testing.T) { |
| 154 | sessionDir := t.TempDir() |
| 155 | visiblePath := filepath.Join(sessionDir, "visible.jsonl") |
| 156 | pendingPath := filepath.Join(sessionDir, "pending.jsonl") |
| 157 | writeSession(t, visiblePath, []provider.Message{ |
| 158 | {Role: provider.RoleUser, Content: "ordinary alpha visible decision"}, |
| 159 | }) |
| 160 | writeSession(t, pendingPath, []provider.Message{ |
| 161 | {Role: provider.RoleUser, Content: "hidden alpha cleanup pending secret"}, |
| 162 | }) |
| 163 | if err := agent.MarkCleanupPending(pendingPath, "delete"); err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | |
| 167 | searcher := NewSearcher(Options{SessionDir: sessionDir}) |
| 168 | hits, err := searcher.Search(context.Background(), SearchRequest{Query: "alpha", Limit: 5}) |
| 169 | if err != nil { |
| 170 | t.Fatalf("Search() error = %v", err) |
| 171 | } |
| 172 | if len(hits) != 1 || hits[0].SessionPath != visiblePath { |
| 173 | t.Fatalf("Search() hits = %+v, want only visible path %s", hits, visiblePath) |
| 174 | } |
| 175 | hits, err = searcher.Search(context.Background(), SearchRequest{Query: "cleanup pending secret", Limit: 5}) |
| 176 | if err != nil { |
| 177 | t.Fatalf("Search() pending-only query error = %v", err) |
| 178 | } |
| 179 | if len(hits) != 0 { |
| 180 | t.Fatalf("Search() pending-only hits = %+v, want none", hits) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | func TestAroundRejectsCleanupPending(t *testing.T) { |
| 185 | sessionDir := t.TempDir() |
| 186 | path := filepath.Join(sessionDir, "pending.jsonl") |
| 187 | writeSession(t, path, []provider.Message{{Role: provider.RoleUser, Content: "pending secret"}}) |
| 188 | if err := agent.MarkCleanupPending(path, "delete"); err != nil { |
| 189 | t.Fatal(err) |
| 190 | } |
| 191 | |
| 192 | searcher := NewSearcher(Options{SessionDir: sessionDir}) |
| 193 | if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 0}); err == nil { |
| 194 | t.Fatal("Around() cleanup-pending error = nil, want rejection") |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | func TestHistorySkipsSubagentsOwnedByCleanupPendingParent(t *testing.T) { |
| 199 | sessionDir := t.TempDir() |
| 200 | parentPath := filepath.Join(sessionDir, "parent.jsonl") |
| 201 | writeSession(t, parentPath, []provider.Message{{Role: provider.RoleUser, Content: "parent prompt"}}) |
| 202 | visibleParentPath := filepath.Join(sessionDir, "visible-parent.jsonl") |
| 203 | writeSession(t, visibleParentPath, []provider.Message{{Role: provider.RoleUser, Content: "visible parent prompt"}}) |
| 204 | |
| 205 | pendingSubagentPath := writeSubagentSession(t, sessionDir, "sa_pending", agent.BranchID(parentPath), "subagent hidden orchid result") |
| 206 | visibleSubagentPath := writeSubagentSession(t, sessionDir, "sa_visible", agent.BranchID(visibleParentPath), "subagent visible orchid result") |
| 207 | if err := agent.MarkCleanupPending(parentPath, "delete"); err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | |
| 211 | searcher := NewSearcher(Options{SessionDir: sessionDir}) |
| 212 | hits, err := searcher.Search(context.Background(), SearchRequest{Query: "orchid result", Limit: 5}) |
| 213 | if err != nil { |
| 214 | t.Fatalf("Search() error = %v", err) |
| 215 | } |
| 216 | if len(hits) != 1 || hits[0].SessionPath != visibleSubagentPath { |
| 217 | t.Fatalf("Search() hits = %+v, want only visible subagent %s", hits, visibleSubagentPath) |
| 218 | } |
| 219 | if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: pendingSubagentPath, MessageIndex: 0}); err == nil { |
| 220 | t.Fatal("Around() cleanup-pending parent subagent error = nil, want rejection") |
| 221 | } |
| 222 | if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: visibleSubagentPath, MessageIndex: 0}); err != nil { |
| 223 | t.Fatalf("Around() visible subagent error = %v", err) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | func TestAroundRequiresPathUnderHistoryRoots(t *testing.T) { |
| 228 | sessionDir := t.TempDir() |
| 229 | outside := t.TempDir() |
| 230 | path := filepath.Join(outside, "outside.jsonl") |
| 231 | writeSession(t, path, []provider.Message{{Role: provider.RoleUser, Content: "secret"}}) |
| 232 | |
| 233 | searcher := NewSearcher(Options{SessionDir: sessionDir}) |
| 234 | if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 0}); err == nil { |
| 235 | t.Fatal("Around() error = nil, want path confinement error") |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | func TestAroundRendersNearbyMessages(t *testing.T) { |
| 240 | sessionDir := t.TempDir() |
| 241 | path := filepath.Join(sessionDir, "nearby.jsonl") |
| 242 | writeSession(t, path, []provider.Message{ |
| 243 | {Role: provider.RoleUser, Content: "first"}, |
| 244 | {Role: provider.RoleAssistant, Content: "second"}, |
| 245 | {Role: provider.RoleUser, Content: "third"}, |
| 246 | }) |
| 247 | |
| 248 | searcher := NewSearcher(Options{SessionDir: sessionDir}) |
| 249 | msgs, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 1, Before: 1, After: 1}) |
| 250 | if err != nil { |
| 251 | t.Fatalf("Around() error = %v", err) |
| 252 | } |
| 253 | if len(msgs) != 3 { |
| 254 | t.Fatalf("Around() returned %d messages, want 3", len(msgs)) |
| 255 | } |
| 256 | joined := msgs[0].Text + "\n" + msgs[1].Text + "\n" + msgs[2].Text |
| 257 | for _, want := range []string{"[0 user]", "[1 assistant]", "[2 user]"} { |
| 258 | if !strings.Contains(joined, want) { |
| 259 | t.Fatalf("Around() output missing %q:\n%s", want, joined) |
| 260 | } |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | func TestAroundClampsLargeAfterWithoutOverflow(t *testing.T) { |
| 265 | sessionDir := t.TempDir() |
| 266 | path := filepath.Join(sessionDir, "nearby.jsonl") |
| 267 | writeSession(t, path, []provider.Message{ |
| 268 | {Role: provider.RoleUser, Content: "first"}, |
| 269 | {Role: provider.RoleAssistant, Content: "second"}, |
| 270 | }) |
| 271 | |
| 272 | searcher := NewSearcher(Options{SessionDir: sessionDir}) |
| 273 | msgs, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 0, Before: 0, After: maxAround * 10}) |
| 274 | if err != nil { |
| 275 | t.Fatalf("Around() error = %v", err) |
| 276 | } |
| 277 | if len(msgs) != 2 { |
| 278 | t.Fatalf("Around() returned %d messages, want 2", len(msgs)) |
| 279 | } |
| 280 | if msgs[0].Index != 0 || msgs[1].Index != 1 { |
| 281 | t.Fatalf("Around() message indexes = [%d, %d], want [0, 1]", msgs[0].Index, msgs[1].Index) |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | func writeSession(t *testing.T, path string, msgs []provider.Message) { |
| 286 | t.Helper() |
| 287 | sess := agent.NewSession("") |
| 288 | for _, msg := range msgs { |
| 289 | sess.Add(msg) |
| 290 | } |
| 291 | if err := sess.Save(path); err != nil { |
| 292 | t.Fatalf("Save(%s) error = %v", path, err) |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | func writeSubagentSession(t *testing.T, sessionDir, ref, parentSession, content string) string { |
| 297 | t.Helper() |
| 298 | dir := filepath.Join(sessionDir, "subagents") |
| 299 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 300 | t.Fatal(err) |
| 301 | } |
| 302 | path := filepath.Join(dir, ref+".jsonl") |
| 303 | writeSession(t, path, []provider.Message{{Role: provider.RoleUser, Content: content}}) |
| 304 | meta := agent.SubagentMeta{Ref: ref, ParentSession: parentSession} |
| 305 | b, err := json.Marshal(meta) |
| 306 | if err != nil { |
| 307 | t.Fatal(err) |
| 308 | } |
| 309 | if err := os.WriteFile(filepath.Join(dir, ref+".meta.json"), b, 0o644); err != nil { |
| 310 | t.Fatal(err) |
| 311 | } |
| 312 | return path |
| 313 | } |
| 314 |