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