| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "reasonix/internal/agent" |
| 13 | "reasonix/internal/config" |
| 14 | "reasonix/internal/history" |
| 15 | "reasonix/internal/historycatalog" |
| 16 | "reasonix/internal/provider" |
| 17 | "reasonix/internal/sessioncatalog" |
| 18 | ) |
| 19 | |
| 20 | func TestLegacyHistorySearchSnapshotSurvivesUnrelatedIndexWrites(t *testing.T) { |
| 21 | isolateDesktopUserDirs(t) |
| 22 | dir := config.SessionDir() |
| 23 | if err := os.MkdirAll(dir, 0700); err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | path := filepath.Join(dir, "search.jsonl") |
| 27 | source := agent.NewSession("") |
| 28 | for i := range 405 { |
| 29 | source.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("snapshot marker %d", i)}) |
| 30 | } |
| 31 | if err := source.SaveSnapshot(path); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | app := NewApp() |
| 35 | app.ctx = t.Context() |
| 36 | t.Cleanup(app.closeSessionServices) |
| 37 | installSessionCatalogForTest(t, app, dir, "global", "") |
| 38 | if err := history.RebuildSharedCatalog(t.Context(), []historycatalog.Root{{Path: dir, Scope: "global", Source: "global"}}); err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | t.Cleanup(func() { _ = history.CloseSharedCatalog(context.Background()) }) |
| 42 | deadline := time.After(5 * time.Second) |
| 43 | for history.SharedCatalog() == nil { |
| 44 | select { |
| 45 | case <-deadline: |
| 46 | t.Fatal("history catalog did not open") |
| 47 | case <-time.After(10 * time.Millisecond): |
| 48 | } |
| 49 | } |
| 50 | if err := history.SharedCatalog().ReconcileRoot(t.Context(), historycatalog.Root{Path: dir, Scope: "global", Source: "global"}); err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | req := HistorySearchRequest{Query: "snapshot marker", Scope: "global", Limit: 200} |
| 54 | first := app.SearchHistoryContent(req) |
| 55 | if first.ReadError != nil || len(first.Items) != 200 || first.NextCursor == "" { |
| 56 | t.Fatalf("first=%+v", first) |
| 57 | } |
| 58 | targetFirst, err := app.SearchHistoryContentForTarget(SessionSelector{SessionPath: path}, "snapshot marker", "", 200) |
| 59 | if err != nil || len(targetFirst.Items) != 200 { |
| 60 | t.Fatalf("target first=%+v err=%v", targetFirst, err) |
| 61 | } |
| 62 | other := t.TempDir() |
| 63 | otherPath := filepath.Join(other, "other.jsonl") |
| 64 | unrelated := agent.NewSession("") |
| 65 | unrelated.Add(provider.Message{Role: provider.RoleUser, Content: "snapshot snapshot marker marker"}) |
| 66 | if err := unrelated.SaveSnapshot(otherPath); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | if err := history.SharedCatalog().ReconcileRoot(t.Context(), historycatalog.Root{Path: other, Scope: "project", WorkspaceRoot: other}); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | seen := map[int]bool{} |
| 73 | for _, hit := range first.Items { |
| 74 | seen[hit.MessageIndex] = true |
| 75 | } |
| 76 | req.Cursor = first.NextCursor |
| 77 | for req.Cursor != "" { |
| 78 | page := app.SearchHistoryContent(req) |
| 79 | if page.ReadError != nil || page.StaleCursor || page.SnapshotID != first.SnapshotID { |
| 80 | t.Fatalf("continuation=%+v", page) |
| 81 | } |
| 82 | for _, hit := range page.Items { |
| 83 | if seen[hit.MessageIndex] { |
| 84 | t.Fatalf("duplicate %d", hit.MessageIndex) |
| 85 | } |
| 86 | seen[hit.MessageIndex] = true |
| 87 | } |
| 88 | req.Cursor = page.NextCursor |
| 89 | } |
| 90 | if len(seen) != 405 { |
| 91 | t.Fatalf("hits=%d", len(seen)) |
| 92 | } |
| 93 | targetNext, err := app.SearchHistoryContentForTarget(SessionSelector{SessionPath: path}, "snapshot marker", targetFirst.NextCursor, 200) |
| 94 | if err != nil || len(targetNext.Items) != 200 { |
| 95 | t.Fatalf("target next=%+v err=%v", targetNext, err) |
| 96 | } |
| 97 | req.Cursor, req.Query = first.NextCursor, "other query" |
| 98 | if page := app.SearchHistoryContent(req); !page.StaleCursor { |
| 99 | t.Fatal("cross-query cursor accepted") |
| 100 | } |
| 101 | app.tabs["switch"] = &WorkspaceTab{ID: "switch", Scope: "global", SessionPath: path} |
| 102 | app.activeTabID = "switch" |
| 103 | if app.activeSessionPath(app.activeSessionDir()) != path { |
| 104 | t.Fatal("fixture did not activate the requested session") |
| 105 | } |
| 106 | req.Query = "snapshot marker" |
| 107 | if page := app.SearchHistoryContent(req); !page.StaleCursor { |
| 108 | t.Fatal("search cursor crossed an active-session switch") |
| 109 | } |
| 110 | req.Cursor, req.Status = "", "current" |
| 111 | current := app.SearchHistoryContent(req) |
| 112 | if current.ReadError != nil || len(current.Items) != 200 || !current.Items[0].Current { |
| 113 | t.Fatalf("current search after switch=%+v", current) |
| 114 | } |
| 115 | if page, err := app.SearchHistoryContentForTarget(SessionSelector{SessionPath: path}, "snapshot marker", targetFirst.NextCursor, 200); err != nil || page.StaleCursor || len(page.Items) != 200 { |
| 116 | t.Fatalf("exact-target search must survive unrelated focus changes: page=%+v err=%v", page, err) |
| 117 | } |
| 118 | app.activeTabID = "" |
| 119 | req.Cursor, req.Status = first.NextCursor, "" |
| 120 | if err := os.Remove(path); err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | req.Query = "snapshot marker" |
| 124 | if page := app.SearchHistoryContent(req); !page.StaleCursor { |
| 125 | t.Fatal("deleted source stayed readable") |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | func TestHistorySessionSnapshotKeepsOrderAndFencesRemoval(t *testing.T) { |
| 130 | isolateDesktopUserDirs(t) |
| 131 | dir := config.SessionDir() |
| 132 | if err := os.MkdirAll(dir, 0700); err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | app := NewApp() |
| 136 | app.ctx = t.Context() |
| 137 | t.Cleanup(app.closeSessionServices) |
| 138 | installSessionCatalogForTest(t, app, dir, "global", "") |
| 139 | catalog := app.sessionCatalog.Load() |
| 140 | for i := range 405 { |
| 141 | if err := catalog.UpsertSession(t.Context(), sessioncatalog.SessionRecord{Path: filepath.Join(dir, fmt.Sprintf("%03d.jsonl", i)), Directory: dir, Scope: "global", TopicID: fmt.Sprint(i), LastActivityAt: int64(i), Health: sessioncatalog.HealthOK, TurnsState: sessioncatalog.TurnsValid}); err != nil { |
| 142 | t.Fatal(err) |
| 143 | } |
| 144 | } |
| 145 | req := HistorySessionPageRequest{Scope: "global", Limit: 200} |
| 146 | first := app.ListHistorySessions(req) |
| 147 | if first.ReadError != nil || len(first.Items) != 200 || first.NextCursor == "" { |
| 148 | t.Fatalf("first=%+v", first) |
| 149 | } |
| 150 | changed, ok, err := catalog.GetSession(t.Context(), filepath.Join(dir, "000.jsonl")) |
| 151 | if err != nil || !ok { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | changed.LastActivityAt, changed.CustomTitle = 999, "Updated" |
| 155 | if err := catalog.UpsertSession(t.Context(), changed); err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | seen := map[string]bool{} |
| 159 | for _, row := range first.Items { |
| 160 | seen[row.Path] = true |
| 161 | } |
| 162 | req.Cursor = first.NextCursor |
| 163 | for req.Cursor != "" { |
| 164 | page := app.ListHistorySessions(req) |
| 165 | if page.ReadError != nil || page.StaleCursor { |
| 166 | t.Fatalf("page=%+v", page) |
| 167 | } |
| 168 | for _, row := range page.Items { |
| 169 | if seen[row.Path] || row.Title == "Updated" { |
| 170 | t.Fatalf("live order leaked: %+v", row) |
| 171 | } |
| 172 | seen[row.Path] = true |
| 173 | } |
| 174 | req.Cursor = page.NextCursor |
| 175 | } |
| 176 | if len(seen) != 405 { |
| 177 | t.Fatalf("rows=%d", len(seen)) |
| 178 | } |
| 179 | for _, name := range []string{"000", "001", "000"} { |
| 180 | path := filepath.Join(dir, name+".jsonl") |
| 181 | app.tabs[name] = &WorkspaceTab{ID: name, Scope: "global", SessionPath: path, Ctrl: &snapshotSwitchController{activationStubController: activationStubController{sessionPath: path}, dir: dir}} |
| 182 | app.activeTabID = name |
| 183 | if app.activeSessionPath(app.activeSessionDir()) != path { |
| 184 | t.Fatal("fixture did not activate the requested session") |
| 185 | } |
| 186 | if page := app.ListHistorySessions(HistorySessionPageRequest{Scope: "global", Cursor: first.NextCursor, Limit: 200}); !page.StaleCursor { |
| 187 | t.Fatal("metadata cursor crossed an active-session switch") |
| 188 | } |
| 189 | current := app.ListHistorySessions(HistorySessionPageRequest{Scope: "global", Status: "current", Limit: 200}) |
| 190 | if current.ReadError != nil || len(current.Items) != 1 || current.Items[0].Path != path || !current.Items[0].Current { |
| 191 | t.Fatalf("current metadata after A-B-A switch=%+v", current) |
| 192 | } |
| 193 | } |
| 194 | app.activeTabID = "" |
| 195 | fresh := app.ListHistorySessions(req) |
| 196 | if len(fresh.Items) == 0 || fresh.Items[0].Title != "Updated" { |
| 197 | t.Fatalf("refresh=%+v", fresh) |
| 198 | } |
| 199 | if err := catalog.RemoveSession(t.Context(), changed.Path, "test"); err != nil { |
| 200 | t.Fatal(err) |
| 201 | } |
| 202 | req.Cursor = first.NextCursor |
| 203 | if page := app.ListHistorySessions(req); !page.StaleCursor { |
| 204 | t.Fatal("removed source was not fenced") |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | type snapshotSwitchController struct { |
| 209 | activationStubController |
| 210 | dir string |
| 211 | } |
| 212 | |
| 213 | func (c *snapshotSwitchController) SessionDir() string { return c.dir } |
| 214 | |
| 215 | func TestHistoryContentSearchDefaultsCoverEveryToolResultOnce(t *testing.T) { |
| 216 | isolateDesktopUserDirs(t) |
| 217 | dir := config.SessionDir() |
| 218 | if err := os.MkdirAll(dir, 0700); err != nil { |
| 219 | t.Fatal(err) |
| 220 | } |
| 221 | path := filepath.Join(dir, "tools.jsonl") |
| 222 | source := agent.NewSession("") |
| 223 | source.Add(provider.Message{Role: provider.RoleUser, Content: "list the servers"}) |
| 224 | source.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{ |
| 225 | {ID: "ok", Name: "mcp_list", Arguments: "{}"}, |
| 226 | {ID: "bad", Name: "mcp_list", Arguments: "{}"}, |
| 227 | }}) |
| 228 | source.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "ok", Name: "mcp_list", Content: "servers: alpha zulumarker"}) |
| 229 | source.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "bad", Name: "mcp_list", Content: "error: quebecmarker unreachable"}) |
| 230 | if err := source.SaveSnapshot(path); err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | app := NewApp() |
| 234 | app.ctx = t.Context() |
| 235 | t.Cleanup(app.closeSessionServices) |
| 236 | installSessionCatalogForTest(t, app, dir, "global", "") |
| 237 | root := historycatalog.Root{Path: dir, Scope: "global", Source: "global"} |
| 238 | if err := history.RebuildSharedCatalog(t.Context(), []historycatalog.Root{root}); err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | t.Cleanup(func() { _ = history.CloseSharedCatalog(context.Background()) }) |
| 242 | deadline := time.After(5 * time.Second) |
| 243 | for history.SharedCatalog() == nil { |
| 244 | select { |
| 245 | case <-deadline: |
| 246 | t.Fatal("history catalog did not open") |
| 247 | case <-time.After(10 * time.Millisecond): |
| 248 | } |
| 249 | } |
| 250 | if err := history.SharedCatalog().ReconcileRoot(t.Context(), root); err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | for _, query := range []string{"zulumarker", "quebecmarker"} { |
| 254 | page := app.SearchHistoryContent(HistorySearchRequest{Query: query, Scope: "global", Limit: 50}) |
| 255 | if page.ReadError != nil || len(page.Items) != 1 || page.Items[0].Kind != "tool_output" { |
| 256 | t.Fatalf("query %q: want exactly one tool_output hit, got %+v", query, page) |
| 257 | } |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | func TestHistorySearchBuildReadsEachSessionOnceAndKeepsRankOrder(t *testing.T) { |
| 262 | isolateDesktopUserDirs(t) |
| 263 | dir := config.SessionDir() |
| 264 | if err := os.MkdirAll(dir, 0700); err != nil { |
| 265 | t.Fatal(err) |
| 266 | } |
| 267 | digests := map[string]string{} |
| 268 | paths := []string{filepath.Join(dir, "a.jsonl"), filepath.Join(dir, "b.jsonl")} |
| 269 | for _, path := range paths { |
| 270 | source := agent.NewSession("") |
| 271 | source.Add(provider.Message{Role: provider.RoleUser, Content: "first interleave"}) |
| 272 | source.Add(provider.Message{Role: provider.RoleUser, Content: "second interleave"}) |
| 273 | if err := source.SaveSnapshot(path); err != nil { |
| 274 | t.Fatal(err) |
| 275 | } |
| 276 | _, state, _, err := agent.LoadSessionDisplayMessages(path) |
| 277 | if err != nil { |
| 278 | t.Fatal(err) |
| 279 | } |
| 280 | digests[path] = state.DigestHex |
| 281 | } |
| 282 | loads := 0 |
| 283 | original := loadHistorySearchMessages |
| 284 | loadHistorySearchMessages = func(path string) ([]provider.Message, agent.PersistedState, bool, error) { |
| 285 | loads++ |
| 286 | return original(path) |
| 287 | } |
| 288 | t.Cleanup(func() { loadHistorySearchMessages = original }) |
| 289 | app := NewApp() |
| 290 | app.ctx = t.Context() |
| 291 | t.Cleanup(app.closeSessionServices) |
| 292 | store := &app.desktopSessions.readSnapshots |
| 293 | candidates, out := &readSnapshot{}, &readSnapshot{} |
| 294 | t.Cleanup(func() { store.dispose(candidates); store.dispose(out) }) |
| 295 | ranked := []historycatalog.Candidate{ |
| 296 | {SessionPath: paths[0], MessageIndex: 0, Kind: "user_text"}, |
| 297 | {SessionPath: paths[1], MessageIndex: 1, Kind: "user_text"}, |
| 298 | {SessionPath: paths[0], MessageIndex: 1, Kind: "user_text"}, |
| 299 | {SessionPath: paths[1], MessageIndex: 0, Kind: "user_text"}, |
| 300 | } |
| 301 | for _, row := range ranked { |
| 302 | row.ContentDigest = digests[row.SessionPath] |
| 303 | if err := store.append(t.Context(), candidates, row); err != nil { |
| 304 | t.Fatal(err) |
| 305 | } |
| 306 | } |
| 307 | fence, err := app.newReadSourceFence(store, out) |
| 308 | if err != nil { |
| 309 | t.Fatal(err) |
| 310 | } |
| 311 | build := &historySearchSnapshotBuild{ctx: t.Context(), app: app, store: store, snapshot: out, request: HistorySearchRequest{Query: "interleave"}, fence: fence, terms: []string{"interleave"}, cutoff: time.Now()} |
| 312 | if err := build.run(candidates); err != nil { |
| 313 | t.Fatal(err) |
| 314 | } |
| 315 | var got []HistorySearchHit |
| 316 | if err := out.walk(t.Context(), func(b []byte) error { |
| 317 | var hit HistorySearchHit |
| 318 | if err := json.Unmarshal(b, &hit); err != nil { |
| 319 | return err |
| 320 | } |
| 321 | got = append(got, hit) |
| 322 | return nil |
| 323 | }); err != nil { |
| 324 | t.Fatal(err) |
| 325 | } |
| 326 | if loads != len(paths) { |
| 327 | t.Fatalf("session loads = %d, want %d (one per session)", loads, len(paths)) |
| 328 | } |
| 329 | if len(got) != len(ranked) { |
| 330 | t.Fatalf("hits = %+v", got) |
| 331 | } |
| 332 | for i, hit := range got { |
| 333 | if hit.SessionPath != ranked[i].SessionPath || hit.MessageIndex != ranked[i].MessageIndex || hit.Snippet == "" { |
| 334 | t.Fatalf("hit %d = %+v, want rank order %+v", i, hit, ranked[i]) |
| 335 | } |
| 336 | } |
| 337 | } |
| 338 |