@tokentop/ttop 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +4 -4
  2. package/src/agents/aggregator.test.ts +269 -1
  3. package/src/agents/aggregator.ts +196 -8
  4. package/src/agents/costing.test.ts +584 -0
  5. package/src/agents/costing.ts +144 -30
  6. package/src/agents/types.ts +7 -0
  7. package/src/demo/simulator.test.ts +92 -0
  8. package/src/demo/simulator.ts +579 -211
  9. package/src/plugins/plugin-context-factory.ts +9 -1
  10. package/src/plugins/providers/anthropic.test.ts +145 -0
  11. package/src/plugins/providers/anthropic.ts +18 -12
  12. package/src/plugins/providers/chutes.ts +2 -0
  13. package/src/plugins/providers/github-copilot.ts +3 -0
  14. package/src/plugins/providers/minimax.ts +2 -0
  15. package/src/plugins/providers/openai-api.ts +4 -0
  16. package/src/plugins/providers/zai.ts +2 -0
  17. package/src/pricing/estimator.ts +7 -0
  18. package/src/pricing/index.ts +5 -0
  19. package/src/pricing/models-dev.test.ts +117 -0
  20. package/src/pricing/models-dev.ts +22 -0
  21. package/src/storage/persistence-service.test.ts +201 -0
  22. package/src/storage/persistence-service.ts +54 -5
  23. package/src/storage/repos/agentSessions.ts +49 -0
  24. package/src/tui/App.tsx +4 -4
  25. package/src/tui/components/SessionDetailsDrawer.tsx +29 -1
  26. package/src/tui/components/SessionsTable.tsx +25 -7
  27. package/src/tui/contexts/AgentSessionContext.tsx +59 -21
  28. package/src/tui/contexts/PluginContext.tsx +31 -10
  29. package/src/tui/utils/scrollFollow.test.ts +71 -0
  30. package/src/tui/utils/scrollFollow.ts +12 -0
  31. package/src/tui/views/Dashboard.tsx +0 -6
  32. package/src/tui/views/RealTimeDashboard.tsx +105 -38
  33. package/src/version.ts +1 -1
