返回 DeepSeek-Reasonix
session_history_binding_test.go
根目录 / desktop / session_history_binding_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "testing"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/config"
12 "reasonix/internal/provider"
13 "reasonix/internal/session"
14 "reasonix/internal/store"
15 )
16
17 func TestNativeHistoryCacheFailureDoesNotFallBackToFullReplay(t *testing.T) {
18 a := historySliceTestApp(t)
19 t.Cleanup(a.closeHistoryReaders)
20 tab := newColdHistoryTab(t, a)
21 _, tab.SessionPath = saveHistorySliceSession(t, tabSessionDir(tab), "cache-error.jsonl", []provider.Message{historySliceUser(0, "original history")})
22 // The fixture writer creates a sidecar; exercise a cold source without it.
23 if err := os.Remove(store.SessionDisplayIndex(tab.SessionPath)); err != nil && !os.IsNotExist(err) {
24 t.Fatal(err)
25 }
26 before, err := os.ReadFile(tab.SessionPath)
27 if err != nil {
28 t.Fatal(err)
29 }
30 cache := filepath.Join(t.TempDir(), "not-a-directory")
31 if err := os.WriteFile(cache, []byte("cache unavailable"), 0600); err != nil {
32 t.Fatal(err)
33 }
34 t.Setenv("REASONIX_CACHE_HOME", cache)
35 request := normalizeHistorySliceRequest(HistorySliceRequest{Entries: 1, Turns: 1})
36 if _, err := a.coldHistorySlice(tabSessionDir(tab), tab.SessionPath, request); err == nil {
37 t.Fatal("cache failure silently fell back to full compatibility replay")
38 }
39 if _, found, stale := a.coldHistoryFieldValue(tabSessionDir(tab), tab.SessionPath, 0, 0, HistoryContentRef{Field: "content"}); found || !stale {
40 t.Fatal("cache failure silently fell back to a compatibility content read")
41 }
42 after, err := os.ReadFile(tab.SessionPath)
43 if err != nil || string(after) != string(before) {
44 t.Fatalf("read failure changed authoritative history: %v", err)
45 }
46 if _, err := os.Stat(store.SessionDisplayIndex(tab.SessionPath)); !os.IsNotExist(err) {
47 t.Fatalf("read failure wrote a compatibility sidecar: %v", err)
48 }
49 }
50
51 func TestNativeHistoryReadersSharePreparationUntilLastRelease(t *testing.T) {
52 a := historySliceTestApp(t)
53 t.Cleanup(a.closeHistoryReaders)
54 tab := newColdHistoryTab(t, a)
55 _, tab.SessionPath = saveHistorySliceSession(t, tabSessionDir(tab), "shared.jsonl", []provider.Message{historySliceUser(0, "one"), historySliceAssistant(0, "answer")})
56 // Hold admission so both readers deterministically join an unfinished job.
57 resume, err := a.historyMaintenance.Foreground(t.Context())
58 if err != nil {
59 t.Fatal(err)
60 }
61 defer resume()
62 one, err := a.BeginSessionHistoryReadForTab(tab.ID)
63 if err != nil {
64 t.Fatal(err)
65 }
66 two, err := a.BeginSessionHistoryReadForTab(tab.ID)
67 if err != nil {
68 t.Fatal(err)
69 }
70 first, _ := a.historyReader(one.ID)
71 second, _ := a.historyReader(two.ID)
72 if first.native == nil || first.native != second.native {
73 t.Fatal("readers started duplicate preparations")
74 }
75 a.ReleaseSessionHistoryRead(one.ID)
76 if second.native.ctx.Err() != nil {
77 t.Fatal("one release canceled shared preparation")
78 }
79 resume()
80 page, err := a.ReadSessionHistorySlice(two.ID, HistorySliceRequest{Turns: 1, Entries: 2})
81 if err != nil || page.Status != "ready" {
82 t.Fatalf("remaining reader: %+v %v", page, err)
83 }
84 a.ReleaseSessionHistoryRead(two.ID)
85 if second.native.ctx.Err() != context.Canceled {
86 t.Fatal("last reader did not cancel job")
87 }
88 a.historyReaders.workers.Wait()
89 if err := second.native.pager.DB.Ping(); err == nil {
90 t.Fatal("last release retained SQLite handle")
91 }
92 }
93
94 func TestHistoricalDirectoryColdReaderDoesNotStartRuntimeOrImport(t *testing.T) {
95 isolateDesktopUserDirs(t)
96 root := config.SessionStoreDir()
97 const id = "cold-original-store"
98 coldV4MigrationFixture(t, root, id)
99 path := filepath.Join(root, id)
100 before, err := desktopSourceFingerprint(path)
101 if err != nil {
102 t.Fatal(err)
103 }
104 a := NewApp()
105 a.ctx = t.Context()
106 t.Cleanup(a.closeSessionServices)
107 a.tabs["cold"] = &WorkspaceTab{ID: "cold", SessionPath: path, SessionGeneration: 1}
108 handle, err := a.BeginSessionHistoryReadForTab("cold")
109 if err != nil {
110 t.Fatal(err)
111 }
112 defer a.ReleaseSessionHistoryRead(handle.ID)
113 reader, err := a.historyReader(handle.ID)
114 if err != nil {
115 t.Fatal(err)
116 }
117 // TitleMessages waits on the same locator preparation, giving this test a
118 // deterministic completion barrier without polling a timer.
119 if _, err := reader.query.TitleMessages(reader.ctx, reader.ref, 1); err != nil {
120 t.Fatal(err)
121 }
122 page, err := a.ReadSessionHistoryWindow(handle.ID, session.HistoryWindowRequest{Anchor: "newest", Limit: 10})
123 if err != nil || page.Status != "ready" || len(page.Messages) == 0 {
124 t.Fatalf("cold page: %+v %v", page, err)
125 }
126 service, err := a.historicalSessionService(root)
127 if err != nil {
128 t.Fatal(err)
129 }
130 if _, exists := service.Runtime(reader.ref); exists || a.tabs["cold"].Ctrl != nil {
131 t.Fatal("cold read created execution runtime")
132 }
133 state, err := a.workspaceRegistry().Load(t.Context())
134 if err != nil || len(state.SourceMappings) != 0 || len(state.PendingOperations) != 0 {
135 t.Fatalf("cold read imported source: %v", err)
136 }
137 after, err := desktopSourceFingerprint(path)
138 if err != nil || before != after {
139 t.Fatal("cold read modified authoritative records")
140 }
141 if _, err := os.Stat(filepath.Join(config.DesktopSessionStoreDir(), id)); !os.IsNotExist(err) {
142 t.Fatalf("cold read created new-format copy: %v", err)
143 }
144 a.ReleaseSessionHistoryRead(handle.ID)
145 page, err = a.ReadSessionHistoryWindow(handle.ID, session.HistoryWindowRequest{Anchor: "newest"})
146 if err != nil || page.Status != "stale_cursor" {
147 t.Fatalf("released handle: %+v %v", page, err)
148 }
149 }
150
151 func TestNativeHistorySourceReplacementRetiresOldGeneration(t *testing.T) {
152 a := historySliceTestApp(t)
153 t.Cleanup(a.closeHistoryReaders)
154 tab := newColdHistoryTab(t, a)
155 path := filepath.Join(tabSessionDir(tab), "generation.jsonl")
156 if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
157 t.Fatal(err)
158 }
159 if err := os.WriteFile(path, []byte("{\"role\":\"user\",\"content\":\"old\"}\n"), 0600); err != nil {
160 t.Fatal(err)
161 }
162 tab.SessionPath = path
163 one, err := a.BeginSessionHistoryReadForTab(tab.ID)
164 if err != nil {
165 t.Fatal(err)
166 }
167 first, _ := a.historyReader(one.ID)
168 if page, err := a.ReadSessionHistorySlice(one.ID, HistorySliceRequest{Entries: 2}); err != nil || page.Status != "ready" {
169 t.Fatalf("initial page: %+v %v", page, err)
170 }
171 replacement := filepath.Join(filepath.Dir(path), "replacement")
172 if err := os.WriteFile(replacement, []byte("{\"role\":\"user\",\"content\":\"new\"}\n"), 0600); err != nil {
173 t.Fatal(err)
174 }
175 if err := os.Rename(replacement, path); err != nil {
176 t.Fatal(err)
177 }
178 two, err := a.BeginSessionHistoryReadForTab(tab.ID)
179 if err != nil {
180 t.Fatal(err)
181 }
182 second, _ := a.historyReader(two.ID)
183 if first.ctx.Err() != context.Canceled {
184 t.Fatal("source replacement did not cancel in-flight reads")
185 }
186 if first.native == second.native || first.native.key == second.native.key {
187 t.Fatal("replacement reused the old preparation")
188 }
189 if page, err := a.ReadSessionHistorySlice(two.ID, HistorySliceRequest{Entries: 2}); err != nil || page.Status != "ready" {
190 t.Fatalf("replacement page: %+v %v", page, err)
191 }
192 select {
193 case <-first.native.closed:
194 default:
195 t.Fatal("new cache published before old SQLite handle closed")
196 }
197 if page, err := a.ReadSessionHistorySlice(one.ID, HistorySliceRequest{Entries: 2}); err != nil || page.Status != "stale_cursor" {
198 t.Fatalf("old page: %+v %v", page, err)
199 }
200 a.ReleaseSessionHistoryRead(one.ID)
201 if second.native.ctx.Err() != nil {
202 t.Fatal("late old release canceled the new generation")
203 }
204 a.ReleaseSessionHistoryRead(two.ID)
205 }
206
207 func TestNativeHistoryWaitRejectsRetiredPreparation(t *testing.T) {
208 for _, ready := range []bool{false, true} {
209 t.Run(map[bool]string{false: "preparing", true: "ready"}[ready], func(t *testing.T) {
210 ctx, cancel := context.WithCancel(t.Context())
211 job := &nativeHistoryPreparation{ctx: ctx, done: make(chan struct{}), pager: &agent.DisplayPager{}}
212 if ready {
213 close(job.done)
214 }
215 cancel()
216 pager, err := job.wait(t.Context())
217 if pager != nil || !errors.Is(err, context.Canceled) {
218 t.Fatalf("retired preparation returned a pager: %v, %v", pager, err)
219 }
220 })
221 }
222 }
223
224 func TestNativeHistoryImmediateReopenRetainsCloseBarrier(t *testing.T) {
225 a := historySliceTestApp(t)
226 t.Cleanup(a.closeHistoryReaders)
227 tab := newColdHistoryTab(t, a)
228 _, tab.SessionPath = saveHistorySliceSession(t, tabSessionDir(tab), "reopen.jsonl", []provider.Message{historySliceUser(0, "one")})
229 resume, err := a.historyMaintenance.Foreground(t.Context())
230 if err != nil {
231 t.Fatal(err)
232 }
233 defer resume()
234 one, err := a.BeginSessionHistoryReadForTab(tab.ID)
235 if err != nil {
236 t.Fatal(err)
237 }
238 first, _ := a.historyReader(one.ID)
239 generation, err := nativeHistorySourceGeneration(first.path)
240 if err != nil {
241 t.Fatal(err)
242 }
243 // Delay the worker's final close bookkeeping under the owner lock, then
244 // reacquire the same source before the canceled worker can retire it.
245 a.historyReaders.mu.Lock()
246 job := first.native
247 job.cancel()
248 next, release := a.acquireNativeHistoryLocked(first.path, first.head, sessionRuntimeKey(first.path), generation)
249 if next == job || next.ctx.Err() != nil {
250 a.historyReaders.mu.Unlock()
251 t.Fatal("reopen reused a canceled preparation")
252 }
253 a.historyReaders.mu.Unlock()
254 defer release()
255 a.ReleaseSessionHistoryRead(one.ID)
256 resume()
257 if _, err := next.wait(t.Context()); err != nil {
258 t.Fatal(err)
259 }
260 select {
261 case <-job.closed:
262 default:
263 t.Fatal("new preparation completed before predecessor closed")
264 }
265 if next.ctx.Err() != nil {
266 t.Fatal("old release canceled the reopened reader")
267 }
268 }
269
270 func TestNativeHistoryCompatibilityPageBorrowsBoundPreparation(t *testing.T) {
271 a := historySliceTestApp(t)
272 t.Cleanup(a.closeHistoryReaders)
273 tab := newColdHistoryTab(t, a)
274 _, tab.SessionPath = saveHistorySliceSession(t, tabSessionDir(tab), "compatibility.jsonl", []provider.Message{
275 historySliceUser(0, "one"), historySliceAssistant(0, "answer"), historySliceUser(1, "two"),
276 })
277 handle, err := a.BeginSessionHistoryReadForTab(tab.ID)
278 if err != nil {
279 t.Fatal(err)
280 }
281 defer a.ReleaseSessionHistoryRead(handle.ID)
282 request := normalizeHistorySliceRequest(HistorySliceRequest{Entries: 1, Turns: 1})
283 bound, err := a.ReadSessionHistorySlice(handle.ID, request)
284 if err != nil || bound.Status != "ready" {
285 t.Fatalf("bound page: %+v %v", bound, err)
286 }
287 reader, _ := a.historyReader(handle.ID)
288 compatibility, ready, err := a.pagedColdHistorySlice(t.Context(), tabSessionDir(tab), tab.SessionPath, request)
289 if err != nil || !ready {
290 t.Fatalf("compatibility page: %+v %v", compatibility, err)
291 }
292 if compatibility.NextCursor == "" || compatibility.NextCursor != bound.Page.NextCursor {
293 t.Fatal("compatibility and bound readers used different source generations")
294 }
295 if reader.ctx.Err() != nil || reader.native.pager.DB.Ping() != nil {
296 t.Fatal("compatibility reader released the bound reader's database")
297 }
298 a.historyReaders.mu.Lock()
299 refs := reader.native.refs
300 a.historyReaders.mu.Unlock()
301 if refs != 1 {
302 t.Fatalf("compatibility read leaked a preparation reference: %d", refs)
303 }
304 }
305
306 func TestNativeHistoryShutdownCancelsUnboundPreparation(t *testing.T) {
307 a := historySliceTestApp(t)
308 tab := newColdHistoryTab(t, a)
309 _, path := saveHistorySliceSession(t, tabSessionDir(tab), "shutdown.jsonl", []provider.Message{historySliceUser(0, "one")})
310 generation, err := nativeHistorySourceGeneration(path)
311 if err != nil {
312 t.Fatal(err)
313 }
314 resume, err := a.historyMaintenance.Foreground(t.Context())
315 if err != nil {
316 t.Fatal(err)
317 }
318 defer resume()
319 a.historyReaders.mu.Lock()
320 job, release := a.acquireNativeHistoryLocked(path, "", sessionRuntimeKey(path), generation)
321 a.historyReaders.mu.Unlock()
322 defer release()
323 a.closeHistoryReaders()
324 if _, err := job.wait(t.Context()); !errors.Is(err, context.Canceled) {
325 t.Fatalf("shutdown left unbound preparation active: %v", err)
326 }
327 select {
328 case <-job.closed:
329 default:
330 t.Fatal("shutdown returned before cache owner closed")
331 }
332 }
333
333 lines GO