17 "github.com/stretchr/testify/require"
21 // replayMessages is the length of the synthetic log.
22 replayMessages = 100000
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
29 func TestMemoryRestartLoadsTheSameView(t *testing.T) {
31 clock := newTestClock()
32 memory, err := openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
33 require.NoError(t, err)
35 memory.chat.high = 200
38 memory.cview.high = 80
41 _, err = memory.Append("user", fmt.Sprint(n))
42 require.NoError(t, err)
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())
52 memory, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
53 require.NoError(t, err)
56 require.NoError(t, memory.Close())
59 require.Equal(t, view, memory.View())
60 require.Equal(t, context, memory.Context(0, 100))
62 loaded, err := os.ReadFile(filepath.Join(root, "view.json"))
63 require.NoError(t, err)
64 require.Equal(t, saved, loaded)
67 func TestMemoryOpensAfterACrashBeforeTheViewSave(t *testing.T) {
69 clock := newTestClock()
70 memory, err := openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
71 require.NoError(t, err)
73 _, err = memory.Append("user", "first")
74 require.NoError(t, err)
76 saved, err := os.ReadFile(filepath.Join(root, "view.json"))
77 require.NoError(t, err)
79 _, err = memory.Append("user", "second")
80 require.NoError(t, err)
83 require.NoError(t, memory.Close())
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))
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())
94 func TestMemoryKeepsTheCompactionViewCoarser(t *testing.T) {
95 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer(), discardLog())
96 require.NoError(t, err)
99 require.NoError(t, memory.Close())
102 memory.chat.high = 200
103 memory.chat.low = 100
105 memory.cview.high = 80
106 memory.cview.low = 40
108 _, err = memory.Append("user", fmt.Sprint(n%10))
109 require.NoError(t, err)
112 for _, line := range memory.chat.lines {
113 outer := memory.cview.lines[coarse]
114 for nodeEnd(outer.level, outer.index) <= line.index<<line.level {
116 outer = memory.cview.lines[coarse]
119 require.GreaterOrEqual(t, outer.level, line.level, "message %d", n)
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)
131 require.NoError(t, memory.Close())
135 _, err = memory.Append("echo", memoryLongText(n))
136 require.NoError(t, err)
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
147 require.Equal(t, []NodeKey{{0, 0}, {0, 1}, {0, 2}, {0, 3}, {0, 4}, {0, 5}, {0, 6}, {0, 7}}, running)
149 require.Eventually(t, func() bool {
150 return summarizer.Active() == buildMax
151 }, 10*time.Second, time.Millisecond)
153 close(summarizer.gate)
154 require.NoError(t, memory.Wait(t.Context()))
155 require.Equal(t, buildMax, summarizer.peak)
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)
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)
171 require.NoError(t, memory.Close())
174 _, err = memory.Append("echo", memoryLongText(0))
175 require.NoError(t, err)
176 require.Contains(t, memory.View(), viewPending)
178 settled := make(chan error, 1)
180 settled <- memory.Settle(t.Context())
184 case err = <-settled:
185 require.Fail(t, "Settle returned before the build", "error: %v", err)
186 case <-time.After(50 * time.Millisecond):
189 close(summarizer.gate)
190 require.NoError(t, <-settled)
191 require.NotContains(t, memory.View(), viewPending)
194 func TestMemorySettleFailsWhileABuildFailed(t *testing.T) {
195 summarizer := newFakeSummarizer()
196 summarizer.SetFailing(true)
198 logs := &SyncBuffer{}
199 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer, daemonTestLogger(logs))
200 require.NoError(t, err)
203 require.NoError(t, memory.Close())
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())
211 summarizer.SetFailing(false)
213 _, err = memory.Append("user", "retry")
214 require.NoError(t, err)
215 require.NoError(t, memory.Settle(t.Context()))
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)
225 require.NoError(t, memory.Close())
228 _, err = memory.Append("echo", memoryLongText(0))
229 require.NoError(t, err)
231 ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
234 require.ErrorIs(t, memory.Settle(ctx), context.DeadlineExceeded)
237 func TestMemoryStartsALeafAfterFewerThanEightUnbuilt(t *testing.T) {
238 summarizer := newFakeSummarizer()
239 summarizer.SetFailing(true)
241 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer, discardLog())
242 require.NoError(t, err)
245 require.NoError(t, memory.Close())
249 _, err = memory.Append("echo", memoryLongText(n))
250 require.NoError(t, err)
253 require.NoError(t, memory.Wait(t.Context()))
255 for _, task := range summarizer.Tasks() {
256 require.Less(t, task.Index, 8)
259 require.False(t, treeIsBuilt(t, memory.tree, 0, 8))
260 summarizer.SetFailing(false)
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))
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)
275 require.NoError(t, memory.Close())
278 _, err = memory.Append("echo", memoryLongText(0))
279 require.NoError(t, err)
280 require.NoError(t, memory.Wait(t.Context()))
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)
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))
295 func TestMemoryRestartRestoresTheQueue(t *testing.T) {
297 clock := newTestClock()
298 summarizer := newFakeSummarizer()
299 summarizer.SetFailing(true)
301 memory, err := openMemory(t.Context(), root, clock.Now, summarizer, discardLog())
302 require.NoError(t, err)
305 _, err = memory.Append("echo", memoryLongText(n))
306 require.NoError(t, err)
309 require.NoError(t, memory.Wait(t.Context()))
310 require.NoError(t, memory.Close())
312 memory, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
313 require.NoError(t, err)
316 require.NoError(t, memory.Close())
319 require.NoError(t, memory.Wait(t.Context()))
320 require.True(t, treeIsBuilt(t, memory.tree, 2, 0))
323 func TestMemoryBuildsFreeParents(t *testing.T) {
324 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer(), discardLog())
325 require.NoError(t, err)
328 require.NoError(t, memory.Close())
331 for _, text := range []string{"a", "b", "c", "d"} {
332 _, err = memory.Append("user", text)
333 require.NoError(t, err)
336 node := treeReadNode(t, memory.tree, 2, 0)
337 require.Equal(t, "user: a\nuser: b\nuser: c\nuser: d", node.Text)
340 func TestMemoryKeepsALongMessageOutOfTheView(t *testing.T) {
341 summarizer := newFakeSummarizer()
342 summarizer.SetFailing(true)
344 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, summarizer, discardLog())
345 require.NoError(t, err)
348 require.NoError(t, memory.Close())
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())
356 summarizer.SetFailing(false)
358 _, err = memory.Append("user", "again")
359 require.NoError(t, err)
360 require.NoError(t, memory.Wait(t.Context()))
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())
367 func TestMemoryCoversMessagesAfterTheSavedView(t *testing.T) {
369 clock := newTestClock()
370 memory, err := openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
371 require.NoError(t, err)
373 _, err = memory.Append("user", "a")
374 require.NoError(t, err)
376 _, err = memory.log.Append("reply", "b")
377 require.NoError(t, err)
378 require.NoError(t, memory.Close())
380 memory, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
381 require.NoError(t, err)
384 require.NoError(t, memory.Close())
387 require.Equal(t, "<chat>\n0+1|user: a\n1+1|reply: b\n</chat>", memory.View())
390 func TestMemoryRefusesToRebuildAMissingView(t *testing.T) {
392 clock := newTestClock()
393 memory, err := openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
394 require.NoError(t, err)
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")))
401 _, err = openMemory(t.Context(), root, clock.Now, newFakeSummarizer(), discardLog())
402 require.ErrorIs(t, err, errMemoryNoView)
405 func TestQueuedAppendResumesWithoutADuplicate(t *testing.T) {
407 memory, err := openMemory(t.Context(), dir, newTestClock().Now, newFakeSummarizer(), discardLog())
408 require.NoError(t, err)
412 first, err := memory.AppendOnce("user", "Record the gate size.", nil, func(id int) error {
415 require.Equal(t, 0, memory.log.Len())
419 require.NoError(t, err)
420 require.NoError(t, memory.Close())
422 memory, err = openMemory(t.Context(), dir, newTestClock().Now, newFakeSummarizer(), discardLog())
423 require.NoError(t, err)
426 require.NoError(t, memory.Close())
429 second, err := memory.AppendOnce("user", first.Text, saved, func(int) error {
430 require.FailNow(t, "a recorded message must not be prepared again")
434 require.NoError(t, err)
435 require.Equal(t, first, second)
436 require.Equal(t, 1, memory.Len())
439 func TestQueuedAppendRetriesAnUnwrittenPreparedMessage(t *testing.T) {
440 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer(), discardLog())
441 require.NoError(t, err)
444 require.NoError(t, memory.Close())
447 failure := errors.New("fake queue write failure")
451 _, err = memory.AppendOnce("work", "[Garden] Check the gate.", nil, func(id int) error {
456 require.ErrorIs(t, err, failure)
457 require.Equal(t, 0, memory.Len())
459 message, err := memory.AppendOnce("work", "[Garden] Check the gate.", saved, func(id int) error {
460 require.Equal(t, *saved, id)
464 require.NoError(t, err)
465 require.Equal(t, 0, message.ID)
466 require.Equal(t, 1, memory.Len())
469 func TestQueuedAppendRefusesAnInvalidReceipt(t *testing.T) {
470 memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer(), discardLog())
471 require.NoError(t, err)
474 require.NoError(t, memory.Close())
477 _, err = memory.Append("user", "Keep the original.")
478 require.NoError(t, err)
480 testCases := []struct {
486 name: "receipt points to different text",
488 cause: "does not match",
491 name: "receipt skips a log position",
493 cause: "beyond the log",
496 name: "receipt has a negative position",
498 cause: "read queued message",
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")
509 require.ErrorContains(t, err, tc.cause)
510 require.Equal(t, 1, memory.Len())
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) {
523 messages := replayMessages
525 messages = replayShortMessages
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)
535 require.NoError(t, memory.Close())
541 long := make([]bool, messages)
542 for id := range messages {
543 if id == messages/2 {
544 memory = replayRequireRestart(t, memory, root, clock)
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)
554 size, merged := replayRequireViews(t, memory, lines, long, id%1000 == 0)
560 require.NoError(t, memory.Wait(t.Context()))
563 memory = replayRequireRestart(t, memory, root, clock)
564 require.Equal(t, messages, memory.log.Len())
565 require.Positive(t, batches)
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())
574 var stats runtime.MemStats
576 runtime.ReadMemStats(&stats)
577 t.Logf("heap in use: %d KB", stats.HeapInuse/1024)
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)
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 {
590 require.NoError(t, memory.Wait(t.Context()))
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())
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()))
604 loaded, err := os.ReadFile(filepath.Join(root, "view.json"))
605 require.NoError(t, err)
606 require.Equal(t, saved, loaded)
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
614 func replayMessage(random *rand.Rand, id int) (string, string) {
615 roll := random.IntN(100)
618 return "user", replayText(id, 10+random.IntN(200))
620 return "reply", replayText(id, 100+random.IntN(1500))
622 return "note", replayText(id, 50+random.IntN(600))
624 return "work", replayText(id, 300+random.IntN(3000))
626 return "tool", replayText(id, 60+random.IntN(400))
628 return "echo", replayText(id, 30000+random.IntN(30000))
630 return "echo", replayText(id, 20+random.IntN(4000))
634 // replayViewLines returns the number of lines in the chat view of memory.
635 func replayViewLines(memory *Memory) int {
637 defer memory.mutex.Unlock()
639 return len(memory.chat.lines)
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) {
652 defer memory.mutex.Unlock()
654 size := replayAppendSize(memory.chat)
655 require.LessOrEqual(t, size, viewChatHigh)
656 require.LessOrEqual(t, replayAppendSize(memory.cview), viewCompactionHigh)
658 merged := len(memory.chat.lines) < lines
660 require.LessOrEqual(t, size, viewChatLow)
663 replayRequireOnlySummaries(t, memory, long, full)
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é "
674 return head + strings.Repeat(words, max(1, (size-len(head))/len(words)))
677 // replayAppendSize returns the size of view with its last line as the pending
679 func replayAppendSize(view *View) int {
680 last := view.lines[len(view.lines)-1]
681 return view.Size() - view.lineSize(last) + len(viewPending)
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) {
692 rendered := strings.Split(memory.chat.Render(), "\n")
693 require.Len(t, rendered, len(memory.chat.lines)+2)
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]
699 require.Equal(t, treeIsBuilt(t, memory.tree, line.level, line.index), built)
703 require.Equal(t, 0, line.level)
704 require.Equal(t, head+viewPending, rendered[n+1])
709 require.Equal(t, head+strings.ReplaceAll(node.Text, "\n", " "), rendered[n+1])
711 if line.level == 0 && long[line.index] {
712 require.True(t, strings.HasPrefix(node.Text, "summary "), "message %d", line.index)