@@ -39,13 +39,21 @@ export function createPluginContext(
39
39
  permissions: PluginPermissions,
40
40
  signal?: AbortSignal,
41
41
  ): PluginContext {
42
+ // Lazily create the AbortSignal only when accessed to avoid accumulating
43
+ // dangling 30s timers from AbortSignal.timeout() on every context creation.
44
+ let _signal = signal;
42
45
  const ctx: PluginContext = {
43
46
  config: {},
44
47
  logger: createPluginLogger(pluginId),
45
48
  http: createSandboxedHttpClient(pluginId, permissions),
46
49
  authSources: createAuthSources(pluginId, permissions),
47
50
  storage: createPluginStorage(pluginId),
48
- signal: signal ?? AbortSignal.timeout(30_000),
51
+ get signal() {
52
+ if (!_signal) {
53
+ _signal = AbortSignal.timeout(30_000);
54
+ }
55
+ return _signal;
56
+ },
49
57
  };
50
58
  deepFreeze(ctx.config);
51
59
  return ctx;
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type {
3
3
  Credentials,
4
+ OpenCodeAuthEntry,
5
+ PluginContext,
4
6
  PluginHttpClient,
5
7
  PluginLogger,
6
8
  ProviderFetchContext,
@@ -200,3 +202,146 @@ describe("anthropic fetchUsage — success", () => {
200
202
  expect(result.error).toBeUndefined();
201
203
  });
202
204
  });
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // auth.discover — credential source fallthrough
208
+ // ---------------------------------------------------------------------------
209
+
210
+ function createDiscoverContext(options: {
211
+ opencodeEntry?: OpenCodeAuthEntry | null;
212
+ files?: Record<string, string>;
213
+ env?: Record<string, string>;
214
+ platformOs?: "darwin" | "linux" | "win32";
215
+ homedir?: string;
216
+ }): PluginContext {
217
+ const files = options.files ?? {};
218
+ const env = options.env ?? {};
219
+ return {
220
+ config: {},
221
+ logger: createMockLogger(),
222
+ http: { fetch: async () => new Response(null, { status: 404 }) },
223
+ storage: {
224
+ get: async () => null,
225
+ set: async () => {},
226
+ delete: async () => {},
227
+ has: async () => false,
228
+ },
229
+ signal: new AbortController().signal,
230
+ authSources: {
231
+ env: { get: (name) => env[name] },
232
+ files: {
233
+ readText: async (path) => files[path] ?? null,
234
+ readJson: async <T = unknown>(path: string): Promise<T | null> => {
235
+ const raw = files[path];
236
+ if (!raw) return null;
237
+ try {
238
+ return JSON.parse(raw) as T;
239
+ } catch {
240
+ return null;
241
+ }
242
+ },
243
+ exists: async (path) => path in files,
244
+ },
245
+ opencode: {
246
+ getProviderEntry: async () => options.opencodeEntry ?? null,
247
+ },
248
+ platform: {
249
+ os: options.platformOs ?? "linux",
250
+ homedir: options.homedir ?? "/home/test",
251
+ arch: "x64",
252
+ },
253
+ },
254
+ };
255
+ }
256
+
257
+ describe("anthropic auth.discover — OpenCode OAuth expiry", () => {
258
+ // Regression: an expired OpenCode OAuth entry used to be returned as-is,
259
+ // blocking fallthrough to fresher Claude Code credentials and causing the
260
+ // TUI to show "needs auth" even when Claude Code had a valid token.
261
+ test("falls through to Claude Code credentials when OpenCode OAuth is expired", async () => {
262
+ const now = Date.now();
263
+ const ctx = createDiscoverContext({
264
+ opencodeEntry: {
265
+ type: "oauth",
266
+ access: "stale-opencode-token",
267
+ refresh: "stale-refresh",
268
+ expires: now - 60_000,
269
+ },
270
+ files: {
271
+ "/home/test/.claude/.credentials.json": JSON.stringify({
272
+ claudeAiOauth: {
273
+ accessToken: "fresh-claude-code-token",
274
+ refreshToken: "fresh-refresh",
275
+ expiresAt: now + 3_600_000,
276
+ subscriptionType: "max",
277
+ },
278
+ }),
279
+ },
280
+ });
281
+
282
+ const result = await anthropicPlugin.auth.discover(ctx);
283
+
284
+ expect(result.ok).toBe(true);
285
+ expect(result.credentials?.oauth?.accessToken).toBe("fresh-claude-code-token");
286
+ expect(result.credentials?.source).toBe("external");
287
+ });
288
+
289
+ test("treats near-expiry OpenCode token (within 5-minute buffer) as expired", async () => {
290
+ const now = Date.now();
291
+ const ctx = createDiscoverContext({
292
+ opencodeEntry: {
293
+ type: "oauth",
294
+ access: "near-expiry-opencode-token",
295
+ expires: now + 60_000,
296
+ },
297
+ env: { ANTHROPIC_API_KEY: "sk-ant-api-key" },
298
+ });
299
+
300
+ const result = await anthropicPlugin.auth.discover(ctx);
301
+
302
+ expect(result.ok).toBe(true);
303
+ expect(result.credentials?.apiKey).toBe("sk-ant-api-key");
304
+ expect(result.credentials?.source).toBe("env");
305
+ });
306
+
307
+ test("uses OpenCode entry when token is comfortably in the future", async () => {
308
+ const now = Date.now();
309
+ const ctx = createDiscoverContext({
310
+ opencodeEntry: {
311
+ type: "oauth",
312
+ access: "valid-opencode-token",
313
+ refresh: "valid-refresh",
314
+ expires: now + 3_600_000,
315
+ },
316
+ files: {
317
+ "/home/test/.claude/.credentials.json": JSON.stringify({
318
+ claudeAiOauth: {
319
+ accessToken: "claude-code-token",
320
+ expiresAt: now + 3_600_000,
321
+ },
322
+ }),
323
+ },
324
+ });
325
+
326
+ const result = await anthropicPlugin.auth.discover(ctx);
327
+
328
+ expect(result.ok).toBe(true);
329
+ expect(result.credentials?.oauth?.accessToken).toBe("valid-opencode-token");
330
+ expect(result.credentials?.source).toBe("opencode");
331
+ });
332
+
333
+ test("uses OpenCode entry with no expires field (treated as non-expiring)", async () => {
334
+ const ctx = createDiscoverContext({
335
+ opencodeEntry: {
336
+ type: "oauth",
337
+ access: "no-expiry-token",
338
+ },
339
+ });
340
+
341
+ const result = await anthropicPlugin.auth.discover(ctx);
342
+
343
+ expect(result.ok).toBe(true);
344
+ expect(result.credentials?.oauth?.accessToken).toBe("no-expiry-token");
345
+ expect(result.credentials?.source).toBe("opencode");
346
+ });
347
+ });
@@ -155,18 +155,22 @@ export const anthropicPlugin: ProviderPlugin = {
155
155
  async discover(ctx: PluginContext): Promise<CredentialResult> {
156
156
  const entry = await ctx.authSources.opencode.getProviderEntry("anthropic");
157
157
  if (entry?.type === "oauth" && entry.access) {
158
- return {
159
- ok: true,
160
- credentials: {
161
- oauth: buildOAuthCredentials(
162
- entry.access,
163
- entry.refresh,
164
- entry.expires,
165
- entry.accountId,
166
- ),
167
- source: "opencode",
168
- },
169
- };
158
+ const expired =
159
+ entry.expires != null && entry.expires <= Date.now() + TOKEN_EXPIRY_BUFFER_MS;
160
+ if (!expired) {
161
+ return {
162
+ ok: true,
163
+ credentials: {
164
+ oauth: buildOAuthCredentials(
165
+ entry.access,
166
+ entry.refresh,
167
+ entry.expires,
168
+ entry.accountId,
169
+ ),
170
+ source: "opencode",
171
+ },
172
+ };
173
+ }
170
174
  }
171
175
 
172
176
  const claudeCodeResult = await discoverClaudeCode(ctx);
@@ -233,6 +237,8 @@ export const anthropicPlugin: ProviderPlugin = {
233
237
 
234
238
  if (!response.ok) {
235
239
  log.warn("Failed to fetch Anthropic usage", { status: response.status });
240
+ // Drain the response body to prevent memory leaks in Bun's fetch implementation
241
+ response.body?.cancel();
236
242
 
237
243
  if (response.status === 429) {
238
244
  const retryAfterHeader = response.headers.get("retry-after");
@@ -108,6 +108,8 @@ export const chutesPlugin: ProviderPlugin = {
108
108
 
109
109
  if (!response.ok) {
110
110
  log.warn("Failed to fetch Chutes usage", { status: response.status });
111
+ // Drain the response body to prevent memory leaks in Bun's fetch implementation
112
+ response.body?.cancel();
111
113
 
112
114
  if (response.status === 429) {
113
115
  const retryAfterHeader = response.headers.get("retry-after");
@@ -134,6 +134,9 @@ export const githubCopilotPlugin: ProviderPlugin = {
134
134
  });
135
135
 
136
136
  if (!response.ok) {
137
+ // Drain the response body to prevent memory leaks in Bun's fetch implementation
138
+ response.body?.cancel();
139
+
137
140
  if (response.status === 429) {
138
141
  const retryAfterHeader = response.headers.get("retry-after");
139
142
  const parsedRetryAfter = retryAfterHeader
@@ -150,6 +150,8 @@ function createMinimaxPlugin(id: string, name: string): ProviderPlugin {
150
150
 
151
151
  if (!response.ok) {
152
152
  log.warn("Failed to fetch MiniMax usage", { status: response.status });
153
+ // Drain the response body to prevent memory leaks in Bun's fetch implementation
154
+ response.body?.cancel();
153
155
 
154
156
  if (response.status === 429) {
155
157
  const retryAfterHeader = response.headers.get("retry-after");
@@ -270,6 +270,8 @@ async function fetchCompletionsUsage(
270
270
  });
271
271
 
272
272
  if (!response.ok) {
273
+ // Drain the response body to prevent memory leaks in Bun's fetch implementation
274
+ response.body?.cancel();
273
275
  if (response.status === 429) {
274
276
  const retryAfterHeader = response.headers.get("retry-after");
275
277
  const parsedRetryAfter = retryAfterHeader
@@ -303,6 +305,8 @@ async function fetchCosts(
303
305
  });
304
306
 
305
307
  if (!response.ok) {
308
+ // Drain the response body to prevent memory leaks in Bun's fetch implementation
309
+ response.body?.cancel();
306
310
  if (response.status === 429) {
307
311
  const retryAfterHeader = response.headers.get("retry-after");
308
312
  const parsedRetryAfter = retryAfterHeader
@@ -123,6 +123,8 @@ export const zaiCodingPlanPlugin: ProviderPlugin = {
123
123
 
124
124
  if (!response.ok) {
125
125
  log.warn("Failed to fetch Z.ai usage", { status: response.status });
126
+ // Drain the response body to prevent memory leaks in Bun's fetch implementation
127
+ response.body?.cancel();
126
128
 
127
129
  if (response.status === 429) {
128
130
  const retryAfterHeader = response.headers.get("retry-after");
@@ -1,5 +1,7 @@
1
1
  import type { CostBreakdown, ModelPricing } from "@tokentop/plugin-sdk";
2
2
 
3
+ export const LONG_CONTEXT_THRESHOLD = 200_000;
4
+
3
5
  export interface TokenUsage {
4
6
  input: number;
5
7
  output: number;
@@ -36,6 +38,11 @@ export function estimateCost(usage: TokenUsage, pricing: ModelPricing): CostBrea
36
38
  return breakdown;
37
39
  }
38
40
 
41
+ /**
42
+ * @deprecated This function sums tokens before pricing, which is incompatible with
43
+ * tiered context-based pricing. Use per-request costing via priceStream() instead.
44
+ * Kept for backward compatibility — not called at runtime.
45
+ */
39
46
  export function estimateSessionCost(sessions: TokenUsage[], pricing: ModelPricing): CostBreakdown {
40
47
  const totals = sessions.reduce<TokenUsage>(
41
48
  (acc, session) => ({
@@ -68,5 +68,10 @@ export function clearPricingCache(): void {
68
68
  }
69
69
 
70
70
  export type { TokenUsage } from "./estimator.ts";
71
+ /**
72
+ * @deprecated This function sums tokens before pricing, which is incompatible with
73
+ * tiered context-based pricing. Use per-request costing via priceStream() instead.
74
+ * Kept for backward compatibility — not called at runtime.
75
+ */
71
76
  export { estimateCost, estimateSessionCost, formatCost, formatTokenCount } from "./estimator.ts";
72
77
  export { FALLBACK_PRICING } from "./fallback.ts";
@@ -0,0 +1,117 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import { clearCache, getModelPricing, getProviderModels } from "./models-dev.ts";
3
+
4
+ const mockFetch = mock(() => Promise.resolve(new Response("null")));
5
+ globalThis.fetch = mockFetch as unknown as typeof fetch;
6
+
7
+ function makeApiResponse(costOverride?: Record<string, unknown>) {
8
+ return {
9
+ anthropic: {
10
+ id: "anthropic",
11
+ name: "Anthropic",
12
+ models: {
13
+ "claude-sonnet-4-20250514": {
14
+ id: "claude-sonnet-4-20250514",
15
+ name: "Claude Sonnet 4",
16
+ family: "claude",
17
+ cost: {
18
+ input: 3,
19
+ output: 15,
20
+ cache_read: 0.3,
21
+ cache_write: 3.75,
22
+ ...costOverride,
23
+ },
24
+ },
25
+ },
26
+ },
27
+ };
28
+ }
29
+
30
+ function mockApiResponse(data: unknown) {
31
+ mockFetch.mockImplementation(() =>
32
+ Promise.resolve(new Response(JSON.stringify(data), { status: 200 })),
33
+ );
34
+ }
35
+
36
+ afterEach(() => {
37
+ clearCache();
38
+ mockFetch.mockClear();
39
+ });
40
+
41
+ describe("getModelPricing() — context_over_200k", () => {
42
+ test("returns all longContext fields when API has full context_over_200k", async () => {
43
+ mockApiResponse(
44
+ makeApiResponse({
45
+ context_over_200k: {
46
+ input: 6,
47
+ output: 22.5,
48
+ cache_read: 0.6,
49
+ cache_write: 7.5,
50
+ },
51
+ }),
52
+ );
53
+
54
+ const pricing = await getModelPricing("anthropic", "claude-sonnet-4-20250514");
55
+
56
+ expect(pricing).not.toBeNull();
57
+ expect(pricing!.longContextInput).toBe(6);
58
+ expect(pricing!.longContextOutput).toBe(22.5);
59
+ expect(pricing!.longContextCacheRead).toBe(0.6);
60
+ expect(pricing!.longContextCacheWrite).toBe(7.5);
61
+ });
62
+
63
+ test("returns undefined longContext fields when API lacks context_over_200k", async () => {
64
+ mockApiResponse(makeApiResponse());
65
+
66
+ const pricing = await getModelPricing("anthropic", "claude-sonnet-4-20250514");
67
+
68
+ expect(pricing).not.toBeNull();
69
+ expect(pricing!.longContextInput).toBeUndefined();
70
+ expect(pricing!.longContextOutput).toBeUndefined();
71
+ expect(pricing!.longContextCacheRead).toBeUndefined();
72
+ expect(pricing!.longContextCacheWrite).toBeUndefined();
73
+ expect(pricing!.input).toBe(3);
74
+ expect(pricing!.output).toBe(15);
75
+ });
76
+
77
+ test("handles partial context_over_200k (only input+output, no cache)", async () => {
78
+ mockApiResponse(
79
+ makeApiResponse({
80
+ context_over_200k: {
81
+ input: 6,
82
+ output: 22.5,
83
+ },
84
+ }),
85
+ );
86
+
87
+ const pricing = await getModelPricing("anthropic", "claude-sonnet-4-20250514");
88
+
89
+ expect(pricing).not.toBeNull();
90
+ expect(pricing!.longContextInput).toBe(6);
91
+ expect(pricing!.longContextOutput).toBe(22.5);
92
+ expect(pricing!.longContextCacheRead).toBeUndefined();
93
+ expect(pricing!.longContextCacheWrite).toBeUndefined();
94
+ });
95
+
96
+ test("getProviderModels() passes through longContext fields", async () => {
97
+ mockApiResponse(
98
+ makeApiResponse({
99
+ context_over_200k: {
100
+ input: 6,
101
+ output: 22.5,
102
+ cache_read: 0.6,
103
+ },
104
+ }),
105
+ );
106
+
107
+ const models = await getProviderModels("anthropic");
108
+
109
+ expect(models).not.toBeNull();
110
+ const pricing = models!["claude-sonnet-4-20250514"]!;
111
+ expect(pricing).toBeDefined();
112
+ expect(pricing.longContextInput).toBe(6);
113
+ expect(pricing.longContextOutput).toBe(22.5);
114
+ expect(pricing.longContextCacheRead).toBe(0.6);
115
+ expect(pricing.longContextCacheWrite).toBeUndefined();
116
+ });
117
+ });
@@ -8,6 +8,12 @@ interface ModelsDevCost {
8
8
  output: number;
9
9
  cache_read?: number;
10
10
  cache_write?: number;
11
+ context_over_200k?: {
12
+ input?: number;
13
+ output?: number;
14
+ cache_read?: number;
15
+ cache_write?: number;
16
+ };
11
17
  }
12
18
 
13
19
  interface ModelsDevModel {
@@ -88,6 +94,14 @@ export async function getModelPricing(
88
94
  pricing.cacheWrite = model.cost.cache_write;
89
95
  }
90
96
 
97
+ const lc = model.cost.context_over_200k;
98
+ if (lc) {
99
+ if (lc.input !== undefined) pricing.longContextInput = lc.input;
100
+ if (lc.output !== undefined) pricing.longContextOutput = lc.output;
101
+ if (lc.cache_read !== undefined) pricing.longContextCacheRead = lc.cache_read;
102
+ if (lc.cache_write !== undefined) pricing.longContextCacheWrite = lc.cache_write;
103
+ }
104
+
91
105
  return pricing;
92
106
  }
93
107
 
@@ -119,6 +133,14 @@ export async function getProviderModels(
119
133
  pricing.cacheWrite = model.cost.cache_write;
120
134
  }
121
135
 
136
+ const lc = model.cost.context_over_200k;
137
+ if (lc) {
138
+ if (lc.input !== undefined) pricing.longContextInput = lc.input;
139
+ if (lc.output !== undefined) pricing.longContextOutput = lc.output;
140
+ if (lc.cache_read !== undefined) pricing.longContextCacheRead = lc.cache_read;
141
+ if (lc.cache_write !== undefined) pricing.longContextCacheWrite = lc.cache_write;
142
+ }
143
+
122
144
  result[modelId] = pricing;
123
145
  }
124
146
 
@@ -0,0 +1,201 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import type {
3
+ AgentSessionSnapshotInsert,
4
+ AgentSessionStreamSnapshotRow,
5
+ AgentSessionUpsert,
6
+ UsageEventInsert,
7
+ } from "./types.ts";
8
+
9
+ mock.module("./db.ts", () => ({
10
+ isDatabaseInitialized: () => true,
11
+ }));
12
+
13
+ let upsertCalls: AgentSessionUpsert[] = [];
14
+ let snapshotCalls: Array<{
15
+ snapshot: AgentSessionSnapshotInsert;
16
+ streams: Omit<AgentSessionStreamSnapshotRow, "agentSessionSnapshotId">[];
17
+ }> = [];
18
+ let usageEventCalls: UsageEventInsert[][] = [];
19
+
20
+ let seededSnapshots: Array<{
21
+ agentId: string;
22
+ sessionId: string;
23
+ timestamp: number;
24
+ totalCostUsd: number;
25
+ requestCount: number;
26
+ totalInputTokens: number;
27
+ totalOutputTokens: number;
28
+ }> = [];
29
+
30
+ mock.module("./repos/agentSessions.ts", () => ({
31
+ getLatestSnapshotsForAllSessions: () => seededSnapshots,
32
+ getLatestStreamTotalsForAllSessions: () => [],
33
+ upsertAgentSession: (session: AgentSessionUpsert) => {
34
+ upsertCalls.push(session);
35
+ return upsertCalls.length;
36
+ },
37
+ insertAgentSessionSnapshot: (
38
+ snapshot: AgentSessionSnapshotInsert,
39
+ streams: Omit<AgentSessionStreamSnapshotRow, "agentSessionSnapshotId">[],
40
+ ) => {
41
+ snapshotCalls.push({ snapshot, streams });
42
+ },
43
+ }));
44
+
45
+ mock.module("./repos/usageEvents.ts", () => ({
46
+ insertUsageEventBatch: (events: UsageEventInsert[]) => {
47
+ usageEventCalls.push(events);
48
+ },
49
+ }));
50
+
51
+ const { persistSessions, seedPreviousTotals } = await import("./persistence-service.ts");
52
+
53
+ beforeEach(() => {
54
+ upsertCalls = [];
55
+ snapshotCalls = [];
56
+ usageEventCalls = [];
57
+ seededSnapshots = [];
58
+ (globalThis as { __tokentopPersistence?: unknown }).__tokentopPersistence = undefined;
59
+ });
60
+
61
+ function makePersistData(overrides: {
62
+ agentId?: string;
63
+ sessionId?: string;
64
+ totalCostUsd?: number;
65
+ requestCount?: number;
66
+ totalInputTokens?: number;
67
+ totalOutputTokens?: number;
68
+ timestamp?: number;
69
+ }) {
70
+ const now = overrides.timestamp ?? Date.now();
71
+ return {
72
+ session: {
73
+ agentId: overrides.agentId ?? "claude-code",
74
+ sessionId: overrides.sessionId ?? "s1",
75
+ projectPath: null,
76
+ startedAt: null,
77
+ lastSeenAt: now,
78
+ },
79
+ snapshot: {
80
+ timestamp: now,
81
+ lastActivityAt: now,
82
+ status: null,
83
+ totalInputTokens: overrides.totalInputTokens ?? 1000,
84
+ totalOutputTokens: overrides.totalOutputTokens ?? 500,
85
+ totalCacheReadTokens: 0,
86
+ totalCacheWriteTokens: 0,
87
+ totalCostUsd: overrides.totalCostUsd ?? 1.5,
88
+ requestCount: overrides.requestCount ?? 10,
89
+ },
90
+ streams: [],
91
+ };
92
+ }
93
+
94
+ describe("seedPreviousTotals", () => {
95
+ test("skips persist for session whose fingerprint matches seeded DB snapshot", () => {
96
+ seededSnapshots = [
97
+ {
98
+ agentId: "claude-code",
99
+ sessionId: "s1",
100
+ timestamp: Date.now() - 60_000,
101
+ totalCostUsd: 1.5,
102
+ requestCount: 10,
103
+ totalInputTokens: 1000,
104
+ totalOutputTokens: 500,
105
+ },
106
+ ];
107
+
108
+ seedPreviousTotals();
109
+
110
+ const persistedCount = persistSessions([
111
+ makePersistData({
112
+ agentId: "claude-code",
113
+ sessionId: "s1",
114
+ totalCostUsd: 1.5,
115
+ requestCount: 10,
116
+ totalInputTokens: 1000,
117
+ totalOutputTokens: 500,
118
+ }),
119
+ ]);
120
+
121
+ expect(persistedCount).toBe(0);
122
+ expect(upsertCalls).toHaveLength(0);
123
+ expect(snapshotCalls).toHaveLength(0);
124
+ });
125
+
126
+ test("persists session whose usage changed since seeded snapshot", () => {
127
+ seededSnapshots = [
128
+ {
129
+ agentId: "claude-code",
130
+ sessionId: "s1",
131
+ timestamp: Date.now() - 10 * 60 * 1000,
132
+ totalCostUsd: 1.5,
133
+ requestCount: 10,
134
+ totalInputTokens: 1000,
135
+ totalOutputTokens: 500,
136
+ },
137
+ ];
138
+
139
+ seedPreviousTotals();
140
+
141
+ const persistedCount = persistSessions([
142
+ makePersistData({
143
+ agentId: "claude-code",
144
+ sessionId: "s1",
145
+ totalCostUsd: 2.0,
146
+ requestCount: 12,
147
+ totalInputTokens: 1200,
148
+ totalOutputTokens: 600,
149
+ }),
150
+ ]);
151
+
152
+ expect(persistedCount).toBe(1);
153
+ expect(upsertCalls).toHaveLength(1);
154
+ expect(snapshotCalls).toHaveLength(1);
155
+ });
156
+
157
+ test("persists brand-new session not present in seed", () => {
158
+ seededSnapshots = [];
159
+
160
+ seedPreviousTotals();
161
+
162
+ const persistedCount = persistSessions([
163
+ makePersistData({ agentId: "claude-code", sessionId: "new-session" }),
164
+ ]);
165
+
166
+ expect(persistedCount).toBe(1);
167
+ expect(upsertCalls).toHaveLength(1);
168
+ });
169
+
170
+ test("skips thousands of unchanged sessions after seed (regression for startup freeze)", () => {
171
+ const baseTs = Date.now() - 60_000;
172
+ seededSnapshots = Array.from({ length: 5000 }, (_, i) => ({
173
+ agentId: "claude-code",
174
+ sessionId: `s${i}`,
175
+ timestamp: baseTs + i,
176
+ totalCostUsd: i * 0.01,
177
+ requestCount: i,
178
+ totalInputTokens: i * 100,
179
+ totalOutputTokens: i * 50,
180
+ }));
181
+
182
+ seedPreviousTotals();
183
+
184
+ const persistData = seededSnapshots.map((s) =>
185
+ makePersistData({
186
+ agentId: s.agentId,
187
+ sessionId: s.sessionId,
188
+ totalCostUsd: s.totalCostUsd,
189
+ requestCount: s.requestCount,
190
+ totalInputTokens: s.totalInputTokens,
191
+ totalOutputTokens: s.totalOutputTokens,
192
+ }),
193
+ );
194
+
195
+ const persistedCount = persistSessions(persistData);
196
+
197
+ expect(persistedCount).toBe(0);
198
+ expect(upsertCalls).toHaveLength(0);
199
+ expect(snapshotCalls).toHaveLength(0);
200
+ });
201
+ });