]> git.giacomolli.uk - onechat/blob - build_run_test.go
Give the root tools to start and message children
[onechat] / build_run_test.go
1 package main
2
3 import (
4         "net/http"
5         "path/filepath"
6         "strconv"
7         "strings"
8         "sync"
9         "testing"
10         "time"
11
12         "github.com/stretchr/testify/require"
13 )
14
15 func TestBuildRunStopsOnFailedCallsOrQuotaGrowth(t *testing.T) {
16         quota := func(primary, secondary string) ModelUsageRecord {
17                 return ModelUsageRecord{
18                         Quota: map[string]string{
19                                 "x-codex-primary-used-percent":   primary,
20                                 "x-codex-secondary-used-percent": secondary,
21                                 "x-codex-plan-type":              "plus",
22                         },
23                 }
24         }
25
26         testCases := []struct {
27                 name    string
28                 records []ModelUsageRecord
29                 want    string
30         }{
31                 {
32                         name:    "no calls",
33                         records: nil,
34                         want:    "",
35                 },
36                 {
37                         name:    "growth within the limit",
38                         records: []ModelUsageRecord{quota("10", "40"), quota("25", "41")},
39                         want:    "",
40                 },
41                 {
42                         name:    "growth over the limit",
43                         records: []ModelUsageRecord{quota("10", "40"), quota("12", "55.5")},
44                         want:    "x-codex-secondary-used-percent grew from 40 to 55.5",
45                 },
46                 {
47                         name: "failed call",
48                         records: []ModelUsageRecord{quota("10", "40"), {
49                                 Status: 400,
50                                 Error:  "model call: HTTP 400",
51                         }},
52                         want: "call failed: model call: HTTP 400",
53                 },
54         }
55
56         for _, tc := range testCases {
57                 t.Run(tc.name, func(t *testing.T) {
58                         require.Equal(t, tc.want, buildStop(tc.records))
59                 })
60         }
61 }
62
63 func TestBuildRunStopsWhenTheQuotaGrows(t *testing.T) {
64         server := newModelFake(t)
65         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
66                 w.Header().Set("X-Codex-Primary-Used-Percent", strconv.Itoa(10+call.number))
67                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(strings.Repeat("s", 400))}, 1000, 800, 50))
68         }
69
70         run := newTestBuildRun(t, server)
71         appended, stop := run.Run(t.Context(), probeLog(probeMessages))
72         sizes, err := run.memory.buildModelLines()
73         require.NoError(t, err)
74         require.NoError(t, run.memory.Close())
75
76         require.Regexp(t, `^x-codex-primary-used-percent grew from \d+ to \d+$`, stop)
77         require.Less(t, appended, probeMessages)
78         require.NotEmpty(t, sizes)
79
80         records, err := buildReadUsage(run.usagePath)
81         require.NoError(t, err)
82         require.Greater(t, len(records), int(buildQuotaLimit))
83
84         var report strings.Builder
85
86         require.NoError(t, buildWriteReport(&report, appended, stop, records, sizes))
87         require.Contains(t, report.String(), "stopped early: ")
88         require.Contains(t, report.String(), "cached (80.0%)")
89         require.Contains(t, report.String(), "line bytes: min 400, median 400, mean 400, max 400, 0 over 512")
90         require.Contains(t, report.String(), "calls at once: at most ")
91         require.Contains(t, report.String(), "quota at the first call: primary-used-percent=")
92 }
93
94 func TestBuildRunStopsAtTheCallLimit(t *testing.T) {
95         server := newModelFake(t)
96         run := newTestBuildRun(t, server)
97         run.client.LimitCalls(5)
98
99         entries := make([]DatedMessage, 40)
100         for n := range entries {
101                 entries[n] = DatedMessage{
102                         Kind: "echo",
103                         Text: strings.Repeat("long output ", 100),
104                 }
105         }
106
107         _, stop := run.Run(t.Context(), entries)
108         require.NoError(t, run.memory.Close())
109         require.Equal(t, "call limit of 5 reached", stop)
110         require.Equal(t, 5, server.Calls())
111 }
112
113 func TestBuildRunWaitsOnlyBeforeUserMessages(t *testing.T) {
114         gate := make(chan struct{})
115
116         var mutex sync.Mutex
117
118         running, most := 0, 0
119         server := newModelFake(t)
120         server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
121                 mutex.Lock()
122
123                 running++
124                 most = max(most, running)
125                 mutex.Unlock()
126
127                 <-gate
128
129                 mutex.Lock()
130
131                 running--
132                 mutex.Unlock()
133
134                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("a line")}, 100, 0, 5))
135         }
136
137         run := newTestBuildRun(t, server)
138         long := strings.Repeat("long output ", 100)
139         entries := []DatedMessage{
140                 {
141                         Kind: "echo",
142                         Text: long,
143                 },
144                 {
145                         Kind: "tool",
146                         Text: long,
147                 },
148                 {
149                         Kind: "user",
150                         Text: "next",
151                 },
152                 {
153                         Kind: "reply",
154                         Text: long,
155                 },
156         }
157
158         done := make(chan string)
159         go func() {
160                 _, stop := run.Run(t.Context(), entries)
161                 done <- stop
162         }()
163
164         require.Eventually(t, func() bool {
165                 return server.Calls() == 2
166         }, 10*time.Second, time.Millisecond)
167
168         appended := run.memory.Len()
169         require.Equal(t, 2, appended)
170
171         close(gate)
172         require.Empty(t, <-done)
173         require.NoError(t, run.memory.Close())
174
175         mutex.Lock()
176         defer mutex.Unlock()
177
178         require.Equal(t, 2, most)
179         require.Equal(t, 3, server.Calls())
180 }
181
182 func TestReadUsageFileSortsByCallStart(t *testing.T) {
183         path := filepath.Join(t.TempDir(), "usage.jsonl")
184         usage := ModelUsageLog{
185                 path: path,
186         }
187
188         for _, date := range []string{"2026-01-01T12:00:02Z", "2026-01-01T12:00:00.5Z", "2026-01-01T12:00:01.25Z"} {
189                 require.NoError(t, usage.Write(ModelUsageRecord{
190                         Date: date,
191                 }))
192         }
193
194         records, err := buildReadUsage(path)
195         require.NoError(t, err)
196         require.Equal(t, []string{"2026-01-01T12:00:00.5Z", "2026-01-01T12:00:01.25Z", "2026-01-01T12:00:02Z"}, []string{records[0].Date, records[1].Date, records[2].Date})
197 }
198
199 func TestTimeTextCountsTheCallsAtOnce(t *testing.T) {
200         start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
201         record := func(offset, seconds float64) ModelUsageRecord {
202                 return ModelUsageRecord{
203                         Date:    start.Add(time.Duration(offset * float64(time.Second))).Format(time.RFC3339Nano),
204                         Seconds: seconds,
205                 }
206         }
207
208         text := buildTimeText([]ModelUsageRecord{record(0, 10), record(2, 4), record(3, 2), record(10, 10)})
209         require.Equal(t, "time: 20 s in all, 12.0 calls per minute, 6.5 s per call (median 10.0, max 10.0), calls at once: at most 3, 1.3 on average", text)
210 }
211
212 func newTestBuildRun(t *testing.T, server *ModelFake) *BuildRun {
213         t.Helper()
214
215         root := t.TempDir()
216         usagePath := filepath.Join(root, "usage.jsonl")
217         client := newModelClient(server.URL, ModelAPIKey(modelTestKey), "onechat-"+probeName, usagePath)
218         memory, err := openMemory(t.Context(), root, newTestClock().Now, newCompactor(client, probePrompt, defaultConfig().Compactor, promptTools()), discardLog())
219         require.NoError(t, err)
220
221         return &BuildRun{
222                 memory:    memory,
223                 client:    client,
224                 usagePath: usagePath,
225         }
226 }