返回 DeepSeek-Reasonix
session_history_native_search_test.go
根目录 / desktop / session_history_native_search_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "testing"
10
11 "reasonix/internal/config"
12 "reasonix/internal/provider"
13 "reasonix/internal/session"
14 "reasonix/internal/store"
15 )
16
17 func awaitNativeSearch(t *testing.T, a *App, id, text, cursor string, limit int) session.SearchHistoryPage {
18 t.Helper()
19 page, err := a.SearchSessionHistoryRead(id, text, cursor, limit)
20 if err != nil {
21 t.Fatal(err)
22 }
23 if page.Status == "preparing" {
24 r, readErr := a.historyReader(id)
25 if readErr != nil {
26 t.Fatal(readErr)
27 }
28 search := a.prepareNativeHistorySearch(r.native)
29 select {
30 case <-search.done:
31 case <-t.Context().Done():
32 t.Fatal("search did not settle")
33 }
34 page, err = a.SearchSessionHistoryRead(id, text, cursor, limit)
35 }
36 if err != nil || page.Status != "ready" {
37 t.Fatalf("search: %+v %v", page, err)
38 }
39 return page
40 }
41
42 func TestNativeHistorySearchColdFormatsAndReuse(t *testing.T) {
43 for _, kind := range []string{"checkpoint", "schema1"} {
44 t.Run(kind, func(t *testing.T) {
45 a := historySliceTestApp(t)
46 t.Cleanup(a.closeHistoryReaders)
47 tab := newColdHistoryTab(t, a)
48 messages := []provider.Message{historySliceUser(0, "needle 问题"), historySliceAssistant(0, "second needle 🧭"), historySliceUser(1, "third needle"), historySliceAssistant(1, "literal quote \"needle\"")}
49 dir := tabSessionDir(tab)
50 if err := os.MkdirAll(dir, 0700); err != nil {
51 t.Fatal(err)
52 }
53 tab.SessionPath = filepath.Join(dir, kind+".jsonl")
54 var checkpoint []byte
55 for _, message := range messages {
56 body, _ := json.Marshal(message)
57 checkpoint = append(checkpoint, append(body, '\n')...)
58 }
59 var event []byte
60 if kind == "schema1" {
61 checkpoint = []byte("{\"role\":\"user\",\"content\":\"obsolete needle\"}\n")
62 event, _ = json.Marshal(map[string]any{"schema_version": 1, "type": "replace", "messages": messages})
63 if err := os.WriteFile(store.SessionEventLog(tab.SessionPath), event, 0600); err != nil {
64 t.Fatal(err)
65 }
66 }
67 if err := os.WriteFile(tab.SessionPath, checkpoint, 0600); err != nil {
68 t.Fatal(err)
69 }
70 handle, err := a.BeginSessionHistoryReadForTab(tab.ID)
71 if err != nil {
72 t.Fatal(err)
73 }
74 r, _ := a.historyReader(handle.ID)
75 if _, err := nativeNavigationPager(r); err != nil {
76 t.Fatal(err)
77 }
78 if r.native.search != nil {
79 t.Fatal("ordinary history read prepared full text")
80 }
81 first := awaitNativeSearch(t, a, handle.ID, "needle", "", 2)
82 if len(first.Hits) != 2 || !first.HasMore || first.NextCursor == "" || first.Hits[0].Position != 3 {
83 t.Fatalf("first: %+v", first)
84 }
85 last := awaitNativeSearch(t, a, handle.ID, "needle", first.NextCursor, 2)
86 if len(last.Hits) != 2 || last.HasMore || last.Hits[1].Position != 0 {
87 t.Fatalf("last: %+v", last)
88 }
89 for _, hit := range append(first.Hits, last.Hits...) {
90 location, err := a.LocateSessionHistoryMessage(handle.ID, hit.MessageID, first.SnapshotSequence)
91 if err != nil || location.Status != "ready" || location.Position != hit.Position {
92 t.Fatalf("search hit not reachable: %+v %v", location, err)
93 }
94 }
95 for _, text := range []string{"问题", "🧭", `"needle"`} {
96 if got := awaitNativeSearch(t, a, handle.ID, text, "", 10); len(got.Hits) != 1 {
97 t.Fatalf("Unicode/literal query %q: %+v", text, got)
98 }
99 }
100 if got := awaitNativeSearch(t, a, handle.ID, "obsolete", "", 10); len(got.Hits) != 0 {
101 t.Fatal("indexed an obsolete checkpoint")
102 }
103 for _, cursor := range []string{"garbage", first.NextCursor} {
104 page, err := a.SearchSessionHistoryRead(handle.ID, "different query", cursor, 2)
105 if err != nil || page.Status != "stale_cursor" || len(page.Hits) != 0 {
106 t.Fatalf("accepted foreign cursor: %+v %v", page, err)
107 }
108 }
109 cache := filepath.Join(config.CacheDir(), "history-search-v1", r.native.cacheKey+".sqlite")
110 before, err := os.Stat(cache)
111 if err != nil {
112 t.Fatal(err)
113 }
114 a.ReleaseSessionHistoryRead(handle.ID)
115 <-r.native.closed
116 if err := r.native.search.db.Ping(); err == nil {
117 t.Fatal("retired search retained its database")
118 }
119 handle, err = a.BeginSessionHistoryReadForTab(tab.ID)
120 if err != nil {
121 t.Fatal(err)
122 }
123 awaitNativeSearch(t, a, handle.ID, "needle", "", 2)
124 after, err := os.Stat(cache)
125 if err != nil || !os.SameFile(before, after) {
126 t.Fatalf("reopen rebuilt a trusted search cache: %v", err)
127 }
128 body, _ := os.ReadFile(tab.SessionPath)
129 log, _ := os.ReadFile(store.SessionEventLog(tab.SessionPath))
130 if string(body) != string(checkpoint) || string(log) != string(event) || tab.Ctrl != nil {
131 t.Fatal("cold search changed authoritative history or created a runtime")
132 }
133 if err := os.WriteFile(tab.SessionPath, []byte("{\"role\":\"user\",\"content\":\"replacement\"}\n"), 0600); err != nil {
134 t.Fatal(err)
135 }
136 stale, err := a.SearchSessionHistoryRead(handle.ID, "needle", "", 2)
137 if err != nil || stale.Status != "stale_cursor" || len(stale.Hits) != 0 {
138 t.Fatalf("replacement published stale hits: %+v %v", stale, err)
139 }
140 })
141 }
142 }
143
144 func TestNativeHistorySearchSharesCancellationAndCloseBarrier(t *testing.T) {
145 a := historySliceTestApp(t)
146 t.Cleanup(a.closeHistoryReaders)
147 tab := newColdHistoryTab(t, a)
148 _, tab.SessionPath = saveHistorySliceSession(t, tabSessionDir(tab), "shared-search.jsonl", []provider.Message{historySliceUser(0, "needle")})
149 one, err := a.BeginSessionHistoryReadForTab(tab.ID)
150 if err != nil {
151 t.Fatal(err)
152 }
153 two, err := a.BeginSessionHistoryReadForTab(tab.ID)
154 if err != nil {
155 t.Fatal(err)
156 }
157 r, _ := a.historyReader(two.ID)
158 if _, err := nativeNavigationPager(r); err != nil {
159 t.Fatal(err)
160 }
161 resume, err := a.historyMaintenance.Foreground(t.Context())
162 if err != nil {
163 t.Fatal(err)
164 }
165 defer resume()
166 page, err := a.SearchSessionHistoryRead(one.ID, "needle", "", 1)
167 if err != nil || page.Status != "preparing" || page.CoverageSequence != 0 || len(page.Hits) != 0 {
168 t.Fatalf("unfinished search claimed complete results: %+v %v", page, err)
169 }
170 first := a.prepareNativeHistorySearch(r.native)
171 a.ReleaseSessionHistoryRead(one.ID)
172 if a.prepareNativeHistorySearch(r.native) != first || r.native.ctx.Err() != nil {
173 t.Fatal("one reader canceled or replaced its peer's preparation")
174 }
175 a.ReleaseSessionHistoryRead(two.ID)
176 // Search is still held behind the admission gate: retirement must cancel
177 // that wait and join it without needing to release an unrelated permit.
178 select {
179 case <-r.native.closed:
180 case <-t.Context().Done():
181 t.Fatal("retirement did not cancel the admitted search")
182 }
183 if !errors.Is(first.err, context.Canceled) || first.db != nil {
184 t.Fatalf("canceled preparation published a database: %v", first.err)
185 }
186 }
187
187 lines GO