返回 DeepSeek-Reasonix
history_snapshot_test.go
根目录 / desktop / history_snapshot_test.go
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
338 lines GO