返回 DeepSeek-Reasonix
history_slice_test.go
根目录 / desktop / history_slice_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "reflect"
10 "slices"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15 "unicode/utf8"
16
17 "reasonix/internal/agent"
18 "reasonix/internal/control"
19 "reasonix/internal/event"
20 "reasonix/internal/provider"
21 "reasonix/internal/store"
22 )
23
24 // --- fixtures ---------------------------------------------------------------
25
26 func historySliceTestApp(t *testing.T) *App {
27 t.Helper()
28 isolateDesktopUserDirs(t)
29 app := NewApp()
30 app.ctx = context.Background()
31 return app
32 }
33
34 // saveHistorySliceSession builds a session from msgs and saves it to
35 // dir/name (which also publishes the display index sidecar).
36 func saveHistorySliceSession(t *testing.T, dir, name string, msgs []provider.Message) (*agent.Session, string) {
37 t.Helper()
38 if err := os.MkdirAll(dir, 0o755); err != nil {
39 t.Fatal(err)
40 }
41 sess := agent.NewSession("")
42 for _, m := range msgs {
43 sess.Add(m)
44 }
45 path := filepath.Join(dir, name)
46 if err := sess.Save(path); err != nil {
47 t.Fatalf("save session: %v", err)
48 }
49 return sess, path
50 }
51
52 // newLiveHistoryTab installs a running controller for sess as the app's only
53 // tab and returns the tab.
54 func newLiveHistoryTab(t *testing.T, app *App, dir, sessionPath string, sess *agent.Session) *WorkspaceTab {
55 t.Helper()
56 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
57 ctrl := control.New(control.Options{
58 Executor: exec,
59 SessionDir: dir,
60 SessionPath: sessionPath,
61 Label: "test",
62 Sink: event.Discard,
63 })
64 t.Cleanup(func() {
65 waitHistoryIndexRebuilds(t, app)
66 ctrl.Close()
67 })
68 tab := &WorkspaceTab{
69 ID: "test",
70 Scope: "global",
71 SessionPath: sessionPath,
72 Ready: true,
73 Ctrl: ctrl,
74 disabledMCP: map[string]ServerView{},
75 }
76 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
77 app.tabOrder = []string{tab.ID}
78 app.activeTabID = tab.ID
79 return tab
80 }
81
82 // newColdHistoryTab installs a controller-less tab; the session file is
83 // expected at tab.SessionPath inside tabSessionDir(tab).
84 func newColdHistoryTab(t *testing.T, app *App) *WorkspaceTab {
85 t.Helper()
86 tab := &WorkspaceTab{
87 ID: "cold",
88 Scope: "global",
89 Ready: true,
90 disabledMCP: map[string]ServerView{},
91 }
92 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
93 app.tabOrder = []string{tab.ID}
94 app.activeTabID = tab.ID
95 return tab
96 }
97
98 func historySliceUser(i int, text string) provider.Message {
99 return provider.Message{Role: provider.RoleUser, Content: text, CreatedAt: 1_700_000_000_000 + int64(i)}
100 }
101
102 func historySliceAssistant(i int, text string) provider.Message {
103 return provider.Message{Role: provider.RoleAssistant, Content: text, CreatedAt: 1_700_000_000_000 + int64(i)}
104 }
105
106 // historySliceToolTurn builds one tool-heavy turn: user, assistant with two
107 // calls, two results, assistant with one call, one result, final answer.
108 func historySliceToolTurn(i int) []provider.Message {
109 call := func(suffix string) provider.ToolCall {
110 return provider.ToolCall{
111 ID: fmt.Sprintf("call-%d-%s", i, suffix),
112 Name: "read_file",
113 Arguments: fmt.Sprintf(`{"path":"file-%d-%s.txt"}`, i, suffix),
114 }
115 }
116 result := func(suffix string) provider.Message {
117 return provider.Message{
118 Role: provider.RoleTool,
119 ToolCallID: fmt.Sprintf("call-%d-%s", i, suffix),
120 Name: "read_file",
121 Content: fmt.Sprintf("contents of file %d %s\nline2", i, suffix),
122 CreatedAt: 1_700_000_000_000 + int64(i),
123 }
124 }
125 return []provider.Message{
126 historySliceUser(i, fmt.Sprintf("question-%d", i)),
127 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{call("a"), call("b")}, CreatedAt: 1_700_000_000_000 + int64(i)},
128 result("a"),
129 result("b"),
130 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{call("c")}, CreatedAt: 1_700_000_000_000 + int64(i)},
131 result("c"),
132 historySliceAssistant(i, fmt.Sprintf("answer-%d", i)),
133 }
134 }
135
136 // referenceHistoryRows is the full-history conversion the slice pages must
137 // reassemble to, computed through the legacy full-walk helpers.
138 func referenceHistoryRows(t *testing.T, sessionDir, sessionPath string) []HistoryMessage {
139 t.Helper()
140 loaded, err := agent.LoadSession(sessionPath)
141 if err != nil {
142 t.Fatalf("load session: %v", err)
143 }
144 msgs := historyProviderMessagesWithPersistedTimes(loaded.Snapshot(), sessionPath)
145 return historyMessagesWithPlannerDisplays(
146 msgs,
147 sessionDisplayResolver(sessionDir, sessionPath),
148 sessionPlannerDisplayTurns(sessionDir, sessionPath),
149 nil,
150 )
151 }
152
153 // collectHistorySlicePages pages from the latest to the oldest, failing on
154 // stale cursors or cursor cycles.
155 func collectHistorySlicePages(t *testing.T, app *App, tabID string, req HistorySliceRequest) []HistorySlice {
156 t.Helper()
157 pages := []HistorySlice{}
158 cursor := ""
159 for i := range 10000 {
160 req.Cursor = cursor
161 page := app.HistorySliceForTab(tabID, req)
162 if page.Stale {
163 t.Fatalf("page %d unexpectedly stale", i)
164 }
165 if page.Entries == nil {
166 t.Fatalf("page %d: Entries is nil", i)
167 }
168 pages = append(pages, page)
169 if !page.HasOlder {
170 if page.NextCursor != "" {
171 t.Fatalf("page %d: HasOlder=false but NextCursor set", i)
172 }
173 return pages
174 }
175 if page.NextCursor == "" || page.NextCursor == cursor {
176 t.Fatalf("page %d: cursor did not advance", i)
177 }
178 cursor = page.NextCursor
179 }
180 t.Fatal("paging did not terminate")
181 return nil
182 }
183
184 // concatHistoryPages stitches pages (newest-first) into the full row sequence.
185 func concatHistoryPages(pages []HistorySlice) []HistoryMessage {
186 out := []HistoryMessage{}
187 for _, page := range slices.Backward(pages) {
188 for _, e := range page.Entries {
189 out = append(out, e.Message)
190 }
191 }
192 return out
193 }
194
195 // assertPagesMatchReference asserts the paged rows exactly reassemble the full
196 // conversion: no duplication, no omission.
197 func assertPagesMatchReference(t *testing.T, pages []HistorySlice, reference []HistoryMessage) {
198 t.Helper()
199 got := concatHistoryPages(pages)
200 if !reflect.DeepEqual(got, reference) {
201 n := min(len(got), len(reference))
202 for i := range n {
203 if !reflect.DeepEqual(got[i], reference[i]) {
204 t.Fatalf("row %d differs:\n got: %+v\nwant: %+v", i, got[i], reference[i])
205 }
206 }
207 t.Fatalf("row count = %d, want %d", len(got), len(reference))
208 }
209 // Entry IDs and orders must be unique and strictly increasing.
210 seen := map[string]bool{}
211 lastOrder, lastTurn := -1, -1
212 for _, page := range slices.Backward(pages) {
213 for _, e := range page.Entries {
214 if seen[e.EntryID] {
215 t.Fatalf("duplicate entry ID %s", e.EntryID)
216 }
217 seen[e.EntryID] = true
218 if e.Order < lastOrder {
219 t.Fatalf("entry order regressed: %d after %d", e.Order, lastOrder)
220 }
221 if e.Turn < lastTurn {
222 t.Fatalf("entry turn regressed: %d after %d", e.Turn, lastTurn)
223 }
224 lastOrder, lastTurn = e.Order, e.Turn
225 }
226 }
227 }
228
229 // --- budget tests -----------------------------------------------------------
230
231 func TestHistorySliceTurnBudget(t *testing.T) {
232 app := historySliceTestApp(t)
233 dir := t.TempDir()
234 msgs := []provider.Message{{Role: provider.RoleSystem, Content: "sys"}}
235 for i := range 30 {
236 msgs = append(msgs, historySliceUser(i, fmt.Sprintf("question-%d", i)), historySliceAssistant(i, fmt.Sprintf("answer-%d", i)))
237 }
238 sess, path := saveHistorySliceSession(t, dir, "turns.jsonl", msgs)
239 newLiveHistoryTab(t, app, dir, path, sess)
240
241 pages := collectHistorySlicePages(t, app, "test", HistorySliceRequest{Turns: 12, Entries: 1000, Bytes: 8 << 20})
242 if len(pages) != 3 {
243 t.Fatalf("pages = %d, want 3 (12+12+6 turns)", len(pages))
244 }
245 if pages[0].TotalTurns != 30 {
246 t.Fatalf("TotalTurns = %d, want 30", pages[0].TotalTurns)
247 }
248 if pages[0].EndTurn-pages[0].StartTurn+1 != 12 {
249 t.Fatalf("page 1 spans turns %d..%d, want exactly 12", pages[0].StartTurn, pages[0].EndTurn)
250 }
251 if pages[2].StartTurn != 1 || pages[2].HasOlder {
252 t.Fatalf("oldest page StartTurn = %d HasOlder = %v, want 1/false", pages[2].StartTurn, pages[2].HasOlder)
253 }
254 // The oldest page includes the pre-turn system message.
255 if got := pages[2].Entries[0].Message.Role; got != "system" {
256 t.Fatalf("oldest page first row role = %q, want system", got)
257 }
258 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
259 }
260
261 func TestHistorySliceEntryBudget(t *testing.T) {
262 app := historySliceTestApp(t)
263 dir := t.TempDir()
264 var msgs []provider.Message
265 for i := range 150 {
266 // One user + one assistant per turn = 2 entries per turn.
267 msgs = append(msgs, historySliceUser(i, fmt.Sprintf("q%d", i)), historySliceAssistant(i, fmt.Sprintf("a%d", i)))
268 }
269 sess, path := saveHistorySliceSession(t, dir, "entries.jsonl", msgs)
270 newLiveHistoryTab(t, app, dir, path, sess)
271
272 pages := collectHistorySlicePages(t, app, "test", HistorySliceRequest{Turns: 500, Entries: 120, Bytes: 8 << 20})
273 if len(pages) != 3 {
274 t.Fatalf("pages = %d, want 3 (120+120+60)", len(pages))
275 }
276 if len(pages[0].Entries) != 120 || len(pages[1].Entries) != 120 || len(pages[2].Entries) != 60 {
277 t.Fatalf("entry counts = %d/%d/%d, want 120/120/60", len(pages[0].Entries), len(pages[1].Entries), len(pages[2].Entries))
278 }
279 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
280 }
281
282 func TestHistorySliceByteBudget(t *testing.T) {
283 app := historySliceTestApp(t)
284 dir := t.TempDir()
285 // 60KiB contents stay under the 64KiB ref threshold, so they inline and
286 // count against the byte budget: 512KiB fits 8 entries (480KiB), the 9th
287 // would exceed it.
288 body := strings.Repeat("x", 60<<10)
289 var msgs []provider.Message
290 for i := range 20 {
291 msgs = append(msgs, historySliceUser(i, fmt.Sprintf("q%d", i)), historySliceAssistant(i, body))
292 }
293 sess, path := saveHistorySliceSession(t, dir, "bytes.jsonl", msgs)
294 newLiveHistoryTab(t, app, dir, path, sess)
295
296 page := app.HistorySliceForTab("test", HistorySliceRequest{Turns: 500, Entries: 1000, Bytes: 512 << 10})
297 if page.Stale {
298 t.Fatal("unexpected stale page")
299 }
300 // The byte budget keeps the maximal suffix under 512KiB: 8 user+assistant
301 // pairs (8×60KiB ≈ 480KiB); the 9th assistant body would exceed it.
302 if len(page.Entries) != 16 {
303 t.Fatalf("entries = %d, want 16 (8 pairs × 60KiB under 512KiB)", len(page.Entries))
304 }
305 if !page.HasOlder {
306 t.Fatal("HasOlder = false, want true")
307 }
308 pages := collectHistorySlicePages(t, app, "test", HistorySliceRequest{Turns: 500, Entries: 1000, Bytes: 512 << 10})
309 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
310 }
311
312 func TestHistorySliceGiantTurnSpansPages(t *testing.T) {
313 app := historySliceTestApp(t)
314 dir := t.TempDir()
315 // One user turn followed by 600 tool interactions — a single turn that no
316 // page can hold; pagination must cut at message boundaries.
317 msgs := []provider.Message{historySliceUser(0, "giant turn")}
318 for i := range 300 {
319 id := fmt.Sprintf("call-%d", i)
320 msgs = append(msgs,
321 provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "bash", Arguments: fmt.Sprintf(`{"command":"step %d"}`, i)}}, CreatedAt: 1_700_000_000_000 + int64(i)},
322 provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "bash", Content: fmt.Sprintf("step %d output", i), CreatedAt: 1_700_000_000_000 + int64(i)},
323 )
324 }
325 msgs = append(msgs, historySliceAssistant(0, "done"))
326 sess, path := saveHistorySliceSession(t, dir, "giant.jsonl", msgs)
327 newLiveHistoryTab(t, app, dir, path, sess)
328
329 pages := collectHistorySlicePages(t, app, "test", HistorySliceRequest{Turns: 12, Entries: 50, Bytes: 512 << 10})
330 if len(pages) < 5 {
331 t.Fatalf("pages = %d, want the giant turn spread over many pages", len(pages))
332 }
333 for i, page := range pages {
334 if page.TotalTurns != 1 {
335 t.Fatalf("page %d TotalTurns = %d, want 1", i, page.TotalTurns)
336 }
337 }
338 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
339 }
340
341 // --- content refs + chunks --------------------------------------------------
342
343 func TestHistorySliceContentRefChunkRoundTrip(t *testing.T) {
344 app := historySliceTestApp(t)
345 dir := t.TempDir()
346 big := strings.Repeat("abcdefghij", 10_000) // 100KiB ASCII
347 msgs := []provider.Message{
348 historySliceUser(0, "q"),
349 historySliceAssistant(0, big),
350 }
351 sess, path := saveHistorySliceSession(t, dir, "big.jsonl", msgs)
352 newLiveHistoryTab(t, app, dir, path, sess)
353
354 page := app.HistorySliceForTab("test", HistorySliceRequest{})
355 if page.Stale || len(page.Entries) != 2 {
356 t.Fatalf("page = stale:%v entries:%d, want 2 fresh entries", page.Stale, len(page.Entries))
357 }
358 entry := page.Entries[1]
359 if len(entry.Refs) != 1 {
360 t.Fatalf("refs = %+v, want exactly one content ref", entry.Refs)
361 }
362 ref := entry.Refs[0]
363 if ref.Field != "content" || ref.Size != len(big) {
364 t.Fatalf("ref = %+v, want content size %d", ref, len(big))
365 }
366 if len(entry.Message.Content) > historyFieldPreviewBytes {
367 t.Fatalf("inline preview = %d bytes, want <= %d", len(entry.Message.Content), historyFieldPreviewBytes)
368 }
369 if !strings.HasPrefix(big, entry.Message.Content) {
370 t.Fatal("inline value is not a prefix preview of the original")
371 }
372
373 var b strings.Builder
374 chunks := 0
375 for i := 0; ; i++ {
376 chunk := app.HistoryContentForTab("test", ref, i)
377 if chunk.Stale {
378 t.Fatalf("chunk %d unexpectedly stale", i)
379 }
380 if chunk.EntryID != ref.EntryID || chunk.Field != "content" {
381 t.Fatalf("chunk %d identity = %s/%s", i, chunk.EntryID, chunk.Field)
382 }
383 if i == 0 {
384 chunks = chunk.Chunks
385 if chunks != ref.Chunks {
386 t.Fatalf("chunk count = %d, ref says %d", chunks, ref.Chunks)
387 }
388 }
389 if len(chunk.Data) > historyContentChunkBytes {
390 t.Fatalf("chunk %d = %d bytes, over budget", i, len(chunk.Data))
391 }
392 b.WriteString(chunk.Data)
393 if chunk.Done {
394 break
395 }
396 }
397 if b.String() != big {
398 t.Fatal("reassembled chunks do not equal the original content")
399 }
400 }
401
402 func TestHistorySliceContentRefUTF8Boundary(t *testing.T) {
403 app := historySliceTestApp(t)
404 dir := t.TempDir()
405 // "界🙂" is 7 bytes (3 + 4); repeating it guarantees 256KiB chunk
406 // boundaries land mid-rune unless the splitter backs off.
407 big := strings.Repeat("界🙂", 40_000) // 280KiB
408 msgs := []provider.Message{
409 historySliceUser(0, "q"),
410 historySliceAssistant(0, big),
411 }
412 sess, path := saveHistorySliceSession(t, dir, "utf8.jsonl", msgs)
413 newLiveHistoryTab(t, app, dir, path, sess)
414
415 page := app.HistorySliceForTab("test", HistorySliceRequest{})
416 if len(page.Entries) != 2 || len(page.Entries[1].Refs) != 1 {
417 t.Fatalf("entries = %+v, want the big assistant row with one ref", page.Entries)
418 }
419 ref := page.Entries[1].Refs[0]
420 var b strings.Builder
421 for i := 0; ; i++ {
422 chunk := app.HistoryContentForTab("test", ref, i)
423 if chunk.Stale {
424 t.Fatalf("chunk %d stale", i)
425 }
426 if !utf8.ValidString(chunk.Data) {
427 t.Fatalf("chunk %d is not valid UTF-8 (split mid-rune)", i)
428 }
429 if len(chunk.Data) > historyContentChunkBytes {
430 t.Fatalf("chunk %d = %d bytes, over budget", i, len(chunk.Data))
431 }
432 b.WriteString(chunk.Data)
433 if chunk.Done {
434 break
435 }
436 }
437 if b.String() != big {
438 t.Fatal("UTF-8 reassembly mismatch")
439 }
440 }
441
442 func TestHistorySliceContentRefStaleAfterSave(t *testing.T) {
443 app := historySliceTestApp(t)
444 dir := t.TempDir()
445 big := strings.Repeat("z", 100<<10)
446 msgs := []provider.Message{historySliceUser(0, "q"), historySliceAssistant(0, big)}
447 sess, path := saveHistorySliceSession(t, dir, "stale-ref.jsonl", msgs)
448 newLiveHistoryTab(t, app, dir, path, sess)
449
450 page := app.HistorySliceForTab("test", HistorySliceRequest{})
451 ref := page.Entries[1].Refs[0]
452
453 sess.Add(historySliceUser(1, "more"))
454 sess.Add(historySliceAssistant(1, "more"))
455 if err := sess.Save(path); err != nil {
456 t.Fatalf("save: %v", err)
457 }
458 chunk := app.HistoryContentForTab("test", ref, 0)
459 if !chunk.Stale {
460 t.Fatal("chunk after revision bump should be stale")
461 }
462 }
463
464 func TestHistorySliceColdContentRefUsesAuthoritativeEventTail(t *testing.T) {
465 app := historySliceTestApp(t)
466 tab := newColdHistoryTab(t, app)
467 dir := tabSessionDir(tab)
468 sess, path := saveHistorySliceSession(t, dir, "cold-content-tail.jsonl", []provider.Message{
469 historySliceUser(0, "old question"), historySliceAssistant(0, "old answer"),
470 })
471 oldModel, err := os.ReadFile(path)
472 if err != nil {
473 t.Fatal(err)
474 }
475 big := strings.Repeat("authoritative-tail-", 8_000)
476 sess.Add(historySliceUser(1, "new question"))
477 sess.Add(historySliceAssistant(1, big))
478 if err := sess.SaveSnapshot(path); err != nil {
479 t.Fatalf("SaveSnapshot tail: %v", err)
480 }
481 if err := os.WriteFile(path, oldModel, 0o600); err != nil {
482 t.Fatalf("restore stale display model: %v", err)
483 }
484 logFile, err := os.OpenFile(store.SessionEventLog(path), os.O_WRONLY|os.O_APPEND, 0o600)
485 if err != nil {
486 t.Fatalf("open event log: %v", err)
487 }
488 if _, err := logFile.WriteString(`{"schema_version":1,"type":"append","mess`); err != nil {
489 logFile.Close()
490 t.Fatalf("append torn event: %v", err)
491 }
492 if err := logFile.Close(); err != nil {
493 t.Fatalf("close event log: %v", err)
494 }
495 tab.SessionPath = path
496
497 page := app.HistorySliceForTab("cold", HistorySliceRequest{})
498 if page.Source != "event-log" {
499 t.Fatalf("Source = %q, want event-log recovery", page.Source)
500 }
501 entry := page.Entries[len(page.Entries)-1]
502 if len(entry.Refs) != 1 {
503 t.Fatalf("tail refs = %+v, want one content ref", entry.Refs)
504 }
505 chunk := app.HistoryContentForTab("cold", entry.Refs[0], 0)
506 if chunk.Stale || chunk.Data == "" || !strings.HasPrefix(big, chunk.Data) {
507 t.Fatalf("damaged-log prefix content chunk = stale:%v bytes:%d", chunk.Stale, len(chunk.Data))
508 }
509 }
510
511 // --- cursor staleness -------------------------------------------------------
512
513 func TestHistorySliceCursorStaleOnRevisionBump(t *testing.T) {
514 app := historySliceTestApp(t)
515 dir := t.TempDir()
516 var msgs []provider.Message
517 for i := range 20 {
518 msgs = append(msgs, historySliceUser(i, fmt.Sprintf("q%d", i)), historySliceAssistant(i, fmt.Sprintf("a%d", i)))
519 }
520 sess, path := saveHistorySliceSession(t, dir, "stale.jsonl", msgs)
521 newLiveHistoryTab(t, app, dir, path, sess)
522
523 page1 := app.HistorySliceForTab("test", HistorySliceRequest{Turns: 5})
524 if !page1.HasOlder || page1.NextCursor == "" {
525 t.Fatalf("page 1 HasOlder=%v cursor=%q", page1.HasOlder, page1.NextCursor)
526 }
527 if !page1.RevisionKnown || page1.Revision <= 0 || page1.Digest == "" {
528 t.Fatalf("page 1 identity = known:%v revision:%d digest:%q, want canonical fingerprint", page1.RevisionKnown, page1.Revision, page1.Digest)
529 }
530
531 sess.Add(historySliceUser(20, "q20"))
532 sess.Add(historySliceAssistant(20, "a20"))
533 if err := sess.Save(path); err != nil {
534 t.Fatalf("save: %v", err)
535 }
536 page2 := app.HistorySliceForTab("test", HistorySliceRequest{Turns: 5, Cursor: page1.NextCursor})
537 if !page2.Stale {
538 t.Fatal("continuing with a pre-save cursor must be stale")
539 }
540 if page2.Entries == nil || len(page2.Entries) != 0 {
541 t.Fatalf("stale page entries = %v, want empty non-nil", page2.Entries)
542 }
543 if !page2.RevisionKnown || page2.Revision <= page1.Revision || page2.Digest == "" || page2.Digest == page1.Digest {
544 t.Fatalf("stale page identity = known:%v revision:%d digest:%q, want advanced canonical fingerprint", page2.RevisionKnown, page2.Revision, page2.Digest)
545 }
546 encoded, _ := json.Marshal(page2)
547 if !strings.Contains(string(encoded), `"entries":[]`) {
548 t.Fatalf("stale page JSON must encode entries as []: %s", encoded)
549 }
550 }
551
552 func TestHistorySliceGarbageCursorServesLatest(t *testing.T) {
553 app := historySliceTestApp(t)
554 dir := t.TempDir()
555 msgs := []provider.Message{historySliceUser(0, "q"), historySliceAssistant(0, "a")}
556 sess, path := saveHistorySliceSession(t, dir, "garbage.jsonl", msgs)
557 newLiveHistoryTab(t, app, dir, path, sess)
558
559 page := app.HistorySliceForTab("test", HistorySliceRequest{Cursor: "!!!not-a-cursor!!!"})
560 if page.Stale || len(page.Entries) != 2 {
561 t.Fatalf("garbage cursor: stale=%v entries=%d, want latest page", page.Stale, len(page.Entries))
562 }
563 }
564
565 // --- cold tabs --------------------------------------------------------------
566
567 func TestHistorySliceColdTabFromIndex(t *testing.T) {
568 app := historySliceTestApp(t)
569 tab := newColdHistoryTab(t, app)
570 dir := tabSessionDir(tab)
571 var msgs []provider.Message
572 for i := range 20 {
573 msgs = append(msgs, historySliceToolTurn(i)...)
574 }
575 _, path := saveHistorySliceSession(t, dir, "cold.jsonl", msgs)
576 tab.SessionPath = path
577
578 if _, err := os.Stat(store.SessionDisplayIndex(path)); err != nil {
579 t.Fatalf("save should have written the display index: %v", err)
580 }
581 pages := collectHistorySlicePages(t, app, "cold", HistorySliceRequest{Turns: 5, Entries: 40})
582 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
583 }
584
585 func TestHistorySliceColdTabScanFallbackAndRebuild(t *testing.T) {
586 app := historySliceTestApp(t)
587 tab := newColdHistoryTab(t, app)
588 dir := tabSessionDir(tab)
589 var msgs []provider.Message
590 for i := range 10 {
591 msgs = append(msgs, historySliceToolTurn(i)...)
592 }
593 _, path := saveHistorySliceSession(t, dir, "cold-scan.jsonl", msgs)
594 tab.SessionPath = path
595 indexPath := store.SessionDisplayIndex(path)
596
597 // Delete the index: the first request must page correctly via streaming
598 // scan (no full LoadSession) and republish the index.
599 if err := os.Remove(indexPath); err != nil {
600 t.Fatal(err)
601 }
602 pages := collectHistorySlicePages(t, app, "cold", HistorySliceRequest{Turns: 4, Entries: 30})
603 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
604 if _, err := agent.LoadSessionDisplayIndex(indexPath); err != nil {
605 t.Fatalf("index should be republished after scan fallback: %v", err)
606 }
607
608 // Corrupt the index: same guarantees.
609 if err := os.WriteFile(indexPath, []byte("{not json"), 0o600); err != nil {
610 t.Fatal(err)
611 }
612 pages = collectHistorySlicePages(t, app, "cold", HistorySliceRequest{Turns: 4, Entries: 30})
613 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
614 idx, err := agent.LoadSessionDisplayIndex(indexPath)
615 if err != nil {
616 t.Fatalf("corrupt index should be rebuilt: %v", err)
617 }
618 info, err := os.Stat(path)
619 if err != nil {
620 t.Fatal(err)
621 }
622 if idx.TranscriptSize != info.Size() {
623 t.Fatalf("rebuilt index TranscriptSize = %d, file size = %d", idx.TranscriptSize, info.Size())
624 }
625 }
626
627 func TestHistorySliceColdTabSizeGuard(t *testing.T) {
628 app := historySliceTestApp(t)
629 tab := newColdHistoryTab(t, app)
630 dir := tabSessionDir(tab)
631 var msgs []provider.Message
632 for i := range 5 {
633 msgs = append(msgs, historySliceUser(i, fmt.Sprintf("q%d", i)), historySliceAssistant(i, fmt.Sprintf("a%d", i)))
634 }
635 _, path := saveHistorySliceSession(t, dir, "cold-size.jsonl", msgs)
636 tab.SessionPath = path
637 // Model a pre-WAL checkpoint. Once a native event log exists it is the
638 // canonical transcript, so direct edits to the compatibility JSONL anchor
639 // must not supersede it.
640 removeHistorySliceNativeState(t, path)
641
642 // Append a message line directly to the .jsonl: the index TranscriptSize
643 // no longer matches the file size and must be treated as stale — the page
644 // must come from a rescan and include the appended message, not corrupt
645 // offset slicing.
646 extra, err := json.Marshal(historySliceAssistant(5, "appended-externally"))
647 if err != nil {
648 t.Fatal(err)
649 }
650 f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)
651 if err != nil {
652 t.Fatal(err)
653 }
654 if _, err := f.Write(append(extra, '\n')); err != nil {
655 t.Fatal(err)
656 }
657 if err := f.Close(); err != nil {
658 t.Fatal(err)
659 }
660
661 page := app.HistorySliceForTab("cold", HistorySliceRequest{Turns: 12})
662 if page.Stale {
663 t.Fatal("unexpected stale page")
664 }
665 last := page.Entries[len(page.Entries)-1]
666 if last.Message.Content != "appended-externally" {
667 t.Fatalf("latest entry content = %q, want the externally appended message", last.Message.Content)
668 }
669 }
670
671 func TestHistorySliceColdUsesAuthoritativeEventLogTail(t *testing.T) {
672 app := historySliceTestApp(t)
673 tab := newColdHistoryTab(t, app)
674 dir := tabSessionDir(tab)
675 base := []provider.Message{historySliceUser(0, "old question"), historySliceAssistant(0, "old answer")}
676 sess, path := saveHistorySliceSession(t, dir, "cold-event-tail.jsonl", base)
677 oldReadModel, err := os.ReadFile(path)
678 if err != nil {
679 t.Fatalf("read old display model: %v", err)
680 }
681 // Current saves advance both files. Restore the old read model afterward to
682 // model a crash/older build and prove the event log still wins.
683 sess.Add(historySliceUser(1, "new question"))
684 sess.Add(historySliceAssistant(1, "new answer"))
685 if err := sess.SaveSnapshot(path); err != nil {
686 t.Fatalf("SaveSnapshot append: %v", err)
687 }
688 if err := os.WriteFile(path, oldReadModel, 0o600); err != nil {
689 t.Fatalf("restore stale display model: %v", err)
690 }
691 tab.SessionPath = path
692
693 page := app.HistorySliceForTab("cold", HistorySliceRequest{Turns: 12})
694 if page.Source != "event-log" {
695 t.Fatalf("Source = %q, want event-log", page.Source)
696 }
697 if len(page.Entries) == 0 || page.Entries[len(page.Entries)-1].Message.Content != "new answer" {
698 t.Fatalf("latest cold entry = %+v, want event-log tail", page.Entries)
699 }
700 }
701
702 func TestHistorySliceColdEmptySession(t *testing.T) {
703 app := historySliceTestApp(t)
704 tab := newColdHistoryTab(t, app)
705 dir := tabSessionDir(tab)
706 if err := os.MkdirAll(dir, 0o755); err != nil {
707 t.Fatal(err)
708 }
709 path := filepath.Join(dir, "empty.jsonl")
710 if err := os.WriteFile(path, nil, 0o600); err != nil {
711 t.Fatal(err)
712 }
713 tab.SessionPath = path
714
715 page := app.HistorySliceForTab("cold", HistorySliceRequest{})
716 if page.Stale || page.HasOlder || len(page.Entries) != 0 || page.TotalTurns != 0 {
717 t.Fatalf("empty session page = %+v", page)
718 }
719 encoded, err := json.Marshal(page)
720 if err != nil {
721 t.Fatal(err)
722 }
723 if !strings.Contains(string(encoded), `"entries":[]`) {
724 t.Fatalf("empty page must encode entries as []: %s", encoded)
725 }
726 }
727
728 // --- live fallback + unsaved tail -------------------------------------------
729
730 func TestHistorySliceLiveUnsavedTail(t *testing.T) {
731 app := historySliceTestApp(t)
732 dir := t.TempDir()
733 var msgs []provider.Message
734 for i := range 5 {
735 msgs = append(msgs, historySliceUser(i, fmt.Sprintf("q%d", i)), historySliceAssistant(i, fmt.Sprintf("a%d", i)))
736 }
737 sess, path := saveHistorySliceSession(t, dir, "tail.jsonl", msgs)
738 newLiveHistoryTab(t, app, dir, path, sess)
739
740 // Unsaved appends live only in memory; the index covers the persisted
741 // prefix and the tail must be classified in memory.
742 sess.Add(historySliceUser(5, "q5-unsaved"))
743 sess.Add(historySliceAssistant(5, "a5-unsaved"))
744
745 page := app.HistorySliceForTab("test", HistorySliceRequest{Turns: 12})
746 if page.Stale {
747 t.Fatal("unexpected stale page")
748 }
749 if page.TotalTurns != 6 {
750 t.Fatalf("TotalTurns = %d, want 6 including the unsaved tail", page.TotalTurns)
751 }
752 last := page.Entries[len(page.Entries)-1]
753 if last.Message.Content != "a5-unsaved" || last.Turn != 6 {
754 t.Fatalf("latest entry = %q turn %d, want a5-unsaved turn 6", last.Message.Content, last.Turn)
755 }
756 }
757
758 func TestHistorySliceLiveFallbackRebuildsIndex(t *testing.T) {
759 app := historySliceTestApp(t)
760 dir := t.TempDir()
761 var msgs []provider.Message
762 for i := range 6 {
763 msgs = append(msgs, historySliceUser(i, fmt.Sprintf("q%d", i)), historySliceAssistant(i, fmt.Sprintf("a%d", i)))
764 }
765 sess, path := saveHistorySliceSession(t, dir, "fallback.jsonl", msgs)
766 newLiveHistoryTab(t, app, dir, path, sess)
767 indexPath := store.SessionDisplayIndex(path)
768 if err := os.Remove(indexPath); err != nil {
769 t.Fatal(err)
770 }
771
772 // The request falls back to in-memory classification and stays correct…
773 pages := collectHistorySlicePages(t, app, "test", HistorySliceRequest{Turns: 2, Entries: 10})
774 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
775
776 // …and the single-flight background rebuild republishes the index.
777 deadline := time.Now().Add(5 * time.Second)
778 for {
779 if _, err := agent.LoadSessionDisplayIndex(indexPath); err == nil {
780 break
781 }
782 if time.Now().After(deadline) {
783 t.Fatal("background rebuild did not republish the display index")
784 }
785 time.Sleep(5 * time.Millisecond)
786 }
787 }
788
789 // TestHistorySliceSourceField pins the diagnostic read-path label: cold pages
790 // report "index" (display-index hit) or "scan" (streaming rebuild), live
791 // pages "live-index" or "live-fallback".
792 func TestHistorySliceSourceField(t *testing.T) {
793 newSession := func(t *testing.T, name string) (*App, *agent.Session, string) {
794 app := historySliceTestApp(t)
795 tab := newColdHistoryTab(t, app)
796 dir := tabSessionDir(tab)
797 var msgs []provider.Message
798 for i := range 4 {
799 msgs = append(msgs, historySliceUser(i, fmt.Sprintf("q%d", i)), historySliceAssistant(i, fmt.Sprintf("a%d", i)))
800 }
801 sess, path := saveHistorySliceSession(t, dir, name, msgs)
802 tab.SessionPath = path
803 return app, sess, path
804 }
805
806 t.Run("cold index hit", func(t *testing.T) {
807 app, _, _ := newSession(t, "src-index.jsonl")
808 if page := app.HistorySliceForTab("cold", HistorySliceRequest{}); page.Source != "index" {
809 t.Fatalf("Source = %q, want index", page.Source)
810 }
811 })
812
813 t.Run("cold scan fallback", func(t *testing.T) {
814 app, _, path := newSession(t, "src-scan.jsonl")
815 if err := os.Remove(store.SessionDisplayIndex(path)); err != nil {
816 t.Fatal(err)
817 }
818 if page := app.HistorySliceForTab("cold", HistorySliceRequest{}); page.Source != "scan" {
819 t.Fatalf("Source = %q, want scan", page.Source)
820 }
821 })
822
823 t.Run("live index hit", func(t *testing.T) {
824 app, sess, path := newSession(t, "src-live-index.jsonl")
825 newLiveHistoryTab(t, app, filepath.Dir(path), path, sess)
826 if page := app.HistorySliceForTab("test", HistorySliceRequest{}); page.Source != "live-index" {
827 t.Fatalf("Source = %q, want live-index", page.Source)
828 }
829 })
830
831 t.Run("live fallback", func(t *testing.T) {
832 app, sess, path := newSession(t, "src-live-fallback.jsonl")
833 newLiveHistoryTab(t, app, filepath.Dir(path), path, sess)
834 if err := os.Remove(store.SessionDisplayIndex(path)); err != nil {
835 t.Fatal(err)
836 }
837 if page := app.HistorySliceForTab("test", HistorySliceRequest{}); page.Source != "live-fallback" {
838 t.Fatalf("Source = %q, want live-fallback", page.Source)
839 }
840 })
841 }
842
843 // --- classification ---------------------------------------------------------
844
845 func TestHistorySliceSyntheticAndSteerTurns(t *testing.T) {
846 app := historySliceTestApp(t)
847 dir := t.TempDir()
848 msgs := []provider.Message{
849 historySliceUser(0, "real question 1"),
850 historySliceAssistant(0, "answer 1"),
851 {Role: provider.RoleUser, Content: agent.MidTurnSteerPrefix + "\nfocus on tests", CreatedAt: 1_700_000_000_100},
852 {Role: provider.RoleUser, Content: "<compaction-summary>\nfolded", CreatedAt: 1_700_000_000_101},
853 historySliceUser(1, "real question 2"),
854 historySliceAssistant(1, "answer 2"),
855 }
856 sess, path := saveHistorySliceSession(t, dir, "classify.jsonl", msgs)
857 newLiveHistoryTab(t, app, dir, path, sess)
858
859 page := app.HistorySliceForTab("test", HistorySliceRequest{})
860 if page.TotalTurns != 2 {
861 t.Fatalf("TotalTurns = %d, want 2 (steer and synthetic excluded)", page.TotalTurns)
862 }
863 assertPagesMatchReference(t, []HistorySlice{page}, referenceHistoryRows(t, dir, path))
864 foundSteerNotice := false
865 for _, e := range page.Entries {
866 if e.Message.Role == "notice" && strings.HasPrefix(e.Message.Content, "↪ ") {
867 foundSteerNotice = true
868 }
869 if e.Turn > 2 {
870 t.Fatalf("entry turn = %d, want <= 2", e.Turn)
871 }
872 }
873 if !foundSteerNotice {
874 t.Fatal("steer should surface as a ↪ notice row")
875 }
876 }
877
878 func TestHistorySliceImagesAndUnicode(t *testing.T) {
879 app := historySliceTestApp(t)
880 dir := t.TempDir()
881 msgs := []provider.Message{
882 {Role: provider.RoleUser, Content: "看看这张截图 🖼️", Images: []string{"data:image/png;base64,iVBORw0KGgo="}, CreatedAt: 1_700_000_000_000},
883 historySliceAssistant(0, "图中是……界面,🙂 已识别。"),
884 historySliceUser(1, "第二个问题:中文与 emoji 👍 混排"),
885 historySliceAssistant(1, "回答:混排正常。"),
886 }
887 sess, path := saveHistorySliceSession(t, dir, "unicode.jsonl", msgs)
888 newLiveHistoryTab(t, app, dir, path, sess)
889
890 pages := collectHistorySlicePages(t, app, "test", HistorySliceRequest{Turns: 1, Entries: 2})
891 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
892 if pages[len(pages)-1].TotalTurns != 2 {
893 t.Fatalf("TotalTurns = %d, want 2", pages[len(pages)-1].TotalTurns)
894 }
895 }
896
897 // --- large shapes -----------------------------------------------------------
898
899 func TestHistorySliceToolHeavy3255(t *testing.T) {
900 app := historySliceTestApp(t)
901 dir := t.TempDir()
902 var msgs []provider.Message
903 for i := range 465 { // 465 × 7 = 3255 messages
904 msgs = append(msgs, historySliceToolTurn(i)...)
905 }
906 sess, path := saveHistorySliceSession(t, dir, "tool-heavy.jsonl", msgs)
907 newLiveHistoryTab(t, app, dir, path, sess)
908
909 pages := collectHistorySlicePages(t, app, "test", HistorySliceRequest{Turns: 12, Entries: 120, Bytes: 512 << 10})
910 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
911 }
912
913 func TestHistorySlice46Turn625MessagesCold(t *testing.T) {
914 app := historySliceTestApp(t)
915 tab := newColdHistoryTab(t, app)
916 dir := tabSessionDir(tab)
917 // 1 system + 45 turns × 10 messages + one 174-message turn = 625.
918 msgs := []provider.Message{{Role: provider.RoleSystem, Content: "sys"}}
919 for i := range 45 {
920 msgs = append(msgs, historySliceUser(i, fmt.Sprintf("q%d", i)))
921 for j := range 4 {
922 id := fmt.Sprintf("call-%d-%d", i, j)
923 msgs = append(msgs,
924 provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "bash", Arguments: `{"command":"x"}`}}, CreatedAt: 1_700_000_000_000 + int64(i)},
925 provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "bash", Content: "ok", CreatedAt: 1_700_000_000_000 + int64(i)},
926 )
927 }
928 msgs = append(msgs, historySliceAssistant(i, fmt.Sprintf("a%d", i)))
929 }
930 last := []provider.Message{historySliceUser(45, "q45")}
931 for j := range 86 {
932 id := fmt.Sprintf("call-45-%d", j)
933 last = append(last,
934 provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "bash", Arguments: `{"command":"y"}`}}, CreatedAt: 1_700_000_000_045},
935 provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "bash", Content: "ok", CreatedAt: 1_700_000_000_045},
936 )
937 }
938 last = append(last, historySliceAssistant(45, "a45")) // 174 messages
939 msgs = append(msgs, last...)
940 if len(msgs) != 625 {
941 t.Fatalf("fixture = %d messages, want 625", len(msgs))
942 }
943 _, path := saveHistorySliceSession(t, dir, "46-turns.jsonl", msgs)
944 tab.SessionPath = path
945
946 pages := collectHistorySlicePages(t, app, "cold", HistorySliceRequest{Turns: 7, Entries: 90})
947 if pages[0].TotalTurns != 46 {
948 t.Fatalf("TotalTurns = %d, want 46", pages[0].TotalTurns)
949 }
950 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
951 }
952
953 // --- JSON contract ----------------------------------------------------------
954
955 func TestHistorySliceArraysNeverNull(t *testing.T) {
956 encoded, err := json.Marshal(HistorySlice{})
957 if err != nil {
958 t.Fatal(err)
959 }
960 if !strings.Contains(string(encoded), `"entries":[]`) {
961 t.Fatalf("zero HistorySlice must encode entries as []: %s", encoded)
962 }
963 encoded, err = json.Marshal(staleHistorySlice(3, true, "digest"))
964 if err != nil {
965 t.Fatal(err)
966 }
967 if !strings.Contains(string(encoded), `"entries":[]`) {
968 t.Fatalf("stale HistorySlice must encode entries as []: %s", encoded)
969 }
970 encoded, err = json.Marshal(HistoryEntry{Refs: []HistoryContentRef{}})
971 if err != nil {
972 t.Fatal(err)
973 }
974 if !strings.Contains(string(encoded), `"refs":[]`) {
975 t.Fatalf("HistoryEntry must encode refs as []: %s", encoded)
976 }
977 }
978
979 // --- chunk helpers ----------------------------------------------------------
980
981 func TestHistoryContentChunksRuneAligned(t *testing.T) {
982 if got := historyContentChunkCount(""); got != 1 {
983 t.Fatalf("empty string chunks = %d, want 1", got)
984 }
985 if data, chunks := historyContentChunkAt("", 0); data != "" || chunks != 1 {
986 t.Fatalf("empty chunk = %q/%d", data, chunks)
987 }
988 small := "hello 世界"
989 if got := historyContentChunkCount(small); got != 1 {
990 t.Fatalf("small string chunks = %d, want 1", got)
991 }
992 // Build a string whose 256KiB boundary falls inside a 4-byte rune.
993 unit := "🙂" // 4 bytes
994 big := strings.Repeat(unit, (historyContentChunkBytes/4)+10)
995 if chunks := historyContentChunkCount(big); chunks != 2 {
996 t.Fatalf("chunks = %d, want 2", chunks)
997 }
998 first, _ := historyContentChunkAt(big, 0)
999 second, _ := historyContentChunkAt(big, 1)
1000 if !utf8.ValidString(first) || !utf8.ValidString(second) {
1001 t.Fatal("chunks are not valid UTF-8")
1002 }
1003 if first+second != big {
1004 t.Fatal("chunk split lost content")
1005 }
1006 if len(first) > historyContentChunkBytes {
1007 t.Fatalf("first chunk = %d bytes, over budget", len(first))
1008 }
1009 // Out-of-range chunk index returns empty data with the total count.
1010 if data, chunks := historyContentChunkAt(big, 5); data != "" || chunks != 2 {
1011 t.Fatalf("out-of-range chunk = %q/%d", data, chunks)
1012 }
1013 }
1014
1015 // --- concurrency ------------------------------------------------------------
1016
1017 func TestHistorySliceConcurrentReadsDuringSave(t *testing.T) {
1018 app := historySliceTestApp(t)
1019 dir := t.TempDir()
1020 var msgs []provider.Message
1021 for i := range 30 {
1022 msgs = append(msgs, historySliceToolTurn(i)...)
1023 }
1024 sess, path := saveHistorySliceSession(t, dir, "race.jsonl", msgs)
1025 newLiveHistoryTab(t, app, dir, path, sess)
1026
1027 const readers = 4
1028 start := make(chan struct{})
1029 stop := make(chan struct{})
1030 var wg sync.WaitGroup
1031 errs := make(chan error, readers)
1032 for r := range readers {
1033 wg.Add(1)
1034 go func(r int) {
1035 defer wg.Done()
1036 <-start
1037 cursor := ""
1038 for {
1039 select {
1040 case <-stop:
1041 return
1042 default:
1043 }
1044 page := app.HistorySliceForTab("test", HistorySliceRequest{Turns: 3, Entries: 25, Cursor: cursor})
1045 if page.Entries == nil {
1046 errs <- fmt.Errorf("reader %d: nil entries", r)
1047 return
1048 }
1049 if page.Stale {
1050 // A save landed between pages: restart from latest, as the
1051 // frontend would.
1052 cursor = ""
1053 continue
1054 }
1055 if !page.HasOlder {
1056 cursor = ""
1057 continue
1058 }
1059 cursor = page.NextCursor
1060 }
1061 }(r)
1062 }
1063
1064 // Writer: append + save in a loop while readers page.
1065 close(start)
1066 for i := 30; i < 38; i++ {
1067 sess.Add(historySliceUser(i, fmt.Sprintf("q%d", i)))
1068 sess.Add(historySliceAssistant(i, fmt.Sprintf("a%d", i)))
1069 if err := sess.Save(path); err != nil {
1070 close(stop)
1071 wg.Wait()
1072 t.Fatalf("save: %v", err)
1073 }
1074 }
1075 close(stop)
1076 wg.Wait()
1077 close(errs)
1078 for err := range errs {
1079 t.Fatal(err)
1080 }
1081 // The final state must page cleanly end to end.
1082 pages := collectHistorySlicePages(t, app, "test", HistorySliceRequest{Turns: 5, Entries: 40})
1083 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
1084 }
1085
1085 lines GO