]> git.giacomolli.uk - onechat/blob - memory_test.go
Keep scenario tests with their implementation
[onechat] / memory_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "maps"
8 "math/rand/v2"
9 "os"
10 "path/filepath"
11 "runtime"
12 "slices"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/stretchr/testify/require"
18 )
19
20 const (
21 // replayMessages is the length of the synthetic log.
22 replayMessages = 100000
23
24 // replayShortMessages is the length of the synthetic log with -short.
25 // It is long enough for batches of the chat view.
26 replayShortMessages = 5000
27 )
28
29 func TestMemoryRestartLoadsTheSameView(t *testing.T) {
30 root := t.TempDir()
31 clock := newTestClock()
32 memory, err := openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
33 require.NoError(t, err)
34
35 memory.chat.high = 200
36 memory.chat.low = 100
37
38 memory.cview.high = 80
39 memory.cview.low = 40
40 for n := range 100 {
41 _, err = memory.Append("user", fmt.Sprint(n))
42 require.NoError(t, err)
43 }
44
45 view := memory.View()
46 context := memory.Context(0, 100)
47 saved, err := os.ReadFile(filepath.Join(root, "view.json"))
48 require.NoError(t, err)
49 require.Less(t, len(memory.chat.lines), 100)
50 require.NoError(t, memory.Close())
51
52 memory, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
53 require.NoError(t, err)
54
55 defer func() {
56 require.NoError(t, memory.Close())
57 }()
58
59 require.Equal(t, view, memory.View())
60 require.Equal(t, context, memory.Context(0, 100))
61
62 loaded, err := os.ReadFile(filepath.Join(root, "view.json"))
63 require.NoError(t, err)
64 require.Equal(t, saved, loaded)
65 }
66
67 func TestMemoryOpensAfterACrashBeforeTheViewSave(t *testing.T) {
68 root := t.TempDir()
69 clock := newTestClock()
70 memory, err := openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
71 require.NoError(t, err)
72
73 _, err = memory.Append("user", "first")
74 require.NoError(t, err)
75
76 saved, err := os.ReadFile(filepath.Join(root, "view.json"))
77 require.NoError(t, err)
78
79 _, err = memory.Append("user", "second")
80 require.NoError(t, err)
81
82 view := memory.View()
83 require.NoError(t, memory.Close())
84
85 // The log and the tree hold message 1, but the view does not.
86 require.NoError(t, os.WriteFile(filepath.Join(root, "view.json"), saved, 0o600))
87
88 memory, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
89 require.NoError(t, err)
90 require.Equal(t, view, memory.View())
91 require.NoError(t, memory.Close())
92 }
93
94 func TestMemoryKeepsTheCompactionViewCoarser(t *testing.T) {
95 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer(), discardLog())
96 require.NoError(t, err)
97
98 defer func() {
99 require.NoError(t, memory.Close())
100 }()
101
102 memory.chat.high = 200
103 memory.chat.low = 100
104
105 memory.cview.high = 80
106 memory.cview.low = 40
107 for n := range 64 {
108 _, err = memory.Append("user", fmt.Sprint(n%10))
109 require.NoError(t, err)
110
111 coarse := 0
112 for _, line := range memory.chat.lines {
113 outer := memory.cview.lines[coarse]
114 for nodeEnd(outer.level, outer.index) <= line.index<<line.level {
115 coarse++
116 outer = memory.cview.lines[coarse]
117 }
118
119 require.GreaterOrEqual(t, outer.level, line.level, "message %d", n)
120 }
121 }
122 }
123
124 func TestMemoryRunsAtMostEightBuilds(t *testing.T) {
125 summarizer := newFakeSummarizer()
126 summarizer.gate = make(chan struct{})
127 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer, discardLog())
128 require.NoError(t, err)
129
130 defer func() {
131 require.NoError(t, memory.Close())
132 }()
133
134 for n := range 12 {
135 _, err = memory.Append("echo", memoryLongText(n))
136 require.NoError(t, err)
137 }
138
139 memory.mutex.Lock()
140
141 running := slices.Collect(maps.Keys(memory.queue.running))
142 memory.mutex.Unlock()
143 slices.SortFunc(running, func(a, b NodeKey) int {
144 return a.index - b.index
145 })
146 require.Equal(t, []NodeKey{{0, 0}, {0, 1}, {0, 2}, {0, 3}, {0, 4}, {0, 5}, {0, 6}, {0, 7}}, running)
147
148 require.Eventually(t, func() bool {
149 return summarizer.Active() == buildMax
150 }, 10*time.Second, time.Millisecond)
151
152 close(summarizer.gate)
153 require.NoError(t, memory.Wait(t.Context()))
154 require.Equal(t, buildMax, summarizer.peak)
155
156 for level, count := range []int{12, 6, 3, 1} {
157 for index := range count {
158 require.True(t, treeIsBuilt(t, memory.tree, level, index), "node (%d, %d)", level, index)
159 }
160 }
161 }
162
163 func TestMemorySettleWaitsForThePendingLines(t *testing.T) {
164 summarizer := newFakeSummarizer()
165 summarizer.gate = make(chan struct{})
166 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer, discardLog())
167 require.NoError(t, err)
168
169 defer func() {
170 require.NoError(t, memory.Close())
171 }()
172
173 _, err = memory.Append("echo", memoryLongText(0))
174 require.NoError(t, err)
175 require.Contains(t, memory.View(), viewPending)
176
177 settled := make(chan error, 1)
178 go func() {
179 settled <- memory.Settle(t.Context())
180 }()
181
182 select {
183 case err = <-settled:
184 require.Fail(t, "Settle returned before the build", "error: %v", err)
185 case <-time.After(50 * time.Millisecond):
186 }
187
188 close(summarizer.gate)
189 require.NoError(t, <-settled)
190 require.NotContains(t, memory.View(), viewPending)
191 }
192
193 func TestMemorySettleFailsWhileABuildFailed(t *testing.T) {
194 summarizer := newFakeSummarizer()
195 summarizer.SetFailing(true)
196
197 logs := &SyncBuffer{}
198 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer, daemonTestLogger(logs))
199 require.NoError(t, err)
200
201 defer func() {
202 require.NoError(t, memory.Close())
203 }()
204
205 _, err = memory.Append("echo", memoryLongText(0))
206 require.NoError(t, err)
207 require.ErrorIs(t, memory.Settle(t.Context()), errMemoryUnsettled)
208 require.Equal(t, "<4>summary failed level=0 index=0: fake failure\n", logs.String())
209
210 summarizer.SetFailing(false)
211
212 _, err = memory.Append("user", "retry")
213 require.NoError(t, err)
214 require.NoError(t, memory.Settle(t.Context()))
215 }
216
217 func TestMemorySettleEndsWithItsContext(t *testing.T) {
218 summarizer := newFakeSummarizer()
219 summarizer.gate = make(chan struct{})
220 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer, discardLog())
221 require.NoError(t, err)
222
223 defer func() {
224 require.NoError(t, memory.Close())
225 }()
226
227 _, err = memory.Append("echo", memoryLongText(0))
228 require.NoError(t, err)
229
230 ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
231 defer cancel()
232
233 require.ErrorIs(t, memory.Settle(ctx), context.DeadlineExceeded)
234 }
235
236 func TestMemoryStartsALeafAfterFewerThanEightUnbuilt(t *testing.T) {
237 summarizer := newFakeSummarizer()
238 summarizer.SetFailing(true)
239
240 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer, discardLog())
241 require.NoError(t, err)
242
243 defer func() {
244 require.NoError(t, memory.Close())
245 }()
246
247 for n := range 9 {
248 _, err = memory.Append("echo", memoryLongText(n))
249 require.NoError(t, err)
250 }
251
252 require.NoError(t, memory.Wait(t.Context()))
253
254 for _, task := range summarizer.Tasks() {
255 require.Less(t, task.Index, 8)
256 }
257
258 require.False(t, treeIsBuilt(t, memory.tree, 0, 8))
259 summarizer.SetFailing(false)
260
261 _, err = memory.Append("user", "retry")
262 require.NoError(t, err)
263 require.NoError(t, memory.Wait(t.Context()))
264 require.True(t, treeIsBuilt(t, memory.tree, 0, 8))
265 require.True(t, treeIsBuilt(t, memory.tree, 3, 0))
266 }
267
268 func TestMemoryMergesAfterBothHalves(t *testing.T) {
269 summarizer := newFakeSummarizer()
270 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer, discardLog())
271 require.NoError(t, err)
272
273 defer func() {
274 require.NoError(t, memory.Close())
275 }()
276
277 _, err = memory.Append("echo", memoryLongText(0))
278 require.NoError(t, err)
279 require.NoError(t, memory.Wait(t.Context()))
280
281 _, err = memory.Append("echo", memoryLongText(1))
282 require.NoError(t, err)
283 require.NoError(t, memory.Wait(t.Context()))
284 require.Len(t, summarizer.Tasks(), 3)
285
286 left := treeReadNode(t, memory.tree, 0, 0)
287 right := treeReadNode(t, memory.tree, 0, 1)
288 merge := summarizer.Tasks()[2]
289 require.Equal(t, []string{left.Text, right.Text}, merge.Input)
290 require.Equal(t, "<chat>\n0+1|"+left.Text+"\n1+1|"+right.Text+"\n</chat>", merge.Context)
291 require.True(t, treeIsBuilt(t, memory.tree, 1, 0))
292 }
293
294 func TestMemoryRestartRestoresTheQueue(t *testing.T) {
295 root := t.TempDir()
296 clock := newTestClock()
297 summarizer := newFakeSummarizer()
298 summarizer.SetFailing(true)
299
300 memory, err := openMemory(t.Context(), root, clock.Now, summarizer, discardLog())
301 require.NoError(t, err)
302
303 for n := range 4 {
304 _, err = memory.Append("echo", memoryLongText(n))
305 require.NoError(t, err)
306 }
307
308 require.NoError(t, memory.Wait(t.Context()))
309 require.NoError(t, memory.Close())
310
311 memory, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
312 require.NoError(t, err)
313
314 defer func() {
315 require.NoError(t, memory.Close())
316 }()
317
318 require.NoError(t, memory.Wait(t.Context()))
319 require.True(t, treeIsBuilt(t, memory.tree, 2, 0))
320 }
321
322 func TestMemoryBuildsFreeParents(t *testing.T) {
323 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer(), discardLog())
324 require.NoError(t, err)
325
326 defer func() {
327 require.NoError(t, memory.Close())
328 }()
329
330 for _, text := range []string{"a", "b", "c", "d"} {
331 _, err = memory.Append("user", text)
332 require.NoError(t, err)
333 }
334
335 node := treeReadNode(t, memory.tree, 2, 0)
336 require.Equal(t, "user: a\nuser: b\nuser: c\nuser: d", node.Text)
337 }
338
339 func TestMemoryKeepsALongMessageOutOfTheView(t *testing.T) {
340 summarizer := newFakeSummarizer()
341 summarizer.SetFailing(true)
342
343 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer, discardLog())
344 require.NoError(t, err)
345
346 defer func() {
347 require.NoError(t, memory.Close())
348 }()
349
350 _, err = memory.Append("echo", memoryLongText(0))
351 require.NoError(t, err)
352 require.NoError(t, memory.Wait(t.Context()))
353 require.Equal(t, "<chat>\n0+1|"+viewPending+"\n</chat>", memory.View())
354
355 summarizer.SetFailing(false)
356
357 _, err = memory.Append("user", "again")
358 require.NoError(t, err)
359 require.NoError(t, memory.Wait(t.Context()))
360
361 task, err := memory.task(NodeKey{0, 0})
362 require.NoError(t, err)
363 require.Equal(t, "<chat>\n0+1|"+fakeLine(task)+"\n1+1|user: again\n</chat>", memory.View())
364 }
365
366 func TestMemoryCoversMessagesAfterTheSavedView(t *testing.T) {
367 root := t.TempDir()
368 clock := newTestClock()
369 memory, err := openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
370 require.NoError(t, err)
371
372 _, err = memory.Append("user", "a")
373 require.NoError(t, err)
374
375 _, err = memory.log.Append("reply", "b")
376 require.NoError(t, err)
377 require.NoError(t, memory.Close())
378
379 memory, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
380 require.NoError(t, err)
381
382 defer func() {
383 require.NoError(t, memory.Close())
384 }()
385
386 require.Equal(t, "<chat>\n0+1|user: a\n1+1|reply: b\n</chat>", memory.View())
387 }
388
389 func TestMemoryRefusesToRebuildAMissingView(t *testing.T) {
390 root := t.TempDir()
391 clock := newTestClock()
392 memory, err := openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
393 require.NoError(t, err)
394
395 _, err = memory.Append("user", "a")
396 require.NoError(t, err)
397 require.NoError(t, memory.Close())
398 require.NoError(t, os.Remove(filepath.Join(root, "view.json")))
399
400 _, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
401 require.ErrorIs(t, err, errMemoryNoView)
402 }
403
404 func TestQueuedAppendResumesWithoutADuplicate(t *testing.T) {
405 dir := t.TempDir()
406 memory, err := openMemory(t.Context(), dir, newTestClock().Now, newFakeSummarizer(), discardLog())
407 require.NoError(t, err)
408
409 var saved *int
410
411 first, err := memory.AppendOnce("user", "Record the gate size.", nil, func(id int) error {
412 saved = &id
413
414 require.Equal(t, 0, memory.log.Len())
415
416 return nil
417 })
418 require.NoError(t, err)
419 require.NoError(t, memory.Close())
420
421 memory, err = openMemory(t.Context(), dir, newTestClock().Now, newFakeSummarizer(), discardLog())
422 require.NoError(t, err)
423
424 defer func() {
425 require.NoError(t, memory.Close())
426 }()
427
428 second, err := memory.AppendOnce("user", first.Text, saved, func(int) error {
429 require.FailNow(t, "a recorded message must not be prepared again")
430
431 return nil
432 })
433 require.NoError(t, err)
434 require.Equal(t, first, second)
435 require.Equal(t, 1, memory.Len())
436 }
437
438 func TestQueuedAppendRetriesAnUnwrittenPreparedMessage(t *testing.T) {
439 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer(), discardLog())
440 require.NoError(t, err)
441
442 defer func() {
443 require.NoError(t, memory.Close())
444 }()
445
446 failure := errors.New("fake queue write failure")
447
448 var saved *int
449
450 _, err = memory.AppendOnce("work", "[Garden] Check the gate.", nil, func(id int) error {
451 saved = &id
452
453 return failure
454 })
455 require.ErrorIs(t, err, failure)
456 require.Equal(t, 0, memory.Len())
457
458 message, err := memory.AppendOnce("work", "[Garden] Check the gate.", saved, func(id int) error {
459 require.Equal(t, *saved, id)
460
461 return nil
462 })
463 require.NoError(t, err)
464 require.Equal(t, 0, message.ID)
465 require.Equal(t, 1, memory.Len())
466 }
467
468 func TestQueuedAppendRefusesAnInvalidReceipt(t *testing.T) {
469 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer(), discardLog())
470 require.NoError(t, err)
471
472 defer func() {
473 require.NoError(t, memory.Close())
474 }()
475
476 _, err = memory.Append("user", "Keep the original.")
477 require.NoError(t, err)
478
479 testCases := []struct {
480 name string
481 id int
482 cause string
483 }{
484 {
485 name: "receipt points to different text",
486 id: 0,
487 cause: "does not match",
488 },
489 {
490 name: "receipt skips a log position",
491 id: 2,
492 cause: "beyond the log",
493 },
494 {
495 name: "receipt has a negative position",
496 id: -1,
497 cause: "read queued message",
498 },
499 }
500 for _, tc := range testCases {
501 t.Run(tc.name, func(t *testing.T) {
502 _, err := memory.AppendOnce("user", "Other text.", &tc.id, func(int) error {
503 require.FailNow(t, "an invalid receipt must not be prepared")
504
505 return nil
506 })
507 require.ErrorContains(t, err, tc.cause)
508 require.Equal(t, 1, memory.Len())
509 })
510 }
511 }
512
513 // TestReplaySyntheticLog runs a synthetic log through the memory core with the
514 // fake summarizer. After each message it waits for the builds, as a turn does.
515 // It checks that each view stays within its sawtooth, that no view line holds a
516 // whole long message or a part of an unbuilt one, and that a restart in the
517 // middle and at the end loads the same views byte for byte. With -short, the
518 // log is shorter, and the test still requires a batch of the chat view.
519 func TestReplaySyntheticLog(t *testing.T) {
520 start := time.Now()
521 messages := replayMessages
522 if testing.Short() {
523 messages = replayShortMessages
524 }
525
526 root := t.TempDir()
527 clock := newTestClock()
528 random := rand.New(rand.NewPCG(1, 2))
529 memory, err := openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
530 require.NoError(t, err)
531
532 defer func() {
533 require.NoError(t, memory.Close())
534 }()
535
536 batches := 0
537
538 low := viewChatHigh
539 long := make([]bool, messages)
540 for id := range messages {
541 if id == messages/2 {
542 memory = replayRequireRestart(t, memory, root, clock)
543 }
544
545 clock.now = clock.now.Add(time.Minute)
546 kind, text := replayMessage(random, id)
547 long[id] = len(kind)+len(": ")+len(text) > nodeBytes
548 lines := replayViewLines(memory)
549 _, err = memory.Append(kind, text)
550 require.NoError(t, err)
551
552 size, merged := replayRequireViews(t, memory, lines, long, id%1000 == 0)
553 if merged {
554 batches++
555 low = min(low, size)
556 }
557
558 require.NoError(t, memory.Wait(t.Context()))
559 }
560
561 memory = replayRequireRestart(t, memory, root, clock)
562 require.Equal(t, messages, memory.log.Len())
563 require.Positive(t, batches)
564
565 require.NoError(t, memory.Wait(t.Context()))
566 t.Logf("%d messages, %d nodes, %d batches in %s", messages, memory.tree.Count(), batches, time.Since(start).Round(time.Second))
567 t.Logf("chat view: %d lines, %d bytes, lowest after a batch %d bytes", len(memory.chat.lines), memory.chat.Size(), low)
568 t.Logf("compaction view: %d lines, %d bytes", len(memory.cview.lines), memory.cview.Size())
569
570 runtime.GC()
571
572 var stats runtime.MemStats
573
574 runtime.ReadMemStats(&stats)
575 t.Logf("heap in use: %d KB", stats.HeapInuse/1024)
576 }
577
578 // memoryLongText returns a text that is too long for its own node.
579 func memoryLongText(n int) string {
580 return fmt.Sprintf("long %d ", n) + strings.Repeat("x", nodeBytes)
581 }
582
583 // replayRequireRestart closes memory, opens it again, and checks that the views
584 // and view.json stay the same byte for byte.
585 func replayRequireRestart(t *testing.T, memory *Memory, root string, clock *TestClock) *Memory {
586 t.Helper()
587
588 require.NoError(t, memory.Wait(t.Context()))
589
590 view := memory.View()
591 context := memory.Context(0, memory.log.Len())
592 saved, err := os.ReadFile(filepath.Join(root, "view.json"))
593 require.NoError(t, err)
594 require.NoError(t, memory.Close())
595
596 memory, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
597 require.NoError(t, err)
598 require.NoError(t, memory.Wait(t.Context()))
599 require.Equal(t, view, memory.View())
600 require.Equal(t, context, memory.Context(0, memory.log.Len()))
601
602 loaded, err := os.ReadFile(filepath.Join(root, "view.json"))
603 require.NoError(t, err)
604 require.Equal(t, saved, loaded)
605
606 return memory
607 }
608
609 // replayMessage returns the kind and the text of message id. Most messages are
610 // tool calls and their output. Users write short lines. One output in a hundred
611 // is over 30 KB.
612 func replayMessage(random *rand.Rand, id int) (string, string) {
613 roll := random.IntN(100)
614 switch {
615 case roll < 5:
616 return "user", replayText(id, 10+random.IntN(200))
617 case roll < 10:
618 return "reply", replayText(id, 100+random.IntN(1500))
619 case roll < 12:
620 return "note", replayText(id, 50+random.IntN(600))
621 case roll < 13:
622 return "work", replayText(id, 300+random.IntN(3000))
623 case roll < 56:
624 return "tool", replayText(id, 60+random.IntN(400))
625 case roll < 57:
626 return "echo", replayText(id, 30000+random.IntN(30000))
627 default:
628 return "echo", replayText(id, 20+random.IntN(4000))
629 }
630 }
631
632 // replayViewLines returns the number of lines in the chat view of memory.
633 func replayViewLines(memory *Memory) int {
634 memory.mutex.Lock()
635 defer memory.mutex.Unlock()
636
637 return len(memory.chat.lines)
638 }
639
640 // replayRequireViews checks the views after a new message, and with full
641 // compares them with the tree. Each view is at most its high limit, and a batch
642 // merges the chat view to its low limit. The leaf of the new message can be
643 // built after the append, so the sizes count it as the pending mark, as the
644 // append did. It returns the size of the chat view and if a batch merged it,
645 // from the number of lines before the message.
646 func replayRequireViews(t *testing.T, memory *Memory, lines int, long []bool, full bool) (int, bool) {
647 t.Helper()
648
649 memory.mutex.Lock()
650 defer memory.mutex.Unlock()
651
652 size := replayAppendSize(memory.chat)
653 require.LessOrEqual(t, size, viewChatHigh)
654 require.LessOrEqual(t, replayAppendSize(memory.cview), viewCompactionHigh)
655
656 merged := len(memory.chat.lines) < lines
657 if merged {
658 require.LessOrEqual(t, size, viewChatLow)
659 }
660
661 replayRequireOnlySummaries(t, memory, long, full)
662
663 return size, merged
664 }
665
666 // replayText returns a text of about size bytes that starts with its message ID
667 // and holds some non-ASCII characters.
668 func replayText(id, size int) string {
669 head := fmt.Sprintf("m%d ", id)
670 words := "ação data line café "
671
672 return head + strings.Repeat(words, max(1, (size-len(head))/len(words)))
673 }
674
675 // replayAppendSize returns the size of view with its last line as the pending
676 // mark.
677 func replayAppendSize(view *View) int {
678 last := view.lines[len(view.lines)-1]
679 return view.Size() - view.lineSize(last) + len(viewPending)
680 }
681
682 // replayRequireOnlySummaries checks each line of the chat view as the model
683 // reads it. A built line holds its node, a long message has a summary of the
684 // fake summarizer and not its text, and an unbuilt line holds only the pending
685 // mark. With full, it also checks that the view keeps the node of each built
686 // line. The caller holds the lock of memory.
687 func replayRequireOnlySummaries(t *testing.T, memory *Memory, long []bool, full bool) {
688 t.Helper()
689
690 rendered := strings.Split(memory.chat.Render(), "\n")
691 require.Len(t, rendered, len(memory.chat.lines)+2)
692
693 for n, line := range memory.chat.lines {
694 head := fmt.Sprintf("%d+%d|", line.index<<line.level, 1<<line.level)
695 node, built := memory.chat.nodes[line]
696 if full {
697 require.Equal(t, treeIsBuilt(t, memory.tree, line.level, line.index), built)
698 }
699
700 if !built {
701 require.Equal(t, 0, line.level)
702 require.Equal(t, head+viewPending, rendered[n+1])
703
704 continue
705 }
706
707 require.Equal(t, head+strings.ReplaceAll(node.Text, "\n", " "), rendered[n+1])
708
709 if line.level == 0 && long[line.index] {
710 require.True(t, strings.HasPrefix(node.Text, "summary "), "message %d", line.index)
711 }
712 }
713 }