]> git.giacomolli.uk - onechat/blob - log_test.go
Show an input prompt while the agent is idle
[onechat] / log_test.go
1 package main
2
3 import (
4         "os"
5         "path/filepath"
6         "testing"
7         "time"
8
9         "github.com/stretchr/testify/require"
10 )
11
12 func TestLogReopenLoadsTheSameMessages(t *testing.T) {
13         root := t.TempDir()
14         clock := newTestClock()
15         log, err := openLog(t.Context(), root, clock.Now)
16         require.NoError(t, err)
17
18         var written []Message
19
20         for _, kind := range []string{"user", "reply", "tool", "echo", "work", "note"} {
21                 message, err := log.Append(kind, kind+" text ✓")
22                 require.NoError(t, err)
23
24                 written = append(written, message)
25         }
26
27         require.Equal(t, 5, written[5].ID)
28         require.Equal(t, len("note text ✓"), written[5].Size)
29         require.NoError(t, log.Close())
30
31         log, err = openLog(t.Context(), root, clock.Now)
32         require.NoError(t, err)
33
34         defer func() {
35                 require.NoError(t, log.Close())
36         }()
37
38         require.Equal(t, len(written), log.Len())
39
40         for _, message := range written {
41                 read, err := log.Message(message.ID)
42                 require.NoError(t, err)
43                 require.Equal(t, message, read)
44         }
45
46         message, err := log.Append("user", "after the reopen")
47         require.NoError(t, err)
48         require.Equal(t, 6, message.ID)
49 }
50
51 func TestLogBuildsAMissingIndexAgain(t *testing.T) {
52         root := t.TempDir()
53         clock := newTestClock()
54         log, err := openLog(t.Context(), root, clock.Now)
55         require.NoError(t, err)
56
57         for _, text := range []string{"a", "b", "c"} {
58                 _, err = log.Append("user", text)
59                 require.NoError(t, err)
60
61                 clock.now = clock.now.Add(24 * time.Hour)
62         }
63
64         require.NoError(t, log.Close())
65
66         // A missing index, and an index that a crash cut in a record.
67         for _, cut := range []func(string) error{os.Remove, logTruncateToOneAndAHalf} {
68                 require.NoError(t, cut(filepath.Join(root, "index", "main.idx")))
69
70                 log, err = openLog(t.Context(), root, clock.Now)
71                 require.NoError(t, err)
72                 require.Equal(t, 3, log.Len())
73
74                 message, err := log.Message(2)
75                 require.NoError(t, err)
76                 require.Equal(t, "c", message.Text)
77                 require.NoError(t, log.Close())
78         }
79 }
80
81 func TestLogWritesOneFilePerDay(t *testing.T) {
82         root := t.TempDir()
83         clock := newTestClock()
84         log, err := openLog(t.Context(), root, clock.Now)
85         require.NoError(t, err)
86
87         defer func() {
88                 require.NoError(t, log.Close())
89         }()
90
91         _, err = log.Append("user", "first day")
92         require.NoError(t, err)
93
94         clock.now = clock.now.Add(24 * time.Hour)
95         _, err = log.Append("user", "second day")
96         require.NoError(t, err)
97
98         first, err := os.ReadFile(filepath.Join(root, "main", "2026-01-01.jsonl"))
99         require.NoError(t, err)
100         require.JSONEq(t, `{"i":0,"kind":"user","text":"first day","size":9,"date":"2026-01-01T12:00:00Z"}`+"\n", string(first))
101
102         second, err := os.ReadFile(filepath.Join(root, "main", "2026-01-02.jsonl"))
103         require.NoError(t, err)
104         require.JSONEq(t, `{"i":1,"kind":"user","text":"second day","size":10,"date":"2026-01-02T12:00:00Z"}`+"\n", string(second))
105 }
106
107 func TestLogAllowsOneWriter(t *testing.T) {
108         root := t.TempDir()
109         clock := newTestClock()
110         log, err := openLog(t.Context(), root, clock.Now)
111         require.NoError(t, err)
112
113         _, err = openLog(t.Context(), root, clock.Now)
114         require.ErrorIs(t, err, errLocked)
115         require.NoError(t, log.Close())
116
117         log, err = openLog(t.Context(), root, clock.Now)
118         require.NoError(t, err)
119         require.NoError(t, log.Close())
120 }
121
122 func TestLogRejectsAnUnknownKind(t *testing.T) {
123         log, err := openLog(t.Context(), t.TempDir(), newTestClock().Now)
124         require.NoError(t, err)
125
126         defer func() {
127                 require.NoError(t, log.Close())
128         }()
129
130         _, err = log.Append("talk", "text")
131         require.ErrorContains(t, err, `unknown message kind "talk"`)
132         require.Equal(t, 0, log.Len())
133 }
134
135 func TestLogRejectsAGapInTheIDs(t *testing.T) {
136         root := t.TempDir()
137         require.NoError(t, os.MkdirAll(filepath.Join(root, "main"), 0o700))
138
139         line := `{"i":1,"kind":"user","text":"x","size":1,"date":"2026-01-01T12:00:00Z"}` + "\n"
140         require.NoError(t, os.WriteFile(filepath.Join(root, "main", "2026-01-01.jsonl"), []byte(line), 0o600))
141
142         _, err := openLog(t.Context(), root, newTestClock().Now)
143         require.ErrorContains(t, err, "expected message 0, found 1")
144 }
145
146 func TestLogCutsAnIncompleteLastLine(t *testing.T) {
147         root := t.TempDir()
148         clock := newTestClock()
149         log, err := openLog(t.Context(), root, clock.Now)
150         require.NoError(t, err)
151
152         _, err = log.Append("user", "whole")
153         require.NoError(t, err)
154         require.NoError(t, log.Close())
155
156         path := filepath.Join(root, "main", "2026-01-01.jsonl")
157         file, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0)
158         require.NoError(t, err)
159
160         _, err = file.WriteString(`{"i":1,"kind":"user","te`)
161         require.NoError(t, err)
162         require.NoError(t, file.Close())
163
164         log, err = openLog(t.Context(), root, clock.Now)
165         require.NoError(t, err)
166         require.Equal(t, 1, log.Len())
167
168         message, err := log.Append("user", "after the cut")
169         require.NoError(t, err)
170         require.Equal(t, 1, message.ID)
171         require.NoError(t, log.Close())
172
173         log, err = openLog(t.Context(), root, clock.Now)
174         require.NoError(t, err)
175
176         defer func() {
177                 require.NoError(t, log.Close())
178         }()
179
180         message, err = log.Message(1)
181         require.NoError(t, err)
182         require.Equal(t, "after the cut", message.Text)
183 }
184
185 func TestLogRejectsAnIncompleteLineBeforeTheLastDay(t *testing.T) {
186         root := t.TempDir()
187         require.NoError(t, os.MkdirAll(filepath.Join(root, "main"), 0o700))
188
189         first := `{"i":0,"kind":"user","text":"x","size":1,"date":"2026-01-01T12:00:00Z"}` + "\n" + `{"i":1,"kin`
190         second := `{"i":1,"kind":"user","text":"y","size":1,"date":"2026-01-02T12:00:00Z"}` + "\n"
191
192         require.NoError(t, os.WriteFile(filepath.Join(root, "main", "2026-01-01.jsonl"), []byte(first), 0o600))
193         require.NoError(t, os.WriteFile(filepath.Join(root, "main", "2026-01-02.jsonl"), []byte(second), 0o600))
194
195         _, err := openLog(t.Context(), root, newTestClock().Now)
196         require.ErrorContains(t, err, "ends in an incomplete line")
197 }
198
199 // logTruncateToOneAndAHalf cuts the index at path in its second record.
200 func logTruncateToOneAndAHalf(path string) error {
201         return os.Truncate(path, positionBytes+positionBytes/2)
202 }