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