返回 DeepSeek-Reasonix
tabs_telemetry_test.go
根目录 / desktop / tabs_telemetry_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/billing"
12 "reasonix/internal/config"
13 "reasonix/internal/control"
14 "reasonix/internal/event"
15 "reasonix/internal/provider"
16 "reasonix/internal/tool"
17 )
18
19 type usageProvider struct {
20 usage *provider.Usage
21 }
22
23 func (p usageProvider) Name() string { return "usage" }
24
25 func (p usageProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
26 ch := make(chan provider.Chunk, 2)
27 ch <- provider.Chunk{Type: provider.ChunkText, Text: "ok"}
28 ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: p.usage}
29 close(ch)
30 return ch, nil
31 }
32
33 func TestTelemetryLoadsLegacyReadFileArray(t *testing.T) {
34 path := filepath.Join(t.TempDir(), "session.jsonl.telemetry.json")
35 if err := os.WriteFile(path, []byte(`[{"path":"README.md","turn":2,"time":1000}]`), 0o644); err != nil {
36 t.Fatalf("write legacy telemetry: %v", err)
37 }
38
39 got := loadTelemetry(path)
40 if len(got.ReadFiles) != 1 || got.ReadFiles[0].Path != "README.md" {
41 t.Fatalf("legacy read files = %+v", got.ReadFiles)
42 }
43 if got.Usage.RequestCount != 0 {
44 t.Fatalf("legacy usage request count = %d, want 0", got.Usage.RequestCount)
45 }
46 }
47
48 func TestWorkspaceTabAggregatesSessionUsageTelemetry(t *testing.T) {
49 tab := &WorkspaceTab{}
50 start := time.Now().Add(-2 * time.Second).UnixMilli()
51 tab.recordTurnStarted(start)
52 tab.recordUsage(event.Event{
53 Usage: &provider.Usage{PromptTokens: 100, CompletionTokens: 40, TotalTokens: 140, CacheHitTokens: 70, CacheMissTokens: 30, ReasoningTokens: 10, RequestCount: 3, Estimated: true},
54 UsageSource: event.UsageSourceSubagent,
55 SessionHit: 70,
56 SessionMiss: 30,
57 Pricing: &provider.Pricing{CacheHit: 1, Input: 2, Output: 3, Currency: "¥"},
58 })
59 tab.recordTurnDone(start + 1500)
60
61 got := tab.telemetrySnapshot().Usage
62 if got.RequestCount != 3 || got.PromptTokens != 100 || got.CompletionTokens != 40 || got.TotalTokens != 140 || got.ReasoningTokens != 10 {
63 t.Fatalf("usage tokens = %+v", got)
64 }
65 if !got.Estimated || got.LastEstimated {
66 t.Fatalf("usage lost estimated marker: %+v", got)
67 }
68 if got.CacheHitTokens != 70 || got.CacheMissTokens != 30 {
69 t.Fatalf("cache tokens = hit %d miss %d", got.CacheHitTokens, got.CacheMissTokens)
70 }
71 if got.ElapsedMs != 1500 {
72 t.Fatalf("elapsed = %d, want 1500", got.ElapsedMs)
73 }
74 if got.SessionCost <= 0 || (got.SessionCurrency != "CNY" && got.SessionCurrency != "¥") {
75 t.Fatalf("cost = %f %q, want positive CNY", got.SessionCost, got.SessionCurrency)
76 }
77 if got.Sources[event.UsageSourceSubagent].SessionCost <= 0 || got.Sources[event.UsageSourceSubagent].RequestCount != 3 {
78 t.Fatalf("subagent source stats = %+v, want three costed requests", got.Sources[event.UsageSourceSubagent])
79 }
80 if !got.Sources[event.UsageSourceSubagent].Estimated {
81 t.Fatalf("subagent source lost estimated marker: %+v", got.Sources[event.UsageSourceSubagent])
82 }
83
84 app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
85 context := app.ContextUsageForTab("tab")
86 if context.SessionTokens != 140 {
87 t.Fatalf("context usage session tokens = %d, want 140", context.SessionTokens)
88 }
89 if context.SessionCost <= 0 || (context.SessionCurrency != "CNY" && context.SessionCurrency != "¥") {
90 t.Fatalf("context usage cost = %f %q, want positive CNY", context.SessionCost, context.SessionCurrency)
91 }
92 if context.CacheHitTokens != 70 || context.CacheMissTokens != 30 {
93 t.Fatalf("context usage cache tokens = hit %d miss %d, want 70/30", context.CacheHitTokens, context.CacheMissTokens)
94 }
95 if !context.Estimated {
96 t.Fatalf("context usage lost estimated marker: %+v", context)
97 }
98 if panel := app.ContextPanel("tab"); panel.TotalTokens != 140 || panel.Estimated || !panel.SessionEstimated {
99 t.Fatalf("context panel usage = %+v, want exact executor turn and estimated session", panel)
100 }
101 }
102
103 func TestTabMetaReportsActiveTurnStartedAt(t *testing.T) {
104 const startedAt = int64(1_723_456_789_000)
105 tab := &WorkspaceTab{ID: "tab", WorkspaceRoot: t.TempDir()}
106 tab.recordTurnStarted(startedAt)
107 app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
108
109 if got := app.tabMeta(tab, true).TurnStartedAt; got != startedAt {
110 t.Fatalf("turn started at = %d, want %d", got, startedAt)
111 }
112 }
113
114 func TestWorkspaceTabMarksEstimatedExecutorTurn(t *testing.T) {
115 tab := &WorkspaceTab{}
116 tab.recordUsage(event.Event{
117 Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15, Estimated: true},
118 UsageSource: event.UsageSourceExecutor,
119 })
120 got := tab.telemetrySnapshot().Usage
121 if !got.Estimated || !got.LastEstimated {
122 t.Fatalf("executor usage lost estimated marker: %+v", got)
123 }
124 app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
125 panel := app.ContextPanel("tab")
126 if !panel.SessionEstimated {
127 t.Fatalf("executor panel session usage lost estimated marker: %+v", panel)
128 }
129 }
130
131 func TestWorkspaceTabRepricesUsageWithoutMixingCurrencies(t *testing.T) {
132 // repriceUsage is now a display rebind: it must not recompute from new rates.
133 tab := &WorkspaceTab{}
134 tab.recordUsage(event.Event{
135 Usage: &provider.Usage{PromptTokens: 1_000_000, CompletionTokens: 100_000, TotalTokens: 1_100_000},
136 UsageSource: event.UsageSourceExecutor,
137 Pricing: &provider.Pricing{Input: 1, Output: 2, Currency: "CNY"},
138 })
139 before := tab.telemetrySnapshot().Usage.SessionCost
140 if ok := tab.repriceUsage(map[string]*provider.Pricing{
141 event.UsageSourceExecutor: {Input: 0.14, Output: 0.28, Currency: "USD"},
142 }); !ok {
143 t.Fatal("repriceUsage rejected display rebind")
144 }
145 got := tab.telemetrySnapshot().Usage
146 if got.SessionCost != before {
147 t.Fatalf("display rebind mutated occurrence cost: before=%f after=%f", before, got.SessionCost)
148 }
149 if got.CostLedger == nil || len(got.CostLedger.Entries) == 0 {
150 t.Fatal("expected cost ledger entries")
151 }
152 }
153
154 func TestRuntimeWalletHintDoesNotPersistTelemetry(t *testing.T) {
155 tab := &WorkspaceTab{}
156 tab.recordUsage(event.Event{
157 ModelRef: "deepseek/deepseek-v4-flash",
158 Usage: &provider.Usage{PromptTokens: 1_000_000, TotalTokens: 1_000_000},
159 Pricing: &provider.Pricing{CacheHit: 0.014, Input: 0.44, Output: 1.32, Currency: "USD"},
160 })
161 persisted := tab.telemetrySnapshot().Usage
162 if persisted.SessionCurrency != "USD" || persisted.SessionCost <= 0 {
163 t.Fatalf("persisted original = %+v", persisted)
164 }
165 if !tab.selectRuntimeDisplayCurrency("CNY") {
166 t.Fatal("runtime wallet hint rejected")
167 }
168 displayed := tab.displayTelemetrySnapshot().Usage
169 if displayed.SessionCurrency != "CNY" || displayed.SessionCostQuote == nil || displayed.SessionCostQuote.DisplayStatus != billing.DisplayStatusMatched {
170 t.Fatalf("runtime display = %+v", displayed)
171 }
172 // Persistence and a later session reload must remain on the occurrence-time
173 // original currency; the automatic wallet hint is process-local only.
174 persisted = tab.telemetrySnapshot().Usage
175 if persisted.SessionCurrency != "USD" || persisted.SessionCostQuote == displayed.SessionCostQuote {
176 t.Fatalf("runtime hint leaked into persisted telemetry = %+v", persisted)
177 }
178 }
179
180 func TestWorkspaceTabRepricesCacheWritesWithoutLosingBillingTier(t *testing.T) {
181 tab := &WorkspaceTab{}
182 tab.recordUsage(event.Event{
183 Usage: &provider.Usage{
184 PromptTokens: 500_000,
185 TotalTokens: 500_000,
186 CacheMissTokens: 500_000,
187 CacheWriteTokens: 100_000,
188 CacheWriteBilledTokens: 200_000,
189 },
190 UsageSource: event.UsageSourceExecutor,
191 Pricing: &provider.Pricing{Input: 2, Currency: "CNY"},
192 })
193 // 400K ordinary input units + 200K billed cache-write units at input=2 → 1.2 CNY.
194 got := tab.telemetrySnapshot().Usage
195 if got.SessionCost < 1.19 || got.SessionCost > 1.21 {
196 t.Fatalf("cache-write usage cost = %f, want ~1.2", got.SessionCost)
197 }
198 if got.CacheWriteTokens != 100_000 || got.CacheWriteBilledTokens != 200_000 {
199 t.Fatalf("persisted cache writes = raw %d billed %v", got.CacheWriteTokens, got.CacheWriteBilledTokens)
200 }
201 // Display rebind must preserve occurrence-time cost.
202 before := got.SessionCost
203 if ok := tab.repriceUsage(map[string]*provider.Pricing{
204 event.UsageSourceExecutor: {Input: 1, Currency: "USD"},
205 }); !ok {
206 t.Fatal("repriceUsage rejected cache-write usage")
207 }
208 got = tab.telemetrySnapshot().Usage
209 if got.SessionCost != before {
210 t.Fatalf("display rebind mutated cost: before=%f after=%f", before, got.SessionCost)
211 }
212 }
213
214 func TestRepriceTabUsageLeavesAutoCurrencyUnresolved(t *testing.T) {
215 isolateDesktopUserDirs(t)
216 cfg := config.Default()
217 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
218 t.Fatalf("save auto config: %v", err)
219 }
220 tab := &WorkspaceTab{WorkspaceRoot: t.TempDir(), model: "deepseek-flash/deepseek-v4-flash"}
221 tab.recordUsage(event.Event{
222 Usage: &provider.Usage{PromptTokens: 1_000_000, TotalTokens: 1_000_000},
223 UsageSource: event.UsageSourceExecutor,
224 Pricing: &provider.Pricing{Input: 0.14, Currency: "USD"},
225 })
226 before := tab.telemetrySnapshot().Usage.SessionCost
227 app := NewApp()
228 app.setDesktopLocale("zh-CN")
229
230 app.repriceTabUsageForCurrentCurrency(tab)
231
232 got := tab.telemetrySnapshot().Usage
233 // Locale must not rebind or recompute pricing in automatic mode.
234 if got.SessionCost <= 0 {
235 t.Fatalf("auto-locale lost cost: %f", got.SessionCost)
236 }
237 // Without a wallet hint, original USD remains the selected fact.
238 if got.SessionCost != before && got.CostLedger == nil {
239 t.Fatalf("auto-locale cleared ledger/cost: before=%f after=%f", before, got.SessionCost)
240 }
241 }
242
243 func TestWorkspaceTabDoesNotAddDifferentCurrencies(t *testing.T) {
244 tab := &WorkspaceTab{}
245 tab.recordUsage(event.Event{
246 Usage: &provider.Usage{PromptTokens: 1_000_000, TotalTokens: 1_000_000},
247 Pricing: &provider.Pricing{Input: 1, Currency: "CNY"},
248 })
249 tab.recordUsage(event.Event{
250 Usage: &provider.Usage{PromptTokens: 1_000_000, TotalTokens: 1_000_000},
251 Pricing: &provider.Pricing{Input: 0.14, Currency: "USD"},
252 })
253 got := tab.telemetrySnapshot().Usage
254 // Mixed originals stay in the ledger; we never invent a cross-currency float sum.
255 if got.CostLedger == nil || len(got.CostLedger.Entries) < 2 {
256 t.Fatalf("expected separate ledger entries for mixed currencies, got %+v", got.CostLedger)
257 }
258 if got.SessionCostComplete {
259 t.Fatalf("mixed currencies without a common display should be incomplete")
260 }
261 }
262
263 func TestWorkspaceTabSubagentUsageDoesNotOverwriteExecutorSessionCache(t *testing.T) {
264 tab := &WorkspaceTab{}
265 tab.recordUsage(event.Event{
266 Usage: &provider.Usage{PromptTokens: 1000, CompletionTokens: 10, TotalTokens: 1010, CacheHitTokens: 0, CacheMissTokens: 0},
267 UsageSource: event.UsageSourceExecutor,
268 SessionHit: 700,
269 SessionMiss: 300,
270 })
271 tab.recordUsage(event.Event{
272 Usage: &provider.Usage{PromptTokens: 20, CompletionTokens: 5, TotalTokens: 25, CacheHitTokens: 5, CacheMissTokens: 10},
273 UsageSource: event.UsageSourceSubagent,
274 SessionHit: 999,
275 SessionMiss: 999,
276 })
277 tab.recordUsage(event.Event{
278 Usage: &provider.Usage{PromptTokens: 200, CompletionTokens: 20, TotalTokens: 220, CacheHitTokens: 100, CacheMissTokens: 100},
279 UsageSource: event.UsageSourceExecutor,
280 SessionHit: 800,
281 SessionMiss: 400,
282 })
283
284 got := tab.telemetrySnapshot().Usage
285 if got.CacheHitTokens != 805 || got.CacheMissTokens != 410 {
286 t.Fatalf("cache tokens = hit %d miss %d, want executor deltas plus subagent delta 805/410", got.CacheHitTokens, got.CacheMissTokens)
287 }
288 if got.Sources[event.UsageSourceExecutor].CacheHitTokens != 800 || got.Sources[event.UsageSourceExecutor].CacheMissTokens != 400 {
289 t.Fatalf("executor cache source = %+v, want session deltas 800/400", got.Sources[event.UsageSourceExecutor])
290 }
291 if got.Sources[event.UsageSourceSubagent].CacheHitTokens != 5 || got.Sources[event.UsageSourceSubagent].CacheMissTokens != 10 {
292 t.Fatalf("subagent cache source = %+v, want usage delta 5/10", got.Sources[event.UsageSourceSubagent])
293 }
294 }
295
296 func TestWorkspaceTabTracksPlannerAndExecutorCacheBySource(t *testing.T) {
297 tab := &WorkspaceTab{}
298 tab.recordUsage(event.Event{
299 Usage: &provider.Usage{PromptTokens: 120, CompletionTokens: 15, TotalTokens: 135},
300 UsageSource: event.UsageSourcePlanner,
301 SessionHit: 60,
302 SessionMiss: 40,
303 })
304 tab.recordUsage(event.Event{
305 Usage: &provider.Usage{PromptTokens: 300, CompletionTokens: 40, TotalTokens: 340},
306 UsageSource: event.UsageSourceExecutor,
307 SessionHit: 210,
308 SessionMiss: 90,
309 })
310
311 got := tab.telemetrySnapshot().Usage
312 if got.CacheHitTokens != 270 || got.CacheMissTokens != 130 {
313 t.Fatalf("aggregate cache tokens = hit %d miss %d, want planner+executor 270/130", got.CacheHitTokens, got.CacheMissTokens)
314 }
315 if got.Sources[event.UsageSourcePlanner].CacheHitTokens != 60 || got.Sources[event.UsageSourcePlanner].CacheMissTokens != 40 {
316 t.Fatalf("planner source = %+v, want 60/40", got.Sources[event.UsageSourcePlanner])
317 }
318 if got.Sources[event.UsageSourceExecutor].CacheHitTokens != 210 || got.Sources[event.UsageSourceExecutor].CacheMissTokens != 90 {
319 t.Fatalf("executor source = %+v, want 210/90", got.Sources[event.UsageSourceExecutor])
320 }
321 }
322
323 func TestWorkspaceTabKeepsLastContextScopedToExecutor(t *testing.T) {
324 tab := &WorkspaceTab{}
325 tab.recordUsage(event.Event{
326 Usage: &provider.Usage{
327 PromptTokens: 100,
328 CompletionTokens: 20,
329 TotalTokens: 120,
330 ReasoningTokens: 8,
331 CacheHitTokens: 70,
332 CacheMissTokens: 30,
333 },
334 UsageSource: event.UsageSourceExecutor,
335 })
336 tab.recordUsage(event.Event{
337 Usage: &provider.Usage{
338 PromptTokens: 900,
339 CompletionTokens: 90,
340 TotalTokens: 990,
341 ReasoningTokens: 40,
342 CacheHitTokens: 10,
343 CacheMissTokens: 890,
344 },
345 UsageSource: event.UsageSourceSubagent,
346 })
347
348 got := tab.telemetrySnapshot().Usage
349 if got.LastUsedTokens != 120 ||
350 got.LastPromptTokens != 100 ||
351 got.LastCompletionTokens != 20 ||
352 got.LastReasoningTokens != 8 ||
353 got.LastCacheHitTokens != 70 ||
354 got.LastCacheMissTokens != 30 {
355 t.Fatalf("last executor usage overwritten by ancillary source: %+v", got)
356 }
357 if got.TotalTokens != 1110 || got.Sources[event.UsageSourceSubagent].TotalTokens != 990 {
358 t.Fatalf("all-source totals lost while preserving executor usage: %+v", got)
359 }
360 }
361
362 func TestTelemetryLastContextRoundTripAndLegacyDefaults(t *testing.T) {
363 path := filepath.Join(t.TempDir(), "session.jsonl.telemetry.json")
364 want := tabTelemetrySnapshot{
365 Version: 2,
366 Usage: sessionUsageStats{
367 PromptTokens: 100,
368 TotalTokens: 120,
369 CacheWriteTokens: 5,
370 CacheWriteBilledTokens: 10,
371 LastUsedTokens: 120,
372 LastPromptTokens: 100,
373 LastCompletionTokens: 20,
374 LastReasoningTokens: 8,
375 LastCacheHitTokens: 70,
376 LastCacheMissTokens: 30,
377 },
378 }
379 if err := saveTelemetry(path, want); err != nil {
380 t.Fatalf("save telemetry: %v", err)
381 }
382 got := loadTelemetry(path).Usage
383 if got.LastUsedTokens != want.Usage.LastUsedTokens ||
384 got.LastPromptTokens != want.Usage.LastPromptTokens ||
385 got.LastCompletionTokens != want.Usage.LastCompletionTokens ||
386 got.LastReasoningTokens != want.Usage.LastReasoningTokens ||
387 got.LastCacheHitTokens != want.Usage.LastCacheHitTokens ||
388 got.LastCacheMissTokens != want.Usage.LastCacheMissTokens {
389 t.Fatalf("last context round trip = %+v, want %+v", got, want.Usage)
390 }
391 if got.CacheWriteTokens != 5 || got.CacheWriteBilledTokens != 10 {
392 t.Fatalf("cache-write round trip = raw %d billed %v, want 5/10", got.CacheWriteTokens, got.CacheWriteBilledTokens)
393 }
394
395 if err := os.WriteFile(path, []byte(`{"version":2,"usage":{"promptTokens":50,"totalTokens":50}}`), 0o644); err != nil {
396 t.Fatalf("write pre-last-context telemetry: %v", err)
397 }
398 legacy := loadTelemetry(path).Usage
399 if legacy.CacheWriteTokens != 0 || legacy.CacheWriteBilledTokens != 0 {
400 t.Fatalf("legacy cache-write fields = raw %d billed %v, want zero defaults", legacy.CacheWriteTokens, legacy.CacheWriteBilledTokens)
401 }
402 if legacy.LastUsedTokens != 0 ||
403 legacy.LastPromptTokens != 0 ||
404 legacy.LastCompletionTokens != 0 ||
405 legacy.LastReasoningTokens != 0 ||
406 legacy.LastCacheHitTokens != 0 ||
407 legacy.LastCacheMissTokens != 0 {
408 t.Fatalf("legacy telemetry last context = %+v, want zero defaults", legacy)
409 }
410 }
411
412 // The gauge measures the rebound session's own view, so it no longer needs the
413 // persisted last-used fallback. The panel breakdown still comes from telemetry.
414 func TestContextGaugeMeasuresLiveViewAfterRebind(t *testing.T) {
415 ag := agent.New(
416 usageProvider{usage: nil},
417 tool.NewRegistry(),
418 agent.NewSession("system"),
419 agent.Options{ContextWindow: 200},
420 event.Discard,
421 )
422 tab := &WorkspaceTab{
423 ID: "tab",
424 Ctrl: newFixtureController(t, control.Options{Executor: ag, Sink: event.Discard}),
425 Scope: "global",
426 Ready: true,
427 }
428 tab.recordUsage(event.Event{
429 Usage: &provider.Usage{
430 PromptTokens: 100,
431 CompletionTokens: 20,
432 TotalTokens: 120,
433 ReasoningTokens: 8,
434 CacheHitTokens: 70,
435 CacheMissTokens: 30,
436 },
437 UsageSource: event.UsageSourceExecutor,
438 })
439 tab.recordUsage(event.Event{
440 Usage: &provider.Usage{PromptTokens: 900, CompletionTokens: 90, TotalTokens: 990},
441 UsageSource: event.UsageSourceSubagent,
442 })
443 app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
444
445 context := app.ContextUsageForTab("tab")
446 if want := tab.Ctrl.ContextMaintenanceSnapshot().ProjectedTokens; context.Used != want || context.Window != 200 {
447 t.Fatalf("context gauge = used:%d window:%d, want %d/200 — the live view, not the persisted 120", context.Used, context.Window, want)
448 }
449 panel := app.ContextPanel("tab")
450 if panel.UsedTokens != 120 ||
451 panel.PromptTokens != 100 ||
452 panel.CompletionTokens != 20 ||
453 panel.ReasoningTokens != 8 ||
454 panel.CacheHitTokens != 70 ||
455 panel.CacheMissTokens != 30 {
456 t.Fatalf("context panel fallback = %+v, want persisted executor breakdown", panel)
457 }
458 }
459
460 // TestContextFallbackUsesLatestAttemptAfterMultiAttemptUsage locks the stream-
461 // recovery telemetry contract: billable Prompt/Completion may be 2×30K, but
462 // Last* fields and the panel breakdown must use Context* from the latest
463 // attempt. The gauge itself measures the live view instead.
464 func TestContextFallbackUsesLatestAttemptAfterMultiAttemptUsage(t *testing.T) {
465 ag := agent.New(
466 usageProvider{usage: nil},
467 tool.NewRegistry(),
468 agent.NewSession("system"),
469 agent.Options{ContextWindow: 200_000},
470 event.Discard,
471 )
472 tab := &WorkspaceTab{
473 ID: "tab",
474 Ctrl: newFixtureController(t, control.Options{Executor: ag, Sink: event.Discard}),
475 Scope: "global",
476 Ready: true,
477 }
478 // Two 30K prompt attempts: billable sum 60K+5, latest context 30K+2.
479 tab.recordUsage(event.Event{
480 Usage: &provider.Usage{
481 PromptTokens: 60_000,
482 CompletionTokens: 5,
483 TotalTokens: 60_005,
484 CacheMissTokens: 60_000,
485 ContextPromptTokens: 30_000,
486 ContextCompletionTokens: 2,
487 ContextReasoningTokens: 1,
488 ContextCacheMissTokens: 30_000,
489 },
490 UsageSource: event.UsageSourceExecutor,
491 })
492 got := tab.telemetrySnapshot().Usage
493 if got.LastUsedTokens != 30_002 ||
494 got.LastPromptTokens != 30_000 ||
495 got.LastCompletionTokens != 2 ||
496 got.LastReasoningTokens != 1 ||
497 got.LastCacheMissTokens != 30_000 {
498 t.Fatalf("last context from multi-attempt usage = %+v, want latest 30000+2", got)
499 }
500 // Session billable totals still accumulate the full aggregate.
501 if got.PromptTokens != 60_000 || got.CompletionTokens != 5 {
502 t.Fatalf("session billable totals = prompt %d completion %d, want 60000/5", got.PromptTokens, got.CompletionTokens)
503 }
504
505 app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
506 context := app.ContextUsageForTab("tab")
507 if want := tab.Ctrl.ContextMaintenanceSnapshot().ProjectedTokens; context.Used != want {
508 t.Fatalf("context gauge = %d, want the live view %d (never the billable 60005)", context.Used, want)
509 }
510 panel := app.ContextPanel("tab")
511 if panel.UsedTokens != 30_002 ||
512 panel.PromptTokens != 30_000 ||
513 panel.CompletionTokens != 2 ||
514 panel.ReasoningTokens != 1 ||
515 panel.CacheMissTokens != 30_000 {
516 t.Fatalf("rebind context panel = %+v, want latest-attempt breakdown", panel)
517 }
518 }
519
520 // Providers that omit cache split report ContextCache 0/0 with a valid Context
521 // prompt/completion shape. Last* cache must stay 0/0 — not fall back to the
522 // multi-attempt billable cache aggregate.
523 func TestContextTelemetryKeepsZeroCacheWhenContextShapePresent(t *testing.T) {
524 tab := &WorkspaceTab{ID: "tab", Scope: "global", Ready: true}
525 tab.recordUsage(event.Event{
526 Usage: &provider.Usage{
527 PromptTokens: 60_000,
528 CompletionTokens: 5,
529 TotalTokens: 60_005,
530 CacheMissTokens: 60_000, // billable aggregate from retries
531 ContextPromptTokens: 30_000,
532 ContextCompletionTokens: 2,
533 // ContextCache* intentionally zero: provider did not report a split.
534 },
535 UsageSource: event.UsageSourceExecutor,
536 SessionHit: 0,
537 SessionMiss: 60_000,
538 })
539 got := tab.telemetrySnapshot().Usage
540 if got.LastPromptTokens != 30_000 || got.LastCompletionTokens != 2 {
541 t.Fatalf("last context tokens = prompt %d completion %d, want 30000/2", got.LastPromptTokens, got.LastCompletionTokens)
542 }
543 if got.LastCacheHitTokens != 0 || got.LastCacheMissTokens != 0 {
544 t.Fatalf("last cache = hit %d miss %d, want 0/0 (unreported), not aggregate 60000", got.LastCacheHitTokens, got.LastCacheMissTokens)
545 }
546 if got.LastUsedTokens != 30_002 {
547 t.Fatalf("LastUsedTokens = %d, want 30002", got.LastUsedTokens)
548 }
549 }
550
551 func TestContextPanelUsesLastUsageBreakdownWithTelemetryTotal(t *testing.T) {
552 lastUsage := &provider.Usage{
553 PromptTokens: 10,
554 CompletionTokens: 4,
555 TotalTokens: 14,
556 CacheHitTokens: 7,
557 CacheMissTokens: 3,
558 ReasoningTokens: 2,
559 }
560 ag := agent.New(
561 usageProvider{usage: lastUsage},
562 tool.NewRegistry(),
563 agent.NewSession("system"),
564 agent.Options{},
565 event.Discard,
566 )
567 if err := ag.Run(context.Background(), "hello"); err != nil {
568 t.Fatal(err)
569 }
570 tab := &WorkspaceTab{
571 ID: "tab",
572 Ctrl: newFixtureController(t, control.Options{Executor: ag, Sink: event.Discard}),
573 Scope: "global",
574 Ready: true,
575 }
576 tab.recordUsage(event.Event{
577 Usage: &provider.Usage{
578 PromptTokens: 100,
579 CompletionTokens: 40,
580 TotalTokens: 140,
581 CacheHitTokens: 70,
582 CacheMissTokens: 30,
583 ReasoningTokens: 10,
584 },
585 })
586 app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
587
588 panel := app.ContextPanel("tab")
589 if panel.TotalTokens != 140 {
590 t.Fatalf("context panel total tokens = %d, want telemetry total 140", panel.TotalTokens)
591 }
592 if panel.PromptTokens != 10 || panel.CompletionTokens != 4 || panel.ReasoningTokens != 2 {
593 t.Fatalf("context panel breakdown = prompt:%d completion:%d reasoning:%d, want last usage 10/4/2",
594 panel.PromptTokens, panel.CompletionTokens, panel.ReasoningTokens)
595 }
596 if panel.CacheHitTokens != 7 || panel.CacheMissTokens != 3 {
597 t.Fatalf("context panel cache breakdown = hit:%d miss:%d, want last usage 7/3",
598 panel.CacheHitTokens, panel.CacheMissTokens)
599 }
600 }
601
602 func costedUsageEvent() event.Event {
603 return event.Event{
604 Usage: &provider.Usage{PromptTokens: 100, CompletionTokens: 40, TotalTokens: 140},
605 Pricing: &provider.Pricing{CacheHit: 1, Input: 2, Output: 3, Currency: "¥"},
606 }
607 }
608
609 func TestSyncTelemetryToSessionReKeysAcrossRotation(t *testing.T) {
610 dir := t.TempDir()
611 pathA := filepath.Join(dir, "a.jsonl")
612 pathB := filepath.Join(dir, "b.jsonl")
613
614 tab := &WorkspaceTab{}
615 tab.syncTelemetryToSession(pathA)
616 tab.recordUsage(costedUsageEvent())
617 costA := tab.telemetrySnapshot().Usage.SessionCost
618 if costA <= 0 {
619 t.Fatalf("seed cost = %f, want positive", costA)
620 }
621 if err := saveTelemetry(pathA+".telemetry.json", tab.telemetrySnapshot()); err != nil {
622 t.Fatalf("save telemetry A: %v", err)
623 }
624
625 // Same session: in-memory totals survive.
626 tab.syncTelemetryToSession(pathA)
627 if got := tab.telemetrySnapshot().Usage.SessionCost; got != costA {
628 t.Fatalf("same-session sync cost = %f, want %f", got, costA)
629 }
630
631 // Rotation to a session without a sidecar starts from zero — the previous
632 // session's totals must not bleed over (#5850).
633 tab.syncTelemetryToSession(pathB)
634 if got := tab.telemetrySnapshot().Usage; got.SessionCost != 0 || got.TotalTokens != 0 || got.RequestCount != 0 {
635 t.Fatalf("rotated telemetry = %+v, want zeroed", got)
636 }
637
638 // Rotating back restores session A's persisted totals.
639 tab.syncTelemetryToSession(pathA)
640 if got := tab.telemetrySnapshot().Usage.SessionCost; got != costA {
641 t.Fatalf("restored cost = %f, want %f", got, costA)
642 }
643 }
644
645 func TestContextUsageForTabReKeysAfterControllerRotation(t *testing.T) {
646 dir := t.TempDir()
647 rotated := filepath.Join(dir, "rotated.jsonl")
648 stale := filepath.Join(dir, "stale.jsonl")
649
650 ag := agent.New(usageProvider{usage: &provider.Usage{}}, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
651 tab := &WorkspaceTab{
652 ID: "tab",
653 Ctrl: newFixtureController(t, control.Options{Executor: ag, Sink: event.Discard, SessionDir: dir, SessionPath: rotated}),
654 }
655 // Telemetry still keyed to the pre-rotation session: a typed /new routes
656 // through Controller.Submit and rotates without App.NewSession running.
657 tab.syncTelemetryToSession(stale)
658 tab.recordUsage(costedUsageEvent())
659
660 app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
661 info := app.ContextUsageForTab("tab")
662 if info.SessionCost != 0 || info.SessionTokens != 0 {
663 t.Fatalf("context after rotation = cost %f tokens %d, want zeros", info.SessionCost, info.SessionTokens)
664 }
665 if got := tab.telemetrySnapshot().Usage.RequestCount; got != 0 {
666 t.Fatalf("telemetry request count after rotation = %d, want 0", got)
667 }
668 }
669
670 func TestNewSessionResetsTabUsageTelemetry(t *testing.T) {
671 isolateDesktopUserDirs(t)
672
673 root := globalTabWorkspaceRoot()
674 dir := desktopSessionDir(root)
675 if err := os.MkdirAll(dir, 0o755); err != nil {
676 t.Fatalf("mkdir sessions: %v", err)
677 }
678 sessPath := filepath.Join(dir, "session.jsonl")
679 sess := agent.NewSession("sys")
680 sess.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
681 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "world"})
682 exec := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
683 app := &App{
684 tabs: map[string]*WorkspaceTab{},
685 detachedSessions: map[string]*WorkspaceTab{},
686 activeTabID: "tab",
687 }
688 tab := &WorkspaceTab{
689 ID: "tab",
690 Scope: "global",
691 WorkspaceRoot: root,
692 SessionPath: sessPath,
693 Ready: true,
694 model: "test-model",
695 disabledMCP: map[string]ServerView{},
696 }
697 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
698 tab.Ctrl = newFixtureController(t, control.Options{
699 Executor: exec,
700 SessionDir: dir,
701 SessionPath: sessPath,
702 Label: "test",
703 Sink: tab.sink,
704 })
705 app.tabs[tab.ID] = tab
706
707 tab.syncTelemetryToSession(sessPath)
708 tab.recordUsage(costedUsageEvent())
709 if seed := tab.telemetrySnapshot().Usage.SessionCost; seed <= 0 {
710 t.Fatalf("seed cost = %f, want positive", seed)
711 }
712
713 if err := app.NewSession(); err != nil {
714 t.Fatalf("NewSession: %v", err)
715 }
716 if got := tab.telemetrySnapshot().Usage; got.SessionCost != 0 || got.RequestCount != 0 || got.TotalTokens != 0 {
717 t.Fatalf("telemetry after NewSession = %+v, want zeroed", got)
718 }
719 if info := app.ContextUsageForTab("tab"); info.SessionCost != 0 || info.SessionTokens != 0 {
720 t.Fatalf("context after NewSession = cost %f tokens %d, want zeros", info.SessionCost, info.SessionTokens)
721 }
722 }
723
724 func TestSnapshotConflictRecoveryCarriesTelemetryToFork(t *testing.T) {
725 t.Setenv(agent.SessionLogSchemaEnv, "v1")
726 isolateDesktopUserDirs(t)
727
728 root := globalTabWorkspaceRoot()
729 dir := desktopSessionDir(root)
730 if err := os.MkdirAll(dir, 0o755); err != nil {
731 t.Fatalf("mkdir sessions: %v", err)
732 }
733 originalPath := filepath.Join(dir, "session.jsonl")
734 current := agent.NewSession("sys")
735 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
736 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
737 current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
738 if err := current.Save(originalPath); err != nil {
739 t.Fatalf("Save current: %v", err)
740 }
741
742 staleSess := agent.NewSession("sys")
743 staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
744 staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
745 staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
746 staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
747 app := &App{
748 tabs: map[string]*WorkspaceTab{},
749 detachedSessions: map[string]*WorkspaceTab{},
750 activeTabID: "recovery_tab",
751 }
752 tab := &WorkspaceTab{
753 ID: "recovery_tab",
754 Scope: "global",
755 WorkspaceRoot: root,
756 SessionPath: originalPath,
757 Ready: true,
758 model: "test-model",
759 disabledMCP: map[string]ServerView{},
760 }
761 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
762 tab.Ctrl = newFixtureController(t, control.Options{
763 Executor: staleExec,
764 SessionDir: dir,
765 SessionPath: originalPath,
766 Label: "test",
767 Sink: tab.sink,
768 SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
769 OnSessionRecovered: app.handleTabSessionRecovered(tab),
770 })
771 app.tabs[tab.ID] = tab
772
773 tab.syncTelemetryToSession(originalPath)
774 tab.recordUsage(costedUsageEvent())
775 want := tab.telemetrySnapshot().Usage.SessionCost
776 if want <= 0 {
777 t.Fatalf("seed cost = %f, want positive", want)
778 }
779
780 if err := tab.Ctrl.Snapshot(); err != nil {
781 t.Fatalf("Snapshot: %v", err)
782 }
783 recoveryPath := tab.Ctrl.SessionPath()
784 if recoveryPath == "" || recoveryPath == originalPath {
785 t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
786 }
787
788 // The fork continues the conversation: in-memory totals carry over and a
789 // later sync against the fork path must not wipe them.
790 tab.syncTelemetryToSession(recoveryPath)
791 if got := tab.telemetrySnapshot().Usage.SessionCost; got != want {
792 t.Fatalf("carried cost = %f, want %f", got, want)
793 }
794 // The fork's sidecar was persisted at retarget time, so cost survives an
795 // app exit before the next usage event.
796 if got := loadTelemetry(recoveryPath + ".telemetry.json").Usage.SessionCost; got != want {
797 t.Fatalf("fork sidecar cost = %f, want %f", got, want)
798 }
799 }
800
800 lines GO