]> git.giacomolli.uk - onechat/blob - build_run_test.go
aef89225211dca1b4b908c0bfd4e93b786ffa202
[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 require.NoError(t, buildWriteReport(&report, appended, stop, records, sizes))
86 require.Contains(t, report.String(), "stopped early: ")
87 require.Contains(t, report.String(), "cached (80.0%)")
88 require.Contains(t, report.String(), "line bytes: min 400, median 400, mean 400, max 400, 0 over 512")
89 require.Contains(t, report.String(), "calls at once: at most ")
90 require.Contains(t, report.String(), "quota at the first call: primary-used-percent=")
91 }
92
93 func TestBuildRunStopsAtTheCallLimit(t *testing.T) {
94 server := newModelFake(t)
95 run := newTestBuildRun(t, server)
96 run.client.LimitCalls(5)
97
98 entries := make([]DatedMessage, 40)
99 for n := range entries {
100 entries[n] = DatedMessage{
101 Kind: "echo",
102 Text: strings.Repeat("long output ", 100),
103 }
104 }
105
106 _, stop := run.Run(t.Context(), entries)
107 require.NoError(t, run.memory.Close())
108 require.Equal(t, "call limit of 5 reached", stop)
109 require.Equal(t, 5, server.Calls())
110 }
111
112 func TestBuildRunWaitsOnlyBeforeUserMessages(t *testing.T) {
113 gate := make(chan struct{})
114 var mutex sync.Mutex
115 running, most := 0, 0
116 server := newModelFake(t)
117 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
118 mutex.Lock()
119 running++
120 most = max(most, running)
121 mutex.Unlock()
122
123 <-gate
124
125 mutex.Lock()
126 running--
127 mutex.Unlock()
128
129 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("a line")}, 100, 0, 5))
130 }
131
132 run := newTestBuildRun(t, server)
133 long := strings.Repeat("long output ", 100)
134 entries := []DatedMessage{
135 {
136 Kind: "echo",
137 Text: long,
138 },
139 {
140 Kind: "tool",
141 Text: long,
142 },
143 {
144 Kind: "user",
145 Text: "next",
146 },
147 {
148 Kind: "reply",
149 Text: long,
150 },
151 }
152
153 done := make(chan string)
154 go func() {
155 _, stop := run.Run(t.Context(), entries)
156 done <- stop
157 }()
158
159 require.Eventually(t, func() bool {
160 return server.Calls() == 2
161 }, 10*time.Second, time.Millisecond)
162
163 appended := run.memory.Len()
164 require.Equal(t, 2, appended)
165
166 close(gate)
167 require.Empty(t, <-done)
168 require.NoError(t, run.memory.Close())
169
170 mutex.Lock()
171 defer mutex.Unlock()
172
173 require.Equal(t, 2, most)
174 require.Equal(t, 3, server.Calls())
175 }
176
177 func TestReadUsageFileSortsByCallStart(t *testing.T) {
178 path := filepath.Join(t.TempDir(), "usage.jsonl")
179 usage := ModelUsageLog{
180 path: path,
181 }
182
183 for _, date := range []string{"2026-01-01T12:00:02Z", "2026-01-01T12:00:00.5Z", "2026-01-01T12:00:01.25Z"} {
184 require.NoError(t, usage.Write(ModelUsageRecord{
185 Date: date,
186 }))
187 }
188
189 records, err := buildReadUsage(path)
190 require.NoError(t, err)
191 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})
192 }
193
194 func TestTimeTextCountsTheCallsAtOnce(t *testing.T) {
195 start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
196 record := func(offset, seconds float64) ModelUsageRecord {
197 return ModelUsageRecord{
198 Date: start.Add(time.Duration(offset * float64(time.Second))).Format(time.RFC3339Nano),
199 Seconds: seconds,
200 }
201 }
202
203 text := buildTimeText([]ModelUsageRecord{record(0, 10), record(2, 4), record(3, 2), record(10, 10)})
204 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)
205 }
206
207 func newTestBuildRun(t *testing.T, server *ModelFake) *BuildRun {
208 t.Helper()
209
210 root := t.TempDir()
211 usagePath := filepath.Join(root, "usage.jsonl")
212 client := newModelClient(server.URL, ModelAPIKey(modelTestKey), "onechat-"+probeName, usagePath)
213 memory, err := openMemory(t.Context(), root, newTestClock().Now, newCompactor(client, probePrompt, defaultConfig().Compactor), discardLog())
214 require.NoError(t, err)
215
216 return &BuildRun{
217 memory: memory,
218 client: client,
219 usagePath: usagePath,
220 }
221 }