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