1import {
2 USAGE_CONTRACT_VERSION,
3 USAGE_MERGE_COMPATIBLE_SINCE,
4 type EnvironmentId,
5 type UsageBucket,
6 type UsageDay,
7 type UsageProviderKind,
8 UsageSummary,
9} from "@t3tools/contracts";
10import { describe, expect, it } from "vite-plus/test";
11import * as Schema from "effect/Schema";
12
13import { isModelCostUnknown, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts";
14
15const decodeSummary = Schema.decodeUnknownSync(UsageSummary);
16const encodeSummary = Schema.encodeSync(UsageSummary);
17
18function bucket(overrides: Partial<UsageBucket> = {}): UsageBucket {
19 return {
20 day: "2026-08-07" as UsageDay,
21 provider: "claude",
22 model: "claude-fable-5",
23 totals: {
24 uncachedInputTokens: 100,
25 cachedInputTokens: 1000,
26 cacheCreationTokens: 10,
27 outputTokens: 50,
28 reasoningTokens: 0,
29 },
30 costUsd: 10,
31 cacheSavingsUsd: 2,
32 costSource: "modelPriced",
33 records: 5,
34 unpricedRecords: 0,
35 sessions: 1,
36 ...overrides,
37 };
38}
39
40function summary(
41 buckets: readonly UsageBucket[],
42 sources: readonly {
43 provider: UsageProviderKind;
44 hostId: string;
45 homePath: string;
46 volumeId?: string;
47 distinctSessions?: number;
48 }[],
49 contractVersion: number = USAGE_CONTRACT_VERSION,
50): UsageSummary {
51 return {
52 contractVersion,
53 readAt: "2026-08-07T00:00:00.000Z",
54 timeZone: "UTC",
55 sinceDay: "2026-08-01" as UsageDay,
56 untilDay: "2026-08-31" as UsageDay,
57 buckets,
58 sources: sources.map((source) => ({
59 fingerprint: {
60 hostId: source.hostId,
61 provider: source.provider,
62 resolvedHomePath: source.homePath,
63 volumeId: source.volumeId ?? `vol-${source.hostId}`,
64 },
65 status: "ok" as const,
66 scannedFiles: 1,
67 skippedFiles: 0,
68 malformedRecords: 0,
69 distinctSessions: source.distinctSessions ?? 1,
70 message: null,
71 })),
72 pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 },
73 scanDurationMs: 1,
74 };
75}
76
77function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage {
78 return { environmentId: id as EnvironmentId, label: id, summary: usageSummary };
79}
80
81describe("mergeUsage", () => {
82 it("counts a Cursor account once across servers while retaining each server's other providers", () => {
83 const account = {
84 provider: "cursor" as const,
85 hostId: "cursor.com",
86 homePath: "cursor-account:account-hash",
87 volumeId: "account-hash",
88 };
89 const merged = mergeUsage(
90 [
91 environment(
92 "mac",
93 summary([bucket({ provider: "cursor", sourcePath: account.homePath })], [account]),
94 ),
95 environment(
96 "linux",
97 summary(
98 [
99 bucket({ provider: "cursor", sourcePath: account.homePath }),
100 bucket({ provider: "opencode", sourcePath: "/opencode" }),
101 ],
102 [account, { provider: "opencode", hostId: "linux", homePath: "/opencode" }],
103 ),
104 ),
105 ],
106 USAGE_CONTRACT_VERSION,
107 );
108 expect(
109 merged.providers.map((provider) => [provider.provider, provider.costUsd]).sort(),
110 ).toEqual([
111 ["cursor", 10],
112 ["opencode", 10],
113 ]);
114 expect(merged.duplicateSources).toHaveLength(1);
115 });
116
117 it("sums environments that read different transcript directories", () => {
118 const merged = mergeUsage(
119 [
120 environment(
121 "env-a",
122 summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }]),
123 ),
124 environment(
125 "env-b",
126 summary([bucket()], [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }]),
127 ),
128 ],
129 USAGE_CONTRACT_VERSION,
130 );
131
132 expect(merged.costUsd).toBe(20);
133 expect(merged.records).toBe(10);
134 expect(merged.duplicateSources).toHaveLength(0);
135 });
136
137 it("counts a shared transcript directory once", () => {
138 // Two worktree servers on one machine resolve the same provider home.
139 const shared = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
140 const merged = mergeUsage(
141 [
142 environment("env-a", summary([bucket()], [shared])),
143 environment("env-b", summary([bucket()], [shared])),
144 ],
145 USAGE_CONTRACT_VERSION,
146 );
147
148 expect(merged.costUsd).toBe(10);
149 expect(merged.records).toBe(5);
150 expect(merged.sessions).toBe(1);
151 expect(merged.duplicateSources).toHaveLength(1);
152 expect(merged.contributingEnvironments).toEqual(["env-a"]);
153 });
154
155 it("drops only the duplicated provider, keeping the environment's other one", () => {
156 const sharedClaude = {
157 provider: "claude" as const,
158 hostId: "mac",
159 homePath: "/home/theo/.claude",
160 };
161 const merged = mergeUsage(
162 [
163 environment("env-a", summary([bucket()], [sharedClaude])),
164 environment(
165 "env-b",
166 summary(
167 [bucket(), bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 4 })],
168 [sharedClaude, { provider: "codex", hostId: "mac", homePath: "/home/theo/.codex" }],
169 ),
170 ),
171 ],
172 USAGE_CONTRACT_VERSION,
173 );
174
175 // env-b's claude bucket is dropped, its codex bucket survives.
176 expect(merged.costUsd).toBe(14);
177 expect(merged.providers.map((provider) => provider.provider).sort()).toEqual([
178 "claude",
179 "codex",
180 ]);
181 expect(merged.sessions).toBe(2);
182 expect(
183 Object.fromEntries(
184 merged.providers.map((provider) => [provider.provider, provider.sessions]),
185 ),
186 ).toEqual({ claude: 1, codex: 1 });
187 });
188
189 it("counts overlapping provider roots once while keeping each environment's unique root", () => {
190 const source = (homePath: string) => ({
191 provider: "opencode" as const,
192 hostId: "host",
193 homePath,
194 });
195 const usage = (sourcePath: string, costUsd: number) =>
196 bucket({ provider: "opencode", sourcePath, costUsd });
197 const merged = mergeUsage(
198 [
199 environment(
200 "env-a",
201 summary([usage("/shared", 10), usage("/a", 2)], [source("/shared"), source("/a")]),
202 ),
203 environment(
204 "env-b",
205 summary([usage("/shared", 10), usage("/b", 3)], [source("/shared"), source("/b")]),
206 ),
207 ],
208 USAGE_CONTRACT_VERSION,
209 );
210 expect(merged.costUsd).toBe(15);
211 expect(merged.sessions).toBe(3);
212 });
213
214 it("uses the newest scan when environments share the same transcript directory", () => {
215 const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
216 const environments = [
217 environment("env-a", summary([bucket({ costUsd: 4, records: 2 })], [source])),
218 environment("env-b", {
219 ...summary([bucket()], [source]),
220 readAt: "2026-08-07T01:00:00.000Z",
221 }),
222 ];
223
224 for (const ordered of [environments, environments.toReversed()]) {
225 const merged = mergeUsage(ordered, USAGE_CONTRACT_VERSION);
226 expect(merged.costUsd).toBe(10);
227 expect(merged.records).toBe(5);
228 expect(merged.sessions).toBe(1);
229 expect(merged.contributingEnvironments).toEqual(["env-b"]);
230 expect(merged.duplicateSources).toEqual(["env-a: /home/theo/.claude"]);
231 }
232 });
233
234 it("prefers a complete scan over a newer partial scan of the same directory", () => {
235 const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
236 const incomplete = summary([bucket({ costUsd: 4, records: 2 })], [source]);
237 const partial = environment("new", {
238 ...incomplete,
239 readAt: "2026-08-07T01:00:00.000Z",
240 sources: incomplete.sources.map((entry) => ({ ...entry, status: "partial" as const })),
241 });
242 const complete = environment("old", summary([bucket()], [source]));
243
244 for (const ordered of [
245 [partial, complete],
246 [complete, partial],
247 ]) {
248 const merged = mergeUsage(ordered, USAGE_CONTRACT_VERSION);
249 expect(merged.costUsd).toBe(10);
250 expect(merged.contributingEnvironments).toEqual(["old"]);
251 expect(merged.duplicateSources).toEqual(["new: /home/theo/.claude"]);
252 }
253 expect(mergeUsage([partial], USAGE_CONTRACT_VERSION).costUsd).toBe(4);
254 });
255
256 it("keeps new cells from a later partial scan without recounting older cells", () => {
257 const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
258 const complete = environment(
259 "old",
260 summary([bucket()], [source], USAGE_MERGE_COMPATIBLE_SINCE),
261 );
262 const partialSummary = summary(
263 [
264 bucket({ sourcePath: source.homePath, costUsd: 4, records: 2 }),
265 bucket({
266 day: "2026-08-08" as UsageDay,
267 sourcePath: source.homePath,
268 costUsd: 3,
269 records: 1,
270 }),
271 ],
272 [{ ...source, distinctSessions: 2 }],
273 );
274 const partial = environment("new", {
275 ...partialSummary,
276 readAt: "2026-08-08T01:00:00.000Z",
277 sources: partialSummary.sources.map((entry) => ({ ...entry, status: "partial" as const })),
278 });
279
280 for (const ordered of [
281 [complete, partial],
282 [partial, complete],
283 ]) {
284 const merged = mergeUsage(ordered, USAGE_CONTRACT_VERSION);
285 expect(merged.costUsd).toBe(13);
286 expect(merged.records).toBe(6);
287 expect(merged.sessions).toBe(2);
288 expect(merged.daily.map(({ day, costUsd }) => [day, costUsd])).toEqual([
289 ["2026-08-07", 10],
290 ["2026-08-08", 3],
291 ]);
292 expect(merged.contributingEnvironments).toEqual(
293 ordered.map(({ environmentId }) => environmentId),
294 );
295 expect(merged.duplicateSources).toEqual(["new: /home/theo/.claude"]);
296 }
297 });
298
299 it("retains a complete cell when a larger partial cell may have skipped old records", () => {
300 const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
301 const complete = environment("old", summary([bucket()], [source]));
302 const partialSummary = summary(
303 [
304 bucket({
305 costUsd: 4,
306 records: 6,
307 totals: {
308 uncachedInputTokens: 80,
309 cachedInputTokens: 500,
310 cacheCreationTokens: 10,
311 outputTokens: 30,
312 reasoningTokens: 0,
313 },
314 }),
315 ],
316 [{ ...source, distinctSessions: 2 }],
317 );
318 const partial = environment("new", {
319 ...partialSummary,
320 readAt: "2026-08-07T01:00:00.000Z",
321 sources: partialSummary.sources.map((entry) => ({ ...entry, status: "partial" as const })),
322 });
323
324 const merged = mergeUsage([complete, partial], USAGE_CONTRACT_VERSION);
325 expect(merged.costUsd).toBe(10);
326 expect(merged.totalTokens).toBe(1160);
327 expect(merged.records).toBe(5);
328 expect(merged.sessions).toBe(1);
329 expect(merged.contributingEnvironments).toEqual(["old"]);
330 });
331
332 it("identifies an environment reporting an older contract version", () => {
333 const merged = mergeUsage(
334 [
335 environment(
336 "env-a",
337 summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }]),
338 ),
339 environment(
340 "env-b",
341 summary(
342 [bucket()],
343 [{ provider: "claude", hostId: "linux", homePath: "/b" }],
344 USAGE_MERGE_COMPATIBLE_SINCE - 1,
345 ),
346 ),
347 ],
348 USAGE_CONTRACT_VERSION,
349 );
350
351 expect(merged.costUsd).toBe(10);
352 expect(merged.contractMismatches).toEqual([
353 {
354 environmentId: "env-b",
355 direction: "serverBehind",
356 contractVersion: USAGE_MERGE_COMPATIBLE_SINCE - 1,
357 },
358 ]);
359 });
360
361 it("identifies an environment reporting a newer contract version", () => {
362 const merged = mergeUsage(
363 [
364 environment(
365 "env-a",
366 summary(
367 [bucket()],
368 [{ provider: "claude", hostId: "mac", homePath: "/a" }],
369 USAGE_CONTRACT_VERSION + 1,
370 ),
371 ),
372 ],
373 USAGE_CONTRACT_VERSION,
374 );
375
376 expect(merged.costUsd).toBe(0);
377 expect(merged.contractMismatches).toEqual([
378 {
379 environmentId: "env-a",
380 direction: "clientBehind",
381 contractVersion: USAGE_CONTRACT_VERSION + 1,
382 },
383 ]);
384 });
385
386 it("keeps the previous compatible contract version so additive provider expansions still merge", () => {
387 const merged = mergeUsage(
388 [
389 environment(
390 "env-a",
391 summary(
392 [bucket({ costUsd: 10 })],
393 [{ provider: "claude", hostId: "mac", homePath: "/a" }],
394 ),
395 ),
396 environment(
397 "env-b",
398 summary(
399 [bucket({ costUsd: 4, provider: "codex", model: "gpt-5.6-sol" })],
400 [{ provider: "codex", hostId: "linux", homePath: "/b" }],
401 USAGE_CONTRACT_VERSION - 1,
402 ),
403 ),
404 ],
405 USAGE_CONTRACT_VERSION,
406 );
407
408 expect(merged.costUsd).toBe(14);
409 expect(merged.contractMismatches).toEqual([]);
410 });
411
412 it("keeps known usage when newer providers and bucket variants cannot be decoded", () => {
413 const known = summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }]);
414 const decoded = decodeSummary({
415 ...known,
416 buckets: [
417 ...known.buckets,
418 { ...bucket(), provider: "future-provider", costUsd: 100 },
419 { ...bucket(), costSource: "future-pricing", costUsd: 200 },
420 ],
421 sources: [
422 ...known.sources,
423 {
424 ...known.sources[0],
425 fingerprint: {
426 ...known.sources[0]?.fingerprint,
427 provider: "future-provider",
428 },
429 },
430 ],
431 });
432 expect(decoded).toEqual(known);
433 const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION);
434 expect(merged.costUsd).toBe(10);
435 expect(merged.totalTokens).toBe(1160);
436 expect(merged.contractMismatches).toEqual([]);
437 });
438
439 it("normalizes model names during decoding before grouping usage", () => {
440 const decoded = decodeSummary(
441 summary(
442 [
443 bucket({ provider: "codex", model: " gpt-5 " }),
444 bucket({ provider: "codex", model: "gpt-5" }),
445 ],
446 [{ provider: "codex", hostId: "mac", homePath: "/a" }],
447 ),
448 );
449
450 expect(decoded.buckets.map((entry) => entry.model)).toEqual(["gpt-5", "gpt-5"]);
451 const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION);
452 expect(merged.models).toHaveLength(1);
453 expect(merged.models[0]).toMatchObject({ model: "gpt-5", costUsd: 20, totalTokens: 2320 });
454 });
455
456 it("keeps all supported providers when encoding a response", () => {
457 const current = summary(
458 [bucket(), bucket({ provider: "grok" })],
459 [
460 { provider: "claude", hostId: "mac", homePath: "/a" },
461 { provider: "grok", hostId: "mac", homePath: "/b" },
462 ],
463 );
464 expect(encodeSummary(current)).toEqual(current);
465 expect(decodeSummary(encodeSummary(current))).toEqual(current);
466 });
467
468 it("still rejects a malformed summary envelope", () => {
469 expect(() => decodeSummary({ ...summary([], []), buckets: null })).toThrow();
470 });
471
472 it("excludes a future incompatible contract even when its buckets still decode", () => {
473 const decoded = decodeSummary(
474 summary(
475 [bucket()],
476 [{ provider: "claude", hostId: "mac", homePath: "/a" }],
477 USAGE_CONTRACT_VERSION + 1,
478 ),
479 );
480 const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION);
481 expect(merged.costUsd).toBe(0);
482 expect(merged.contractMismatches).toEqual([
483 {
484 environmentId: "env-a",
485 direction: "clientBehind",
486 contractVersion: USAGE_CONTRACT_VERSION + 1,
487 },
488 ]);
489 });
490
491 it("derives provider shares and cost quality", () => {
492 const merged = mergeUsage(
493 [
494 environment(
495 "env-a",
496 summary(
497 [
498 bucket({ costUsd: 75 }),
499 bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 25, unpricedRecords: 5 }),
500 ],
501 [
502 { provider: "claude", hostId: "mac", homePath: "/a/.claude" },
503 { provider: "codex", hostId: "mac", homePath: "/a/.codex" },
504 ],
505 ),
506 ),
507 ],
508 USAGE_CONTRACT_VERSION,
509 );
510
511 expect(merged.providers[0]?.provider).toBe("claude");
512 expect(merged.providers[0]?.costShare).toBeCloseTo(0.75, 5);
513 expect(merged.costQuality.unpricedShare).toBeCloseTo(0.5, 5);
514 expect(merged.costQuality.cacheSavingsUsd).toBe(4);
515 });
516
517 it("derives model token shares independently of their cost shares", () => {
518 const merged = mergeUsage(
519 [
520 environment(
521 "env-a",
522 summary(
523 [
524 bucket({ costUsd: 90 }),
525 bucket({
526 provider: "codex",
527 model: "gpt-5.6-sol",
528 costUsd: 10,
529 totals: {
530 uncachedInputTokens: 3 * 1160,
531 cachedInputTokens: 0,
532 cacheCreationTokens: 0,
533 outputTokens: 0,
534 reasoningTokens: 0,
535 },
536 }),
537 ],
538 [
539 { provider: "claude", hostId: "mac", homePath: "/a/.claude" },
540 { provider: "codex", hostId: "mac", homePath: "/a/.codex" },
541 ],
542 ),
543 ),
544 ],
545 USAGE_CONTRACT_VERSION,
546 );
547
548 const byModel = Object.fromEntries(merged.models.map((model) => [model.model, model]));
549 expect(byModel["claude-fable-5"]?.costShare).toBeCloseTo(0.9, 5);
550 expect(byModel["claude-fable-5"]?.tokenShare).toBeCloseTo(0.25, 5);
551 expect(byModel["gpt-5.6-sol"]?.costShare).toBeCloseTo(0.1, 5);
552 expect(byModel["gpt-5.6-sol"]?.tokenShare).toBeCloseTo(0.75, 5);
553 });
554
555 it("marks a model with no known rates as unpriced rather than free", () => {
556 const merged = mergeUsage(
557 [
558 environment(
559 "env-a",
560 summary(
561 [
562 bucket({ costUsd: 75 }),
563 bucket({
564 provider: "codex",
565 model: "unknown-model",
566 costUsd: 0,
567 costSource: "unpriced",
568 unpricedRecords: 5,
569 }),
570 ],
571 [
572 { provider: "claude", hostId: "mac", homePath: "/a/.claude" },
573 { provider: "codex", hostId: "mac", homePath: "/a/.codex" },
574 ],
575 ),
576 ),
577 ],
578 USAGE_CONTRACT_VERSION,
579 );
580
581 expect(merged.models.find((model) => model.model === "unknown-model")?.unpricedRecords).toBe(5);
582 expect(merged.models.filter(isModelCostUnknown).map((model) => model.model)).toEqual([
583 "unknown-model",
584 ]);
585 });
586
587 it("splits cost by category and speed, counting older servers as unsplit standard cost", () => {
588 const merged = mergeUsage(
589 [
590 environment(
591 "env-a",
592 summary(
593 [
594 bucket({
595 costUsd: 10,
596 categoryCostUsd: { input: 1, cacheRead: 2, cacheWrite: 3, output: 4 },
597 fastCostUsd: 6,
598 speedPremiumUsd: 3,
599 }),
600 bucket({
601 provider: "codex",
602 model: "unknown-model",
603 costUsd: 0,
604 costSource: "unpriced",
605 unpricedRecords: 5,
606 }),
607 // Reported cost on one record, no rates for the other four.
608 bucket({
609 provider: "codex",
610 model: "partly-reported",
611 costUsd: 0,
612 unpricedRecords: 4,
613 }),
614 ],
615 [
616 { provider: "claude", hostId: "mac", homePath: "/a/.claude" },
617 { provider: "codex", hostId: "mac", homePath: "/a/.codex" },
618 ],
619 ),
620 ),
621 environment(
622 "env-b",
623 summary(
624 [bucket({ costUsd: 5 })],
625 [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }],
626 USAGE_MERGE_COMPATIBLE_SINCE,
627 ),
628 ),
629 ],
630 USAGE_CONTRACT_VERSION,
631 );
632
633 expect(merged.categoryCost).toEqual({
634 input: 1,
635 cacheRead: 2,
636 cacheWrite: 3,
637 output: 4,
638 unsplit: 5,
639 });
640 expect(merged.speedCost).toEqual({ standard: 9, fast: 6, ultrafast: 0, premium: 3 });
641 expect(merged.models.map(({ model, unpricedTokens }) => [model, unpricedTokens])).toEqual([
642 ["claude-fable-5", 0],
643 ["unknown-model", 1160],
644 ["partly-reported", 928],
645 ]);
646 });
647
648 it("orders models by cost descending", () => {
649 const merged = mergeUsage(
650 [
651 environment(
652 "env-a",
653 summary(
654 [
655 bucket({ provider: "claude", model: "lower-cost", costUsd: 4 }),
656 bucket({ provider: "codex", model: "higher-cost", costUsd: 9 }),
657 ],
658 [
659 { provider: "claude", hostId: "mac", homePath: "/a/.claude" },
660 { provider: "codex", hostId: "mac", homePath: "/a/.codex" },
661 ],
662 ),
663 ),
664 ],
665 USAGE_CONTRACT_VERSION,
666 );
667
668 expect(merged.models.map((model) => model.model)).toEqual(["higher-cost", "lower-cost"]);
669 });
670
671 it("keeps two machines apart when hostname and home path collide", () => {
672 // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make
673 // one machine's usage vanish. Filesystem identity separates them.
674 const shape = { provider: "claude" as const, hostId: "mac", homePath: "/Users/theo/.claude" };
675 const merged = mergeUsage(
676 [
677 environment("env-a", summary([bucket()], [{ ...shape, volumeId: "16777220:1234" }])),
678 environment("env-b", summary([bucket()], [{ ...shape, volumeId: "16777221:9999" }])),
679 ],
680 USAGE_CONTRACT_VERSION,
681 );
682
683 expect(merged.costUsd).toBe(20);
684 expect(merged.duplicateSources).toHaveLength(0);
685 });
686
687 it("still collapses two servers reading the same directory", () => {
688 const same = {
689 provider: "claude" as const,
690 hostId: "mac",
691 homePath: "/Users/theo/.claude",
692 volumeId: "16777220:1234",
693 };
694 const merged = mergeUsage(
695 [
696 environment("env-a", summary([bucket()], [same])),
697 environment("env-b", summary([bucket()], [same])),
698 ],
699 USAGE_CONTRACT_VERSION,
700 );
701
702 expect(merged.costUsd).toBe(10);
703 expect(merged.duplicateSources).toHaveLength(1);
704 });
705
706 it("totals sessions from per-directory distinct counts, not per-bucket sums", () => {
707 // One session that spans two days appears in two buckets. Summing bucket
708 // sessions would say 2; the source's distinct count says 1.
709 const merged = mergeUsage(
710 [
711 environment(
712 "env-a",
713 summary(
714 [bucket({ day: "2026-08-06" as UsageDay }), bucket({ day: "2026-08-07" as UsageDay })],
715 [
716 {
717 provider: "claude",
718 hostId: "mac",
719 homePath: "/a/.claude",
720 distinctSessions: 1,
721 },
722 ],
723 ),
724 ),
725 ],
726 USAGE_CONTRACT_VERSION,
727 );
728
729 expect(merged.sessions).toBe(1);
730 expect(merged.providers[0]?.sessions).toBe(1);
731 });
732
733 it("returns empty totals with no environments", () => {
734 const merged = mergeUsage([], USAGE_CONTRACT_VERSION);
735 expect(merged.costUsd).toBe(0);
736 expect(merged.daily).toHaveLength(0);
737 expect(merged.hourly).toHaveLength(0);
738 });
739
740 it("omits providers with no sessions or usage", () => {
741 const merged = mergeUsage(
742 [
743 environment(
744 "env-a",
745 summary(
746 [],
747 [
748 {
749 provider: "claude",
750 hostId: "mac",
751 homePath: "/a/.claude",
752 distinctSessions: 0,
753 },
754 ],
755 ),
756 ),
757 ],
758 USAGE_CONTRACT_VERSION,
759 );
760
761 expect(merged.providers).toEqual([]);
762 });
763
764 it("derives hourly totals without losing the daily rollup", () => {
765 const merged = mergeUsage(
766 [
767 environment(
768 "env-a",
769 summary(
770 [
771 bucket({ hourStart: "2026-08-07T09:37:00.000Z", costUsd: 3 }),
772 bucket({ hourStart: "2026-08-07T10:37:00.000Z", costUsd: 7 }),
773 ],
774 [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }],
775 ),
776 ),
777 ],
778 USAGE_CONTRACT_VERSION,
779 );
780
781 expect(merged.hourly.map((hour) => [hour.hourStart, hour.costUsd])).toEqual([
782 ["2026-08-07T09:37:00.000Z", 3],
783 ["2026-08-07T10:37:00.000Z", 7],
784 ]);
785 expect(merged.daily).toHaveLength(1);
786 expect(merged.daily[0]?.costUsd).toBe(10);
787 });
788});