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