| 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 |