返回 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 "reasonix/internal/agent"
10 "reasonix/internal/control"
11 "reasonix/internal/event"
12 "reasonix/internal/provider"
13 "reasonix/internal/store"
14 "reflect"
15 "slices"
16 "strings"
17 "testing"
18 "time"
19 "unicode/utf8"
20 )
21
22 // --- fixtures ---------------------------------------------------------------
23
24 func historySliceTestApp(t *testing.T) *App {
25 t.Helper()
26 isolateDesktopUserDirs(t)
27 app := NewApp()
28 app.ctx = context.Background()
29 return app
30 }
31
32 // saveHistorySliceSession builds a session from msgs and saves it to
33 // dir/name (which also publishes the display index sidecar).
34 func saveHistorySliceSession(t *testing.T, dir, name string, msgs []provider.Message) (*agent.Session, string) {
35 t.Helper()
36 if err := os.MkdirAll(dir, 0o755); err != nil {
37 t.Fatal(err)
38 }
39 sess := agent.NewSession("")
40 for _, m := range msgs {
41 sess.Add(m)
42 }
43 path := filepath.Join(dir, name)
44 if err := sess.Save(path); err != nil {
45 t.Fatalf("save session: %v", err)
46 }
47 return sess, path
48 }
49
50 // newLiveHistoryTab installs a running controller for sess as the app's only
51 // tab and returns the tab.
52 func newLiveHistoryTab(t *testing.T, app *App, dir, sessionPath string, sess *agent.Session) *WorkspaceTab {
53 t.Helper()
54 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
55 ctrl := control.New(control.Options{
56 Executor: exec,
57 SessionDir: dir,
58 SessionPath: sessionPath,
59 Label: "test",
60 Sink: event.Discard,
61 })
62 t.Cleanup(func() {
63 waitHistoryIndexRebuilds(t, app)
64 ctrl.Close()
65 })
66 tab := &WorkspaceTab{
67 ID: "test",
68 Scope: "global",
69 SessionPath: sessionPath,
70 Ready: true,
71 Ctrl: ctrl,
72 disabledMCP: map[string]ServerView{},
73 }
74 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
75 app.tabOrder = []string{tab.ID}
76 app.activeTabID = tab.ID
77 return tab
78 }
79
80 // newColdHistoryTab installs a controller-less tab; the session file is
81 // expected at tab.SessionPath inside tabSessionDir(tab).
82 func newColdHistoryTab(t *testing.T, app *App) *WorkspaceTab {
83 t.Helper()
84 tab := &WorkspaceTab{
85 ID: "cold",
86 Scope: "global",
87 Ready: true,
88 disabledMCP: map[string]ServerView{},
89 }
90 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
91 app.tabOrder = []string{tab.ID}
92 app.activeTabID = tab.ID
93 return tab
94 }
95
96 func historySliceUser(i int, text string) provider.Message {
97 return provider.Message{Role: provider.RoleUser, Content: text, CreatedAt: 1_700_000_000_000 + int64(i)}
98 }
99
100 func historySliceAssistant(i int, text string) provider.Message {
101 return provider.Message{Role: provider.RoleAssistant, Content: text, CreatedAt: 1_700_000_000_000 + int64(i)}
102 }
103
104 // historySliceToolTurn builds one tool-heavy turn: user, assistant with two
105 // calls, two results, assistant with one call, one result, final answer.
106 func historySliceToolTurn(i int) []provider.Message {
107 call := func(suffix string) provider.ToolCall {
108 return provider.ToolCall{
109 ID: fmt.Sprintf("call-%d-%s", i, suffix),
110 Name: "read_file",
111 Arguments: fmt.Sprintf(`{"path":"file-%d-%s.txt"}`, i, suffix),
112 }
113 }
114 result := func(suffix string) provider.Message {
115 return provider.Message{
116 Role: provider.RoleTool,
117 ToolCallID: fmt.Sprintf("call-%d-%s", i, suffix),
118 Name: "read_file",
119 Content: fmt.Sprintf("contents of file %d %s\nline2", i, suffix),
120 CreatedAt: 1_700_000_000_000 + int64(i),
121 }
122 }
123 return []provider.Message{
124 historySliceUser(i, fmt.Sprintf("question-%d", i)),
125 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{call("a"), call("b")}, CreatedAt: 1_700_000_000_000 + int64(i)},
126 result("a"),
127 result("b"),
128 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{call("c")}, CreatedAt: 1_700_000_000_000 + int64(i)},
129 result("c"),
130 historySliceAssistant(i, fmt.Sprintf("answer-%d", i)),
131 }
132 }
133
134 // referenceHistoryRows is the full-history conversion the slice pages must
135 // reassemble to, computed through the legacy full-walk helpers.
136 func referenceHistoryRows(t *testing.T, sessionDir, sessionPath string) []HistoryMessage {
137 t.Helper()
138 loaded, err := agent.LoadSession(sessionPath)
139 if err != nil {
140 t.Fatalf("load session: %v", err)
141 }
142 msgs := historyProviderMessagesWithPersistedTimes(loaded.Snapshot(), sessionPath)
143 return historyMessagesWithPlannerDisplays(
144 msgs,
145 sessionDisplayResolver(sessionDir, sessionPath),
146 sessionPlannerDisplayTurns(sessionDir, sessionPath),
147 nil,
148 )
149 }
150
151 // collectHistorySlicePages pages from the latest to the oldest, failing on
152 // stale cursors or cursor cycles.
153 func collectHistorySlicePages(t *testing.T, app *App, tabID string, req HistorySliceRequest) []HistorySlice {
154 t.Helper()
155 pages := []HistorySlice{}
156 cursor := ""
157 for i := range 10000 {
158 req.Cursor = cursor
159 page := app.HistorySliceForTab(tabID, req)
160 if page.Stale {
161 t.Fatalf("page %d unexpectedly stale", i)
162 }
163 if page.Entries == nil {
164 t.Fatalf("page %d: Entries is nil", i)
165 }
166 pages = append(pages, page)
167 if !page.HasOlder {
168 if page.NextCursor != "" {
169 t.Fatalf("page %d: HasOlder=false but NextCursor set", i)
170 }
171 return pages
172 }
173 if page.NextCursor == "" || page.NextCursor == cursor {
174 t.Fatalf("page %d: cursor did not advance", i)
175 }
176 cursor = page.NextCursor
177 }
178 t.Fatal("paging did not terminate")
179 return nil
180 }
181
182 // concatHistoryPages stitches pages (newest-first) into the full row sequence.
183 func concatHistoryPages(pages []HistorySlice) []HistoryMessage {
184 out := []HistoryMessage{}
185 for _, page := range slices.Backward(pages) {
186 for _, e := range page.Entries {
187 out = append(out, e.Message)
188 }
189 }
190 return out
191 }
192
193 // assertPagesMatchReference asserts the paged rows exactly reassemble the full
194 // conversion: no duplication, no omission.
195 func assertPagesMatchReference(t *testing.T, pages []HistorySlice, reference []HistoryMessage) {
196 t.Helper()
197 got := concatHistoryPages(pages)
198 if !reflect.DeepEqual(got, reference) {
199 n := min(len(got), len(reference))
200 for i := range n {
201 if !reflect.DeepEqual(got[i], reference[i]) {
202 t.Fatalf("row %d differs:\n got: %+v\nwant: %+v", i, got[i], reference[i])
203 }
204 }
205 t.Fatalf("row count = %d, want %d", len(got), len(reference))
206 }
207 // Entry IDs and orders must be unique and strictly increasing.
208 seen := map[string]bool{}
209 lastOrder, lastTurn := -1, -1
210 for _, page := range slices.Backward(pages) {
211 for _, e := range page.Entries {
212 if seen[e.EntryID] {
213 t.Fatalf("duplicate entry ID %s", e.EntryID)
214 }
215 seen[e.EntryID] = true
216 if e.Order < lastOrder {
217 t.Fatalf("entry order regressed: %d after %d", e.Order, lastOrder)
218 }
219 if e.Turn < lastTurn {
220 t.Fatalf("entry turn regressed: %d after %d", e.Turn, lastTurn)
221 }
222 lastOrder, lastTurn = e.Order, e.Turn
223 }
224 }
225 }
226
227 // --- budget tests -----------------------------------------------------------
228
229 func TestHistorySliceTurnBudget(t *testing.T) {
230 app := historySliceTestApp(t)
231 dir := t.TempDir()
232 msgs := []provider.Message{{Role: provider.RoleSystem, Content: "sys"}}
233 for i := range 30 {
234 msgs = append(msgs, historySliceUser(i, fmt.Sprintf("question-%d", i)), historySliceAssistant(i, fmt.Sprintf("answer-%d", i)))
235 }
236 sess, path := saveHistorySliceSession(t, dir, "turns.jsonl", msgs)
237 newLiveHistoryTab(t, app, dir, path, sess)
238
239 pages := collectHistorySlicePages(t, app, "test", HistorySliceRequest{Turns: 12, Entries: 1000, Bytes: 8 << 20})
240 if len(pages) != 3 {
241 t.Fatalf("pages = %d, want 3 (12+12+6 turns)", len(pages))
242 }
243 if pages[0].TotalTurns != 30 {
244 t.Fatalf("TotalTurns = %d, want 30", pages[0].TotalTurns)
245 }
246 if pages[0].EndTurn-pages[0].StartTurn+1 != 12 {
247 t.Fatalf("page 1 spans turns %d..%d, want exactly 12", pages[0].StartTurn, pages[0].EndTurn)
248 }
249 if pages[2].StartTurn != 1 || pages[2].HasOlder {
250 t.Fatalf("oldest page StartTurn = %d HasOlder = %v, want 1/false", pages[2].StartTurn, pages[2].HasOlder)
251 }
252 // The oldest page includes the pre-turn system message.
253 if got := pages[2].Entries[0].Message.Role; got != "system" {
254 t.Fatalf("oldest page first row role = %q, want system", got)
255 }
256 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
257 }
258
259 func TestHistorySliceEntryBudget(t *testing.T) {
260 app := historySliceTestApp(t)
261 dir := t.TempDir()
262 var msgs []provider.Message
263 for i := range 150 {
264 // One user + one assistant per turn = 2 entries per turn.
265 msgs = append(msgs, historySliceUser(i, fmt.Sprintf("q%d", i)), historySliceAssistant(i, fmt.Sprintf("a%d", i)))
266 }
267 sess, path := saveHistorySliceSession(t, dir, "entries.jsonl", msgs)
268 newLiveHistoryTab(t, app, dir, path, sess)
269
270 pages := collectHistorySlicePages(t, app, "test", HistorySliceRequest{Turns: 500, Entries: 120, Bytes: 8 << 20})
271 if len(pages) != 3 {
272 t.Fatalf("pages = %d, want 3 (120+120+60)", len(pages))
273 }
274 if len(pages[0].Entries) != 120 || len(pages[1].Entries) != 120 || len(pages[2].Entries) != 60 {
275 t.Fatalf("entry counts = %d/%d/%d, want 120/120/60", len(pages[0].Entries), len(pages[1].Entries), len(pages[2].Entries))
276 }
277 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
278 }
279
280 func TestHistorySliceByteBudget(t *testing.T) {
281 app := historySliceTestApp(t)
282 dir := t.TempDir()
283 // 60KiB contents stay under the 64KiB ref threshold, so they inline and
284 // count against the byte budget: 512KiB fits 8 entries (480KiB), the 9th
285 // would exceed it.
286 body := strings.Repeat("x", 60<<10)
287 var msgs []provider.Message
288 for i := range 20 {
289 msgs = append(msgs, historySliceUser(i, fmt.Sprintf("q%d", i)), historySliceAssistant(i, body))
290 }
291 sess, path := saveHistorySliceSession(t, dir, "bytes.jsonl", msgs)
292 newLiveHistoryTab(t, app, dir, path, sess)
293
294 page := app.HistorySliceForTab("test", HistorySliceRequest{Turns: 500, Entries: 1000, Bytes: 512 << 10})
295 if page.Stale {
296 t.Fatal("unexpected stale page")
297 }
298 // The byte budget keeps the maximal suffix under 512KiB: 8 user+assistant
299 // pairs (8×60KiB ≈ 480KiB); the 9th assistant body would exceed it.
300 if len(page.Entries) != 16 {
301 t.Fatalf("entries = %d, want 16 (8 pairs × 60KiB under 512KiB)", len(page.Entries))
302 }
303 if !page.HasOlder {
304 t.Fatal("HasOlder = false, want true")
305 }
306 pages := collectHistorySlicePages(t, app, "test", HistorySliceRequest{Turns: 500, Entries: 1000, Bytes: 512 << 10})
307 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
308 }
309
310 func TestHistorySliceGiantTurnSpansPages(t *testing.T) {
311 app := historySliceTestApp(t)
312 dir := t.TempDir()
313 // One user turn followed by 600 tool interactions — a single turn that no
314 // page can hold; pagination must cut at message boundaries.
315 msgs := []provider.Message{historySliceUser(0, "giant turn")}
316 for i := range 300 {
317 id := fmt.Sprintf("call-%d", i)
318 msgs = append(msgs,
319 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)},
320 provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "bash", Content: fmt.Sprintf("step %d output", i), CreatedAt: 1_700_000_000_000 + int64(i)},
321 )
322 }
323 msgs = append(msgs, historySliceAssistant(0, "done"))
324 sess, path := saveHistorySliceSession(t, dir, "giant.jsonl", msgs)
325 newLiveHistoryTab(t, app, dir, path, sess)
326
327 pages := collectHistorySlicePages(t, app, "test", HistorySliceRequest{Turns: 12, Entries: 50, Bytes: 512 << 10})
328 if len(pages) < 5 {
329 t.Fatalf("pages = %d, want the giant turn spread over many pages", len(pages))
330 }
331 for i, page := range pages {
332 if page.TotalTurns != 1 {
333 t.Fatalf("page %d TotalTurns = %d, want 1", i, page.TotalTurns)
334 }
335 }
336 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
337 }
338
339 // --- content refs + chunks --------------------------------------------------
340
341 func TestHistorySliceContentRefChunkRoundTrip(t *testing.T) {
342 app := historySliceTestApp(t)
343 dir := t.TempDir()
344 big := strings.Repeat("abcdefghij", 10_000) // 100KiB ASCII
345 msgs := []provider.Message{
346 historySliceUser(0, "q"),
347 historySliceAssistant(0, big),
348 }
349 sess, path := saveHistorySliceSession(t, dir, "big.jsonl", msgs)
350 newLiveHistoryTab(t, app, dir, path, sess)
351
352 page := app.HistorySliceForTab("test", HistorySliceRequest{})
353 if page.Stale || len(page.Entries) != 2 {
354 t.Fatalf("page = stale:%v entries:%d, want 2 fresh entries", page.Stale, len(page.Entries))
355 }
356 entry := page.Entries[1]
357 if len(entry.Refs) != 1 {
358 t.Fatalf("refs = %+v, want exactly one content ref", entry.Refs)
359 }
360 ref := entry.Refs[0]
361 if ref.Field != "content" || ref.Size != len(big) {
362 t.Fatalf("ref = %+v, want content size %d", ref, len(big))
363 }
364 if len(entry.Message.Content) > historyFieldPreviewBytes {
365 t.Fatalf("inline preview = %d bytes, want <= %d", len(entry.Message.Content), historyFieldPreviewBytes)
366 }
367 if !strings.HasPrefix(big, entry.Message.Content) {
368 t.Fatal("inline value is not a prefix preview of the original")
369 }
370
371 var b strings.Builder
372 chunks := 0
373 for i := 0; ; i++ {
374 chunk := app.HistoryContentForTab("test", ref, i)
375 if chunk.Stale {
376 t.Fatalf("chunk %d unexpectedly stale", i)
377 }
378 if chunk.EntryID != ref.EntryID || chunk.Field != "content" {
379 t.Fatalf("chunk %d identity = %s/%s", i, chunk.EntryID, chunk.Field)
380 }
381 if i == 0 {
382 chunks = chunk.Chunks
383 if chunks != ref.Chunks {
384 t.Fatalf("chunk count = %d, ref says %d", chunks, ref.Chunks)
385 }
386 }
387 if len(chunk.Data) > historyContentChunkBytes {
388 t.Fatalf("chunk %d = %d bytes, over budget", i, len(chunk.Data))
389 }
390 b.WriteString(chunk.Data)
391 if chunk.Done {
392 break
393 }
394 }
395 if b.String() != big {
396 t.Fatal("reassembled chunks do not equal the original content")
397 }
398 }
399
400 func TestHistorySliceContentRefUTF8Boundary(t *testing.T) {
401 app := historySliceTestApp(t)
402 dir := t.TempDir()
403 // "界🙂" is 7 bytes (3 + 4); repeating it guarantees 256KiB chunk
404 // boundaries land mid-rune unless the splitter backs off.
405 big := strings.Repeat("界🙂", 40_000) // 280KiB
406 msgs := []provider.Message{
407 historySliceUser(0, "q"),
408 historySliceAssistant(0, big),
409 }
410 sess, path := saveHistorySliceSession(t, dir, "utf8.jsonl", msgs)
411 newLiveHistoryTab(t, app, dir, path, sess)
412
413 page := app.HistorySliceForTab("test", HistorySliceRequest{})
414 if len(page.Entries) != 2 || len(page.Entries[1].Refs) != 1 {
415 t.Fatalf("entries = %+v, want the big assistant row with one ref", page.Entries)
416 }
417 ref := page.Entries[1].Refs[0]
418 var b strings.Builder
419 for i := 0; ; i++ {
420 chunk := app.HistoryContentForTab("test", ref, i)
421 if chunk.Stale {
422 t.Fatalf("chunk %d stale", i)
423 }
424 if !utf8.ValidString(chunk.Data) {
425 t.Fatalf("chunk %d is not valid UTF-8 (split mid-rune)", i)
426 }
427 if len(chunk.Data) > historyContentChunkBytes {
428 t.Fatalf("chunk %d = %d bytes, over budget", i, len(chunk.Data))
429 }
430 b.WriteString(chunk.Data)
431 if chunk.Done {
432 break
433 }
434 }
435 if b.String() != big {
436 t.Fatal("UTF-8 reassembly mismatch")
437 }
438 }
439
440 func TestHistorySliceContentRefStaleAfterSave(t *testing.T) {
441 app := historySliceTestApp(t)
442 dir := t.TempDir()
443 big := strings.Repeat("z", 100<<10)
444 msgs := []provider.Message{historySliceUser(0, "q"), historySliceAssistant(0, big)}
445 sess, path := saveHistorySliceSession(t, dir, "stale-ref.jsonl", msgs)
446 newLiveHistoryTab(t, app, dir, path, sess)
447
448 page := app.HistorySliceForTab("test", HistorySliceRequest{})
449 ref := page.Entries[1].Refs[0]
450
451 sess.Add(historySliceUser(1, "more"))
452 sess.Add(historySliceAssistant(1, "more"))
453 if err := sess.Save(path); err != nil {
454 t.Fatalf("save: %v", err)
455 }
456 chunk := app.HistoryContentForTab("test", ref, 0)
457 if !chunk.Stale {
458 t.Fatal("chunk after revision bump should be stale")
459 }
460 }
461
462 func TestHistorySliceColdContentRefUsesAuthoritativeEventTail(t *testing.T) {
463 app := historySliceTestApp(t)
464 tab := newColdHistoryTab(t, app)
465 dir := tabSessionDir(tab)
466 sess, path := saveHistorySliceSession(t, dir, "cold-content-tail.jsonl", []provider.Message{
467 historySliceUser(0, "old question"), historySliceAssistant(0, "old answer"),
468 })
469 oldModel, err := os.ReadFile(path)
470 if err != nil {
471 t.Fatal(err)
472 }
473 big := strings.Repeat("authoritative-tail-", 8_000)
474 sess.Add(historySliceUser(1, "new question"))
475 sess.Add(historySliceAssistant(1, big))
476 if err := sess.SaveSnapshot(path); err != nil {
477 t.Fatalf("SaveSnapshot tail: %v", err)
478 }
479 if err := os.WriteFile(path, oldModel, 0o600); err != nil {
480 t.Fatalf("restore stale display model: %v", err)
481 }
482 tab.SessionPath = path
483 page := app.HistorySliceForTab("cold", HistorySliceRequest{})
484 if page.Source != "event-log" || page.Error != "" || len(page.Entries) == 0 {
485 t.Fatalf("authoritative cold page: %+v", page)
486 }
487 entry := page.Entries[len(page.Entries)-1]
488 if len(entry.Refs) != 1 {
489 t.Fatalf("tail refs = %+v, want one content ref", entry.Refs)
490 }
491 chunk := app.HistoryContentForTab("cold", entry.Refs[0], 0)
492 if chunk.Stale || chunk.Data == "" || !strings.HasPrefix(big, chunk.Data) {
493 t.Fatalf("content chunk = stale:%v bytes:%d", chunk.Stale, len(chunk.Data))
494 }
495 logFile, err := os.OpenFile(store.SessionEventLog(path), os.O_WRONLY|os.O_APPEND, 0o600)
496 if err != nil {
497 t.Fatalf("open event log: %v", err)
498 }
499 if _, err := logFile.WriteString(`{"schema_version":1,"type":"append","mess`); err != nil {
500 logFile.Close()
501 t.Fatalf("append torn event: %v", err)
502 }
503 if err := logFile.Close(); err != nil {
504 t.Fatalf("close event log: %v", err)
505 }
506 page = app.HistorySliceForTab("cold", HistorySliceRequest{})
507 if page.Error == "" || len(page.Entries) != 0 {
508 t.Fatalf("damaged source exposed a complete-looking prefix: %+v", page)
509 }
510 }
511
512 // --- cursor staleness -------------------------------------------------------
513
514 func TestHistorySliceCursorStaleOnRevisionBump(t *testing.T) {
515 app := historySliceTestApp(t)
516 dir := t.TempDir()
517 var msgs []provider.Message
518 for i := range 20 {
519 msgs = append(msgs, historySliceUser(i, fmt.Sprintf("q%d", i)), historySliceAssistant(i, fmt.Sprintf("a%d", i)))
520 }
521 sess, path := saveHistorySliceSession(t, dir, "stale.jsonl", msgs)
522 newLiveHistoryTab(t, app, dir, path, sess)
523
524 page1 := app.HistorySliceForTab("test", HistorySliceRequest{Turns: 5})
525 if !page1.HasOlder || page1.NextCursor == "" {
526 t.Fatalf("page 1 HasOlder=%v cursor=%q", page1.HasOlder, page1.NextCursor)
527 }
528 if !page1.RevisionKnown || page1.Revision <= 0 || page1.Digest == "" {
529 t.Fatalf("page 1 identity = known:%v revision:%d digest:%q, want canonical fingerprint", page1.RevisionKnown, page1.Revision, page1.Digest)
530 }
531
532 sess.Add(historySliceUser(20, "q20"))
533 sess.Add(historySliceAssistant(20, "a20"))
534 if err := sess.Save(path); err != nil {
535 t.Fatalf("save: %v", err)
536 }
537 page2 := app.HistorySliceForTab("test", HistorySliceRequest{Turns: 5, Cursor: page1.NextCursor})
538 if !page2.Stale {
539 t.Fatal("continuing with a pre-save cursor must be stale")
540 }
541 if page2.Entries == nil || len(page2.Entries) != 0 {
542 t.Fatalf("stale page entries = %v, want empty non-nil", page2.Entries)
543 }
544 if !page2.RevisionKnown || page2.Revision <= page1.Revision || page2.Digest == "" || page2.Digest == page1.Digest {
545 t.Fatalf("stale page identity = known:%v revision:%d digest:%q, want advanced canonical fingerprint", page2.RevisionKnown, page2.Revision, page2.Digest)
546 }
547 encoded, _ := json.Marshal(page2)
548 if !strings.Contains(string(encoded), `"entries":[]`) {
549 t.Fatalf("stale page JSON must encode entries as []: %s", encoded)
550 }
551 }
552
553 func TestHistorySliceGarbageCursorServesLatest(t *testing.T) {
554 app := historySliceTestApp(t)
555 dir := t.TempDir()
556 msgs := []provider.Message{historySliceUser(0, "q"), historySliceAssistant(0, "a")}
557 sess, path := saveHistorySliceSession(t, dir, "garbage.jsonl", msgs)
558 newLiveHistoryTab(t, app, dir, path, sess)
559
560 page := app.HistorySliceForTab("test", HistorySliceRequest{Cursor: "!!!not-a-cursor!!!"})
561 if page.Stale || len(page.Entries) != 2 {
562 t.Fatalf("garbage cursor: stale=%v entries=%d, want latest page", page.Stale, len(page.Entries))
563 }
564 }
565
566 // --- cold tabs --------------------------------------------------------------
567
568 func TestHistorySliceColdTabFromIndex(t *testing.T) {
569 app := historySliceTestApp(t)
570 tab := newColdHistoryTab(t, app)
571 dir := tabSessionDir(tab)
572 var msgs []provider.Message
573 for i := range 20 {
574 msgs = append(msgs, historySliceToolTurn(i)...)
575 }
576 _, path := saveHistorySliceSession(t, dir, "cold.jsonl", msgs)
577 tab.SessionPath = path
578
579 if _, err := os.Stat(store.SessionDisplayIndex(path)); err != nil {
580 t.Fatalf("save should have written the display index: %v", err)
581 }
582 pages := collectHistorySlicePages(t, app, "cold", HistorySliceRequest{Turns: 5, Entries: 40})
583 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
584 }
585
586 func TestHistorySliceColdTabScanFallbackAndRebuild(t *testing.T) {
587 app := historySliceTestApp(t)
588 tab := newColdHistoryTab(t, app)
589 dir := tabSessionDir(tab)
590 var msgs []provider.Message
591 for i := range 10 {
592 msgs = append(msgs, historySliceToolTurn(i)...)
593 }
594 _, path := saveHistorySliceSession(t, dir, "cold-scan.jsonl", msgs)
595 tab.SessionPath = path
596 indexPath := store.SessionDisplayIndex(path)
597
598 original, err := os.ReadFile(path)
599 if err != nil {
600 t.Fatal(err)
601 }
602 // Missing sidecars are rebuilt in the disposable page projection, without
603 // rewriting the compatibility checkpoint or republishing session sidecars.
604 if err := os.Remove(indexPath); err != nil {
605 t.Fatal(err)
606 }
607 pages := collectHistorySlicePages(t, app, "cold", HistorySliceRequest{Turns: 4, Entries: 30})
608 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
609 if _, err := os.Stat(indexPath); !os.IsNotExist(err) {
610 t.Fatalf("read should not republish a session sidecar: %v", err)
611 }
612
613 // Corrupt the index: same guarantees.
614 if err := os.WriteFile(indexPath, []byte("{not json"), 0o600); err != nil {
615 t.Fatal(err)
616 }
617 pages = collectHistorySlicePages(t, app, "cold", HistorySliceRequest{Turns: 4, Entries: 30})
618 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
619 if body, err := os.ReadFile(indexPath); err != nil || string(body) != "{not json" {
620 t.Fatalf("read rewrote source sidecar: %v", err)
621 }
622 if body, err := os.ReadFile(path); err != nil || string(body) != string(original) {
623 t.Fatalf("read rewrote source checkpoint: %v", err)
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, _, path := newSession(t, "src-index.jsonl")
808 // Equal filesystem timestamps cannot certify that the sidecar follows
809 // the event log. Give this index-hit fixture an unambiguous order.
810 indexTime := agent.SessionContentModTime(path).Add(time.Second)
811 if err := os.Chtimes(store.SessionDisplayIndex(path), indexTime, indexTime); err != nil {
812 t.Fatal(err)
813 }
814 if page := app.HistorySliceForTab("cold", HistorySliceRequest{}); page.Source != "index" {
815 t.Fatalf("Source = %q, want index", page.Source)
816 }
817 })
818
819 t.Run("cold scan fallback", func(t *testing.T) {
820 app, _, path := newSession(t, "src-scan.jsonl")
821 if err := os.Remove(store.SessionDisplayIndex(path)); err != nil {
822 t.Fatal(err)
823 }
824 if page := app.HistorySliceForTab("cold", HistorySliceRequest{}); page.Source != "event-log" {
825 t.Fatalf("Source = %q, want event-log", page.Source)
826 }
827 })
828
829 t.Run("live index hit", func(t *testing.T) {
830 app, sess, path := newSession(t, "src-live-index.jsonl")
831 newLiveHistoryTab(t, app, filepath.Dir(path), path, sess)
832 if page := app.HistorySliceForTab("test", HistorySliceRequest{}); page.Source != "live-index" {
833 t.Fatalf("Source = %q, want live-index", page.Source)
834 }
835 })
836
837 t.Run("live fallback", func(t *testing.T) {
838 app, sess, path := newSession(t, "src-live-fallback.jsonl")
839 newLiveHistoryTab(t, app, filepath.Dir(path), path, sess)
840 if err := os.Remove(store.SessionDisplayIndex(path)); err != nil {
841 t.Fatal(err)
842 }
843 if page := app.HistorySliceForTab("test", HistorySliceRequest{}); page.Source != "live-fallback" {
844 t.Fatalf("Source = %q, want live-fallback", page.Source)
845 }
846 })
847 }
848
849 // --- classification ---------------------------------------------------------
850
851 func TestHistorySliceSyntheticAndSteerTurns(t *testing.T) {
852 app := historySliceTestApp(t)
853 dir := t.TempDir()
854 msgs := []provider.Message{
855 historySliceUser(0, "real question 1"),
856 historySliceAssistant(0, "answer 1"),
857 {Role: provider.RoleUser, Content: agent.MidTurnSteerPrefix + "\nfocus on tests", CreatedAt: 1_700_000_000_100},
858 {Role: provider.RoleUser, Content: "<compaction-summary>\nfolded", CreatedAt: 1_700_000_000_101},
859 historySliceUser(1, "real question 2"),
860 historySliceAssistant(1, "answer 2"),
861 }
862 sess, path := saveHistorySliceSession(t, dir, "classify.jsonl", msgs)
863 newLiveHistoryTab(t, app, dir, path, sess)
864
865 page := app.HistorySliceForTab("test", HistorySliceRequest{})
866 if page.TotalTurns != 2 {
867 t.Fatalf("TotalTurns = %d, want 2 (steer and synthetic excluded)", page.TotalTurns)
868 }
869 assertPagesMatchReference(t, []HistorySlice{page}, referenceHistoryRows(t, dir, path))
870 foundSteerNotice := false
871 for _, e := range page.Entries {
872 if e.Message.Role == "notice" && strings.HasPrefix(e.Message.Content, "↪ ") {
873 foundSteerNotice = true
874 }
875 if e.Turn > 2 {
876 t.Fatalf("entry turn = %d, want <= 2", e.Turn)
877 }
878 }
879 if !foundSteerNotice {
880 t.Fatal("steer should surface as a ↪ notice row")
881 }
882 }
883
884 func TestHistorySliceImagesAndUnicode(t *testing.T) {
885 app := historySliceTestApp(t)
886 dir := t.TempDir()
887 msgs := []provider.Message{
888 {Role: provider.RoleUser, Content: "看看这张截图 🖼️", Images: []string{"data:image/png;base64,iVBORw0KGgo="}, CreatedAt: 1_700_000_000_000},
889 historySliceAssistant(0, "图中是……界面,🙂 已识别。"),
890 historySliceUser(1, "第二个问题:中文与 emoji 👍 混排"),
891 historySliceAssistant(1, "回答:混排正常。"),
892 }
893 sess, path := saveHistorySliceSession(t, dir, "unicode.jsonl", msgs)
894 newLiveHistoryTab(t, app, dir, path, sess)
895
896 pages := collectHistorySlicePages(t, app, "test", HistorySliceRequest{Turns: 1, Entries: 2})
897 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
898 if pages[len(pages)-1].TotalTurns != 2 {
899 t.Fatalf("TotalTurns = %d, want 2", pages[len(pages)-1].TotalTurns)
900 }
901 }
902
903 // --- large shapes -----------------------------------------------------------
904
905 func TestHistorySliceToolHeavy3255(t *testing.T) {
906 app := historySliceTestApp(t)
907 dir := t.TempDir()
908 var msgs []provider.Message
909 for i := range 465 { // 465 × 7 = 3255 messages
910 msgs = append(msgs, historySliceToolTurn(i)...)
911 }
912 sess, path := saveHistorySliceSession(t, dir, "tool-heavy.jsonl", msgs)
913 newLiveHistoryTab(t, app, dir, path, sess)
914
915 pages := collectHistorySlicePages(t, app, "test", HistorySliceRequest{Turns: 12, Entries: 120, Bytes: 512 << 10})
916 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
917 }
918
919 func TestHistorySlice46Turn625MessagesCold(t *testing.T) {
920 app := historySliceTestApp(t)
921 tab := newColdHistoryTab(t, app)
922 dir := tabSessionDir(tab)
923 // 1 system + 45 turns × 10 messages + one 174-message turn = 625.
924 msgs := []provider.Message{{Role: provider.RoleSystem, Content: "sys"}}
925 for i := range 45 {
926 msgs = append(msgs, historySliceUser(i, fmt.Sprintf("q%d", i)))
927 for j := range 4 {
928 id := fmt.Sprintf("call-%d-%d", i, j)
929 msgs = append(msgs,
930 provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "bash", Arguments: `{"command":"x"}`}}, CreatedAt: 1_700_000_000_000 + int64(i)},
931 provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "bash", Content: "ok", CreatedAt: 1_700_000_000_000 + int64(i)},
932 )
933 }
934 msgs = append(msgs, historySliceAssistant(i, fmt.Sprintf("a%d", i)))
935 }
936 last := []provider.Message{historySliceUser(45, "q45")}
937 for j := range 86 {
938 id := fmt.Sprintf("call-45-%d", j)
939 last = append(last,
940 provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "bash", Arguments: `{"command":"y"}`}}, CreatedAt: 1_700_000_000_045},
941 provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "bash", Content: "ok", CreatedAt: 1_700_000_000_045},
942 )
943 }
944 last = append(last, historySliceAssistant(45, "a45")) // 174 messages
945 msgs = append(msgs, last...)
946 if len(msgs) != 625 {
947 t.Fatalf("fixture = %d messages, want 625", len(msgs))
948 }
949 _, path := saveHistorySliceSession(t, dir, "46-turns.jsonl", msgs)
950 tab.SessionPath = path
951
952 pages := collectHistorySlicePages(t, app, "cold", HistorySliceRequest{Turns: 7, Entries: 90})
953 if pages[0].TotalTurns != 46 {
954 t.Fatalf("TotalTurns = %d, want 46", pages[0].TotalTurns)
955 }
956 assertPagesMatchReference(t, pages, referenceHistoryRows(t, dir, path))
957 }
958
959 // --- JSON contract ----------------------------------------------------------
960
961 func TestHistorySliceArraysNeverNull(t *testing.T) {
962 encoded, err := json.Marshal(HistorySlice{})
963 if err != nil {
964 t.Fatal(err)
965 }
966 if !strings.Contains(string(encoded), `"entries":[]`) {
967 t.Fatalf("zero HistorySlice must encode entries as []: %s", encoded)
968 }
969 encoded, err = json.Marshal(staleHistorySlice(3, true, "digest"))
970 if err != nil {
971 t.Fatal(err)
972 }
973 if !strings.Contains(string(encoded), `"entries":[]`) {
974 t.Fatalf("stale HistorySlice must encode entries as []: %s", encoded)
975 }
976 encoded, err = json.Marshal(HistoryEntry{Refs: []HistoryContentRef{}})
977 if err != nil {
978 t.Fatal(err)
979 }
980 if !strings.Contains(string(encoded), `"refs":[]`) {
981 t.Fatalf("HistoryEntry must encode refs as []: %s", encoded)
982 }
983 }
984
985 // --- chunk helpers ----------------------------------------------------------
986
987 func TestHistoryContentChunksRuneAligned(t *testing.T) {
988 if got := historyContentChunkCount(""); got != 1 {
989 t.Fatalf("empty string chunks = %d, want 1", got)
990 }
991 if data, chunks := historyContentChunkAt("", 0); data != "" || chunks != 1 {
992 t.Fatalf("empty chunk = %q/%d", data, chunks)
993 }
994 small := "hello 世界"
995 if got := historyContentChunkCount(small); got != 1 {
996 t.Fatalf("small string chunks = %d, want 1", got)
997 }
998 // Build a string whose 256KiB boundary falls inside a 4-byte rune.
999 unit := "🙂" // 4 bytes
1000 big := strings.Repeat(unit, (historyContentChunkBytes/4)+10)
1001 if chunks := historyContentChunkCount(big); chunks != 2 {
1002 t.Fatalf("chunks = %d, want 2", chunks)
1003 }
1004 first, _ := historyContentChunkAt(big, 0)
1005 second, _ := historyContentChunkAt(big, 1)
1006 if !utf8.ValidString(first) || !utf8.ValidString(second) {
1007 t.Fatal("chunks are not valid UTF-8")
1008 }
1009 if first+second != big {
1010 t.Fatal("chunk split lost content")
1011 }
1012 if len(first) > historyContentChunkBytes {
1013 t.Fatalf("first chunk = %d bytes, over budget", len(first))
1014 }
1015 // Out-of-range chunk index returns empty data with the total count.
1016 if data, chunks := historyContentChunkAt(big, 5); data != "" || chunks != 2 {
1017 t.Fatalf("out-of-range chunk = %q/%d", data, chunks)
1018 }
1019 }
1020
1021 // --- concurrency ------------------------------------------------------------
1022
1022 lines GO