package main import ( "net/http" "path/filepath" "strconv" "strings" "sync" "testing" "time" "github.com/stretchr/testify/require" ) func TestBuildRunStopsOnFailedCallsOrQuotaGrowth(t *testing.T) { quota := func(primary, secondary string) ModelUsageRecord { return ModelUsageRecord{ Quota: map[string]string{ "x-codex-primary-used-percent": primary, "x-codex-secondary-used-percent": secondary, "x-codex-plan-type": "plus", }, } } testCases := []struct { name string records []ModelUsageRecord want string }{ { name: "no calls", records: nil, want: "", }, { name: "growth within the limit", records: []ModelUsageRecord{quota("10", "40"), quota("25", "41")}, want: "", }, { name: "growth over the limit", records: []ModelUsageRecord{quota("10", "40"), quota("12", "55.5")}, want: "x-codex-secondary-used-percent grew from 40 to 55.5", }, { name: "failed call", records: []ModelUsageRecord{quota("10", "40"), { Status: 400, Error: "model call: HTTP 400", }}, want: "call failed: model call: HTTP 400", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.want, buildStop(tc.records)) }) } } func TestBuildRunStopsWhenTheQuotaGrows(t *testing.T) { server := newModelFake(t) server.reply = func(w http.ResponseWriter, call ModelFakeCall) { w.Header().Set("X-Codex-Primary-Used-Percent", strconv.Itoa(10+call.number)) modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(strings.Repeat("s", 400))}, 1000, 800, 50)) } run := newTestBuildRun(t, server) appended, stop := run.Run(t.Context(), probeLog(probeMessages)) sizes, err := run.memory.buildModelLines() require.NoError(t, err) require.NoError(t, run.memory.Close()) require.Regexp(t, `^x-codex-primary-used-percent grew from \d+ to \d+$`, stop) require.Less(t, appended, probeMessages) require.NotEmpty(t, sizes) records, err := buildReadUsage(run.usagePath) require.NoError(t, err) require.Greater(t, len(records), int(buildQuotaLimit)) var report strings.Builder require.NoError(t, buildWriteReport(&report, appended, stop, records, sizes)) require.Contains(t, report.String(), "stopped early: ") require.Contains(t, report.String(), "cached (80.0%)") require.Contains(t, report.String(), "line bytes: min 400, median 400, mean 400, max 400, 0 over 512") require.Contains(t, report.String(), "calls at once: at most ") require.Contains(t, report.String(), "quota at the first call: primary-used-percent=") } func TestBuildRunStopsAtTheCallLimit(t *testing.T) { server := newModelFake(t) run := newTestBuildRun(t, server) run.client.LimitCalls(5) entries := make([]DatedMessage, 40) for n := range entries { entries[n] = DatedMessage{ Kind: "echo", Text: strings.Repeat("long output ", 100), } } _, stop := run.Run(t.Context(), entries) require.NoError(t, run.memory.Close()) require.Equal(t, "call limit of 5 reached", stop) require.Equal(t, 5, server.Calls()) } func TestBuildRunWaitsOnlyBeforeUserMessages(t *testing.T) { gate := make(chan struct{}) var mutex sync.Mutex running, most := 0, 0 server := newModelFake(t) server.reply = func(w http.ResponseWriter, _ ModelFakeCall) { mutex.Lock() running++ most = max(most, running) mutex.Unlock() <-gate mutex.Lock() running-- mutex.Unlock() modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("a line")}, 100, 0, 5)) } run := newTestBuildRun(t, server) long := strings.Repeat("long output ", 100) entries := []DatedMessage{ { Kind: "echo", Text: long, }, { Kind: "tool", Text: long, }, { Kind: "user", Text: "next", }, { Kind: "reply", Text: long, }, } done := make(chan string) go func() { _, stop := run.Run(t.Context(), entries) done <- stop }() require.Eventually(t, func() bool { return server.Calls() == 2 }, 10*time.Second, time.Millisecond) appended := run.memory.Len() require.Equal(t, 2, appended) close(gate) require.Empty(t, <-done) require.NoError(t, run.memory.Close()) mutex.Lock() defer mutex.Unlock() require.Equal(t, 2, most) require.Equal(t, 3, server.Calls()) } func TestReadUsageFileSortsByCallStart(t *testing.T) { path := filepath.Join(t.TempDir(), "usage.jsonl") usage := ModelUsageLog{ path: path, } for _, date := range []string{"2026-01-01T12:00:02Z", "2026-01-01T12:00:00.5Z", "2026-01-01T12:00:01.25Z"} { require.NoError(t, usage.Write(ModelUsageRecord{ Date: date, })) } records, err := buildReadUsage(path) require.NoError(t, err) 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}) } func TestTimeTextCountsTheCallsAtOnce(t *testing.T) { start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) record := func(offset, seconds float64) ModelUsageRecord { return ModelUsageRecord{ Date: start.Add(time.Duration(offset * float64(time.Second))).Format(time.RFC3339Nano), Seconds: seconds, } } text := buildTimeText([]ModelUsageRecord{record(0, 10), record(2, 4), record(3, 2), record(10, 10)}) 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) } func newTestBuildRun(t *testing.T, server *ModelFake) *BuildRun { t.Helper() root := t.TempDir() usagePath := filepath.Join(root, "usage.jsonl") client := newModelClient(server.URL, ModelAPIKey(modelTestKey), "onechat-"+probeName, usagePath) memory, err := openMemory(t.Context(), root, newTestClock().Now, newCompactor(client, probePrompt, defaultConfig().Compactor, promptTools()), discardLog()) require.NoError(t, err) return &BuildRun{ memory: memory, client: client, usagePath: usagePath, } }