| 1 | package stats |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "reasonix/internal/billing" |
| 15 | "reasonix/internal/event" |
| 16 | "reasonix/internal/filelock" |
| 17 | "reasonix/internal/provider" |
| 18 | ) |
| 19 | |
| 20 | func flushRecorder(t *testing.T, recorder *Recorder) { |
| 21 | t.Helper() |
| 22 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| 23 | defer cancel() |
| 24 | if err := recorder.Flush(ctx); err != nil { |
| 25 | t.Fatalf("flush recorder: %v", err) |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | func TestRecorderWritesDailyFile(t *testing.T) { |
| 30 | dir := t.TempDir() |
| 31 | inner := &spySink{} |
| 32 | r := NewRecorder(inner, dir, "desktop") |
| 33 | |
| 34 | r.Emit(usageEvent("deepseek/deepseek-v4-flash", 100, 50, 10, 20, 30, 150)) |
| 35 | r.Emit(usageEvent("deepseek/deepseek-v4-pro", 200, 100, 0, 0, 0, 300)) |
| 36 | r.Emit(turnEvent()) |
| 37 | flushRecorder(t, r) |
| 38 | |
| 39 | // The daily file must exist with three lines (2 usage + 1 turn marker). |
| 40 | files := dailyJSONLFiles(t, dir) |
| 41 | if len(files) != 1 { |
| 42 | t.Fatalf("want 1 daily file, got %d", len(files)) |
| 43 | } |
| 44 | data, err := os.ReadFile(filepath.Join(dir, files[0].Name())) |
| 45 | if err != nil { |
| 46 | t.Fatalf("read file: %v", err) |
| 47 | } |
| 48 | lines := 0 |
| 49 | for _, b := range data { |
| 50 | if b == '\n' { |
| 51 | lines++ |
| 52 | } |
| 53 | } |
| 54 | if lines != 3 { |
| 55 | t.Fatalf("want 3 lines, got %d", lines) |
| 56 | } |
| 57 | // Forwarding must be untouched. |
| 58 | if len(inner.events) != 3 { |
| 59 | t.Fatalf("want 3 forwarded events, got %d", len(inner.events)) |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | func TestRecorderPersistsRateBandAndRatedAt(t *testing.T) { |
| 64 | dir := t.TempDir() |
| 65 | r := NewRecorder(&spySink{}, dir, "desktop") |
| 66 | e := usageEvent("deepseek/deepseek-v4-pro", 100, 50, 0, 100, 0, 150) |
| 67 | e.CostQuote = &billing.CostQuote{ |
| 68 | Original: billing.Money{Amount: "0.00135", Currency: "CNY"}, |
| 69 | Estimated: true, CostComplete: true, DisplayComplete: true, Complete: true, |
| 70 | RateBand: billing.RateBandPeak, RatedAt: "2026-08-17T01:00:00Z", |
| 71 | } |
| 72 | r.Emit(e) |
| 73 | flushRecorder(t, r) |
| 74 | |
| 75 | files := dailyJSONLFiles(t, dir) |
| 76 | data, err := os.ReadFile(filepath.Join(dir, files[0].Name())) |
| 77 | if err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | var got map[string]any |
| 81 | if err := json.Unmarshal([]byte(strings.TrimSpace(string(data))), &got); err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | if got["rate_band"] != billing.RateBandPeak || got["rated_at"] != "2026-08-17T01:00:00Z" { |
| 85 | t.Fatalf("scheduled stats fields missing: %s", data) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestRecorderCountsMergedProviderRequests(t *testing.T) { |
| 90 | dir := t.TempDir() |
| 91 | r := NewRecorder(&spySink{}, dir, "desktop") |
| 92 | e := usageEvent("deepseek/deepseek-v4-pro", 100, 50, 10, 0, 100, 150) |
| 93 | e.Usage.RequestCount = 2 |
| 94 | r.Emit(e) |
| 95 | flushRecorder(t, r) |
| 96 | |
| 97 | day := dayStart(time.Now()) |
| 98 | got, err := r.writer.Query(SourceFilter{From: day, To: day}) |
| 99 | if err != nil { |
| 100 | t.Fatalf("query: %v", err) |
| 101 | } |
| 102 | if got.Requests != 2 || len(got.Daily) != 1 || got.Daily[0].Requests != 2 { |
| 103 | t.Fatalf("merged requests = total %d daily %+v, want 2", got.Requests, got.Daily) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func TestRecorderCapturesGuardianUsageAndPreservesProtocolAudit(t *testing.T) { |
| 108 | dir := t.TempDir() |
| 109 | inner := &auditSpySink{} |
| 110 | r := NewRecorder(inner, dir, "desktop") |
| 111 | r.Emit(event.Event{ |
| 112 | Kind: event.GuardianAssessment, |
| 113 | ModelRef: "deepseek/deepseek-v4-flash", |
| 114 | Guardian: event.GuardianResult{Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}}, |
| 115 | }) |
| 116 | event.RecordProtocolRecovery(r, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningRetryRecovered}) |
| 117 | flushRecorder(t, r) |
| 118 | |
| 119 | day := dayStart(time.Now()) |
| 120 | got, err := r.writer.Query(SourceFilter{From: day, To: day}) |
| 121 | if err != nil { |
| 122 | t.Fatalf("query: %v", err) |
| 123 | } |
| 124 | if got.Tokens != 15 || got.TopModel != "deepseek/deepseek-v4-flash" { |
| 125 | t.Fatalf("guardian usage = %+v", got) |
| 126 | } |
| 127 | if len(inner.protocol) != 1 || inner.protocol[0].Kind != event.ProtocolRecoveryMissingReasoningRetryRecovered { |
| 128 | t.Fatalf("protocol audit was not forwarded: %+v", inner.protocol) |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | func TestRecorderSkipsZeroUsage(t *testing.T) { |
| 133 | dir := t.TempDir() |
| 134 | r := NewRecorder(&spySink{}, dir, "desktop") |
| 135 | r.Emit(usageEvent("m", 0, 0, 0, 0, 0, 0)) // TotalTokens <= 0 -> skipped |
| 136 | r.Emit(turnEvent()) |
| 137 | flushRecorder(t, r) |
| 138 | files := dailyJSONLFiles(t, dir) |
| 139 | if len(files) != 1 { |
| 140 | t.Fatalf("want 1 file (turn only), got %d", len(files)) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func TestRecorderPersistsRequestOnlyFailureWithoutForwardingReceipt(t *testing.T) { |
| 145 | dir := t.TempDir() |
| 146 | inner := &spySink{} |
| 147 | r := NewRecorder(inner, dir, "desktop") |
| 148 | r.Emit(event.Event{ |
| 149 | Kind: event.Usage, |
| 150 | ModelRef: "deepseek/deepseek-v4-pro", |
| 151 | Usage: &provider.Usage{RequestCount: 3}, |
| 152 | }) |
| 153 | flushRecorder(t, r) |
| 154 | |
| 155 | day := dayStart(time.Now()) |
| 156 | got, err := r.writer.Query(SourceFilter{From: day, To: day}) |
| 157 | if err != nil { |
| 158 | t.Fatalf("query: %v", err) |
| 159 | } |
| 160 | if got.Requests != 3 || got.Tokens != 0 || got.ActiveDays != 1 { |
| 161 | t.Fatalf("request-only totals = %+v, want requests=3 tokens=0 activeDays=1", got) |
| 162 | } |
| 163 | if len(got.Models) != 0 || len(got.Providers) != 0 { |
| 164 | t.Fatalf("request-only failure created token distribution rows: models=%+v providers=%+v", got.Models, got.Providers) |
| 165 | } |
| 166 | if len(inner.events) != 0 { |
| 167 | t.Fatalf("request-only usage forwarded %d zero-token receipts", len(inner.events)) |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | func TestRecorderNeverWaitsForStatsFileLock(t *testing.T) { |
| 172 | dir := t.TempDir() |
| 173 | release, err := filelock.Acquire(context.Background(), filepath.Join(dir, ".append.lock")) |
| 174 | if err != nil { |
| 175 | t.Fatalf("hold stats lock: %v", err) |
| 176 | } |
| 177 | locked := true |
| 178 | defer func() { |
| 179 | if locked { |
| 180 | release() |
| 181 | } |
| 182 | }() |
| 183 | |
| 184 | inner := &spySink{} |
| 185 | recorder := NewRecorder(inner, dir, "desktop") |
| 186 | emitted := make(chan struct{}) |
| 187 | go func() { |
| 188 | recorder.Emit(usageEvent("deepseek/model", 10, 4, 0, 0, 10, 14)) |
| 189 | close(emitted) |
| 190 | }() |
| 191 | |
| 192 | select { |
| 193 | case <-emitted: |
| 194 | case <-time.After(250 * time.Millisecond): |
| 195 | t.Fatal("stats file lock blocked event forwarding") |
| 196 | } |
| 197 | if len(inner.events) != 1 { |
| 198 | t.Fatalf("forwarded events = %d, want 1", len(inner.events)) |
| 199 | } |
| 200 | |
| 201 | release() |
| 202 | locked = false |
| 203 | flushRecorder(t, recorder) |
| 204 | result, err := recorder.writer.Query(SourceFilter{From: dayStart(time.Now()), To: dayStart(time.Now())}) |
| 205 | if err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | if result.Tokens != 14 { |
| 209 | t.Fatalf("tokens after lock release = %d, want 14", result.Tokens) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | func TestRecorderDisabledOnEmptyDir(t *testing.T) { |
| 214 | r := NewRecorder(&spySink{}, "", "desktop") |
| 215 | r.Emit(usageEvent("m", 1, 1, 0, 0, 0, 2)) |
| 216 | r.Emit(turnEvent()) |
| 217 | // No panic, nothing written — query on empty dir returns zeros. |
| 218 | got, err := r.writer.Query(SourceFilter{From: time.Now().Add(-24 * time.Hour), To: time.Now()}) |
| 219 | if err != nil { |
| 220 | t.Fatalf("query: %v", err) |
| 221 | } |
| 222 | if got.Tokens != 0 || got.Turns != 0 { |
| 223 | t.Fatalf("want zero stats, got %+v", got) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | func TestQueryAggregates(t *testing.T) { |
| 228 | dir := t.TempDir() |
| 229 | w := NewWriter(dir) |
| 230 | now := time.Now() |
| 231 | day := dayStart(now) |
| 232 | |
| 233 | // Two usage rows + one turn on "today", one usage row yesterday. |
| 234 | w.Append(record{Timestamp: day.Add(1 * time.Hour), ModelRef: "deepseek/deepseek-v4-flash", Source: "desktop", Total: 100, Prompt: 60, Completion: 40, CacheHit: 10, CacheMiss: 50}) |
| 235 | w.Append(record{Timestamp: day.Add(2 * time.Hour), ModelRef: "deepseek/deepseek-v4-pro", Source: "desktop", Total: 200, Prompt: 100, Completion: 100}) |
| 236 | w.Append(record{Timestamp: day.Add(3 * time.Hour), Source: "desktop", Turn: true}) |
| 237 | w.Append(record{Timestamp: day.AddDate(0, 0, -1), ModelRef: "zhipu/glm-5.2", Source: "cli", Total: 300}) |
| 238 | |
| 239 | got, err := w.Query(SourceFilter{From: day.AddDate(0, 0, -1), To: day}) |
| 240 | if err != nil { |
| 241 | t.Fatalf("query: %v", err) |
| 242 | } |
| 243 | if got.Tokens != 600 { |
| 244 | t.Fatalf("tokens: want 600, got %d", got.Tokens) |
| 245 | } |
| 246 | if got.Requests != 3 { |
| 247 | t.Fatalf("requests: want 3, got %d", got.Requests) |
| 248 | } |
| 249 | if got.Turns != 1 { |
| 250 | t.Fatalf("turns: want 1, got %d", got.Turns) |
| 251 | } |
| 252 | if got.CacheHit != 10 || got.CacheMiss != 50 { |
| 253 | t.Fatalf("cache: want hit=10 miss=50, got hit=%d miss=%d", got.CacheHit, got.CacheMiss) |
| 254 | } |
| 255 | if got.ActiveDays != 2 { |
| 256 | t.Fatalf("active days: want 2, got %d", got.ActiveDays) |
| 257 | } |
| 258 | if got.TopModel != "zhipu/glm-5.2" { |
| 259 | t.Fatalf("top model: want zhipu/glm-5.2 (300 tokens), got %q", got.TopModel) |
| 260 | } |
| 261 | if len(got.Daily) != 2 { |
| 262 | t.Fatalf("daily series: want 2 entries, got %d", len(got.Daily)) |
| 263 | } |
| 264 | // daysInRange walks from -> to, so Daily[0] is yesterday (glm, no cache) |
| 265 | // and Daily[1] is today (flash hit=10 miss=50 + pro no cache). |
| 266 | if got.Daily[0].CacheHit != 0 || got.Daily[0].CacheMiss != 0 { |
| 267 | t.Fatalf("yesterday cache: want 0/0, got hit=%d miss=%d", got.Daily[0].CacheHit, got.Daily[0].CacheMiss) |
| 268 | } |
| 269 | if got.Daily[1].CacheHit != 10 || got.Daily[1].CacheMiss != 50 { |
| 270 | t.Fatalf("today cache: want hit=10 miss=50, got hit=%d miss=%d", got.Daily[1].CacheHit, got.Daily[1].CacheMiss) |
| 271 | } |
| 272 | if len(got.Models) != 3 { |
| 273 | t.Fatalf("models: want 3, got %d", len(got.Models)) |
| 274 | } |
| 275 | // Providers: deepseek (100+200=300), zhipu (300) — tied, so find by name. |
| 276 | found := map[string]int64{} |
| 277 | for _, p := range got.Providers { |
| 278 | found[p.Provider] = p.Tokens |
| 279 | } |
| 280 | if found["deepseek"] != 300 || found["zhipu"] != 300 || len(found) != 2 { |
| 281 | t.Fatalf("providers: want deepseek=300 zhipu=300, got %+v", got.Providers) |
| 282 | } |
| 283 | // Percent on models sums to ~100 across 3 models: 200/600=33.3, 100/600=16.7, 300/600=50 |
| 284 | if got.Models[0].Percent <= 0 || got.Models[0].Percent > 100 { |
| 285 | t.Fatalf("model percent out of range: %+v", got.Models[0]) |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | func TestQuerySourceFilter(t *testing.T) { |
| 290 | dir := t.TempDir() |
| 291 | w := NewWriter(dir) |
| 292 | now := time.Now() |
| 293 | day := dayStart(now) |
| 294 | |
| 295 | w.Append(record{Timestamp: day, ModelRef: "m1", Source: "desktop", Total: 100}) |
| 296 | w.Append(record{Timestamp: day, ModelRef: "m2", Source: "cli", Total: 50}) |
| 297 | |
| 298 | got, err := w.Query(SourceFilter{From: day, To: day, Source: "cli"}) |
| 299 | if err != nil { |
| 300 | t.Fatalf("query: %v", err) |
| 301 | } |
| 302 | if got.Tokens != 50 { |
| 303 | t.Fatalf("cli-filtered tokens: want 50, got %d", got.Tokens) |
| 304 | } |
| 305 | if len(got.Models) != 1 || got.Models[0].Model != "m2" { |
| 306 | t.Fatalf("cli-filtered models: want [m2], got %+v", got.Models) |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | func TestQueryEmptyRange(t *testing.T) { |
| 311 | w := NewWriter(t.TempDir()) |
| 312 | now := time.Now() |
| 313 | got, err := w.Query(SourceFilter{From: now, To: now.Add(-24 * time.Hour)}) |
| 314 | if err != nil { |
| 315 | t.Fatalf("query: %v", err) |
| 316 | } |
| 317 | if got.Tokens != 0 || got.ActiveDays != 0 || len(got.Daily) != 0 { |
| 318 | t.Fatalf("want empty stats, got %+v", got) |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | func TestQueryDisabledWriterReturnsArrayContract(t *testing.T) { |
| 323 | now := time.Now() |
| 324 | got, err := NewWriter("").Query(SourceFilter{From: now, To: now}) |
| 325 | if err != nil { |
| 326 | t.Fatalf("query: %v", err) |
| 327 | } |
| 328 | if got.Daily == nil || got.Models == nil || got.Providers == nil { |
| 329 | t.Fatalf("array contract contains nil slices: %+v", got) |
| 330 | } |
| 331 | b, err := json.Marshal(got) |
| 332 | if err != nil { |
| 333 | t.Fatalf("marshal: %v", err) |
| 334 | } |
| 335 | var wire struct { |
| 336 | Daily json.RawMessage `json:"daily"` |
| 337 | Models json.RawMessage `json:"models"` |
| 338 | Providers json.RawMessage `json:"providers"` |
| 339 | } |
| 340 | if err := json.Unmarshal(b, &wire); err != nil { |
| 341 | t.Fatalf("unmarshal: %v", err) |
| 342 | } |
| 343 | if string(wire.Daily) != "[]" || string(wire.Models) != "[]" || string(wire.Providers) != "[]" { |
| 344 | t.Fatalf("empty arrays serialized incorrectly: %s", b) |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | func TestQueryTopProviderAggregatesAcrossModels(t *testing.T) { |
| 349 | dir := t.TempDir() |
| 350 | w := NewWriter(dir) |
| 351 | day := dayStart(time.Now()) |
| 352 | for _, rec := range []record{ |
| 353 | {Timestamp: day, ModelRef: "provider-a/model-1", Total: 60}, |
| 354 | {Timestamp: day, ModelRef: "provider-a/model-2", Total: 60}, |
| 355 | {Timestamp: day, ModelRef: "provider-b/model-1", Total: 100}, |
| 356 | } { |
| 357 | if err := w.Append(rec); err != nil { |
| 358 | t.Fatalf("append: %v", err) |
| 359 | } |
| 360 | } |
| 361 | got, err := w.Query(SourceFilter{From: day, To: day}) |
| 362 | if err != nil { |
| 363 | t.Fatalf("query: %v", err) |
| 364 | } |
| 365 | if got.TopModel != "provider-b/model-1" { |
| 366 | t.Fatalf("top model = %q, want provider-b/model-1", got.TopModel) |
| 367 | } |
| 368 | if got.TopProvider != "provider-a" { |
| 369 | t.Fatalf("top provider = %q, want provider-a", got.TopProvider) |
| 370 | } |
| 371 | } |
| 372 | |
| 373 | func TestDecodeRecordsSkipsMalformed(t *testing.T) { |
| 374 | // A torn or hand-edited line must not fail the whole day's read: it is |
| 375 | // skipped and the surrounding valid records still come through. |
| 376 | good := `{"ts":"2026-08-02T10:00:00+08:00","total":100}` + "\n" |
| 377 | bad := `{"ts":"2026-08-02T10:00:00+08:00","total":` + "\n" // truncated JSON |
| 378 | recs, err := decodeRecords(strings.NewReader(good + bad + bad + good)) |
| 379 | if err != nil { |
| 380 | t.Fatalf("decode: %v", err) |
| 381 | } |
| 382 | if len(recs) != 2 { |
| 383 | t.Fatalf("want 2 valid records, got %d", len(recs)) |
| 384 | } |
| 385 | for _, r := range recs { |
| 386 | if r.Total != 100 { |
| 387 | t.Fatalf("record total: want 100, got %d", r.Total) |
| 388 | } |
| 389 | } |
| 390 | } |
| 391 | |
| 392 | func TestAppendRepairsTornTrailingRecord(t *testing.T) { |
| 393 | dir := t.TempDir() |
| 394 | w := NewWriter(dir) |
| 395 | now := time.Now() |
| 396 | path := filepath.Join(dir, now.Format(dayLayout)+".jsonl") |
| 397 | if err := os.WriteFile(path, []byte(`{"ts":"2026-08-02T10:00:00+08:00","total":`), 0o600); err != nil { |
| 398 | t.Fatalf("seed torn record: %v", err) |
| 399 | } |
| 400 | if err := w.Append(record{Timestamp: now, ModelRef: "deepseek/deepseek-v4-flash", Total: 42}); err != nil { |
| 401 | t.Fatalf("append after torn record: %v", err) |
| 402 | } |
| 403 | recs, err := readDaily(dir, now.Format(dayLayout)) |
| 404 | if err != nil { |
| 405 | t.Fatalf("read daily: %v", err) |
| 406 | } |
| 407 | if len(recs) != 1 || recs[0].Total != 42 || recs[0].ModelRef != "deepseek/deepseek-v4-flash" { |
| 408 | t.Fatalf("recovered records = %+v", recs) |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | func TestConcurrentWritersAppendWholeRecords(t *testing.T) { |
| 413 | dir := t.TempDir() |
| 414 | now := time.Now() |
| 415 | const writers = 8 |
| 416 | const perWriter = 40 |
| 417 | var wg sync.WaitGroup |
| 418 | for i := range writers { |
| 419 | wg.Add(1) |
| 420 | go func(model int) { |
| 421 | defer wg.Done() |
| 422 | w := NewWriter(dir) |
| 423 | for range perWriter { |
| 424 | if err := w.Append(record{Timestamp: now, ModelRef: fmt.Sprintf("provider/model-%d", model), Total: 1}); err != nil { |
| 425 | t.Errorf("append: %v", err) |
| 426 | return |
| 427 | } |
| 428 | } |
| 429 | }(i) |
| 430 | } |
| 431 | wg.Wait() |
| 432 | |
| 433 | recs, err := readDaily(dir, now.Format(dayLayout)) |
| 434 | if err != nil { |
| 435 | t.Fatal(err) |
| 436 | } |
| 437 | if len(recs) != writers*perWriter { |
| 438 | t.Fatalf("records = %d, want %d", len(recs), writers*perWriter) |
| 439 | } |
| 440 | } |
| 441 | |
| 442 | // TestDailyTokensWireKeys guards the JSON contract the desktop panel reads: |
| 443 | // the hand-written frontend types use camelCase (byModel/byProvider), so a |
| 444 | // snake_case tag here silently yields undefined fields in DailyTrend and |
| 445 | // crashed the panel with "Cannot convert undefined or null to object". |
| 446 | func TestDailyTokensWireKeys(t *testing.T) { |
| 447 | d := DailyTokens{Day: "2026-08-02", Total: 150, ByModel: map[string]int64{"deepseek/x": 150}, Requests: 2, Turns: 1, CacheHit: 10, CacheMiss: 50} |
| 448 | b, err := json.Marshal(d) |
| 449 | if err != nil { |
| 450 | t.Fatalf("marshal: %v", err) |
| 451 | } |
| 452 | var keys map[string]any |
| 453 | if err := json.Unmarshal(b, &keys); err != nil { |
| 454 | t.Fatalf("unmarshal: %v", err) |
| 455 | } |
| 456 | for _, want := range []string{"day", "total", "byModel", "byProvider", "requests", "turns", "cacheHit", "cacheMiss"} { |
| 457 | if _, ok := keys[want]; !ok { |
| 458 | t.Fatalf("wire key %q missing from %s", want, b) |
| 459 | } |
| 460 | } |
| 461 | for _, bad := range []string{"by_model", "by_provider", "cache_hit", "cache_miss"} { |
| 462 | if _, ok := keys[bad]; ok { |
| 463 | t.Fatalf("legacy snake_case key %q still present in %s", bad, b) |
| 464 | } |
| 465 | } |
| 466 | } |
| 467 | |
| 468 | func TestProviderSplit(t *testing.T) { |
| 469 | if got := providerOf("deepseek/deepseek-v4-flash"); got != "deepseek" { |
| 470 | t.Fatalf("provider: want deepseek, got %q", got) |
| 471 | } |
| 472 | if got := providerOf("bare-model"); got != "default" { |
| 473 | t.Fatalf("bare model: want default, got %q", got) |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | // test helpers |
| 478 | |
| 479 | func dailyJSONLFiles(t *testing.T, dir string) []os.DirEntry { |
| 480 | t.Helper() |
| 481 | entries, err := os.ReadDir(dir) |
| 482 | if err != nil { |
| 483 | t.Fatalf("read dir: %v", err) |
| 484 | } |
| 485 | files := make([]os.DirEntry, 0, len(entries)) |
| 486 | for _, entry := range entries { |
| 487 | if strings.HasSuffix(entry.Name(), ".jsonl") { |
| 488 | files = append(files, entry) |
| 489 | } |
| 490 | } |
| 491 | return files |
| 492 | } |
| 493 | |
| 494 | type spySink struct{ events []event.Event } |
| 495 | |
| 496 | func (s *spySink) Emit(e event.Event) { s.events = append(s.events, e) } |
| 497 | |
| 498 | type auditSpySink struct { |
| 499 | events []event.Event |
| 500 | protocol []event.ProtocolRecoveryAudit |
| 501 | turns int |
| 502 | workspace []event.WorkspaceMutation |
| 503 | runBudgets []event.RunBudgetSample |
| 504 | } |
| 505 | |
| 506 | func (s *auditSpySink) Emit(e event.Event) { s.events = append(s.events, e) } |
| 507 | func (s *auditSpySink) RecordProtocolRecovery(a event.ProtocolRecoveryAudit) { |
| 508 | s.protocol = append(s.protocol, a) |
| 509 | } |
| 510 | func (s *auditSpySink) RecordTurnCompletion() { s.turns++ } |
| 511 | func (s *auditSpySink) RecordWorkspaceMutation(m event.WorkspaceMutation) { |
| 512 | s.workspace = append(s.workspace, m) |
| 513 | } |
| 514 | func (s *auditSpySink) RecordRunBudget(sample event.RunBudgetSample) { |
| 515 | s.runBudgets = append(s.runBudgets, sample) |
| 516 | } |
| 517 | |
| 518 | func TestRecorderForwardsHostCapabilities(t *testing.T) { |
| 519 | inner := &auditSpySink{} |
| 520 | r := NewRecorder(inner, t.TempDir(), "test") |
| 521 | |
| 522 | event.RecordTurnCompletion(r) |
| 523 | event.RecordWorkspaceMutation(r, event.WorkspaceMutation{ToolName: "write_file"}) |
| 524 | event.RecordRunBudget(r, event.RunBudgetSample{Currency: "USD"}) |
| 525 | flushRecorder(t, r) |
| 526 | |
| 527 | if inner.turns != 1 || len(inner.workspace) != 1 || len(inner.runBudgets) != 1 { |
| 528 | t.Fatalf("host capabilities not forwarded: turns=%d workspace=%d run_budget=%d", inner.turns, len(inner.workspace), len(inner.runBudgets)) |
| 529 | } |
| 530 | } |
| 531 | |
| 532 | func usageEvent(model string, prompt, completion, reasoning, hit, miss, total int) event.Event { |
| 533 | return event.Event{ |
| 534 | Kind: event.Usage, |
| 535 | ModelRef: model, |
| 536 | Usage: &provider.Usage{ |
| 537 | PromptTokens: prompt, |
| 538 | CompletionTokens: completion, |
| 539 | ReasoningTokens: reasoning, |
| 540 | CacheHitTokens: hit, |
| 541 | CacheMissTokens: miss, |
| 542 | TotalTokens: total, |
| 543 | }, |
| 544 | } |
| 545 | } |
| 546 | |
| 547 | func turnEvent() event.Event { return event.Event{Kind: event.TurnDone} } |
| 548 |