1import { describe, expect, it } from "vite-plus/test";
2import { normalizeClaudeTurnTokenUsage } from "./ClaudeTurnTokenUsage.ts";
3import {
4 accumulateCodexTurnTokenUsage,
5 completeCodexTurnTokenUsage,
6 getCodexTurnAccumulator,
7 makeCodexTurnTokenUsageState,
8} from "./CodexTurnTokenUsage.ts";
9
10const breakdown = (input: number, cached: number, output: number) => ({
11 totalTokens: input + output,
12 inputTokens: input,
13 cachedInputTokens: cached,
14 outputTokens: output,
15 reasoningOutputTokens: 0,
16});
17const update = (total: ReturnType<typeof breakdown>, last = total) => ({
18 total,
19 last,
20 modelContextWindow: 200_000,
21});
22
23describe("billable native turn usage", () => {
24 it("adds each Codex response once and excludes the prior resumed history", () => {
25 const state = makeCodexTurnTokenUsageState();
26 state.activeTurnId = "turn";
27 const first = update(breakdown(1010, 505, 102), breakdown(10, 5, 2));
28 accumulateCodexTurnTokenUsage(state, "turn", first);
29 accumulateCodexTurnTokenUsage(state, "turn", first);
30 accumulateCodexTurnTokenUsage(
31 state,
32 "turn",
33 update(breakdown(1030, 515, 108), breakdown(20, 10, 6)),
34 );
35 expect(completeCodexTurnTokenUsage(state, "turn", true)).toEqual({
36 usageStatus: "complete",
37 usageScope: "main_agent",
38 hasSubagents: false,
39 inputTokens: 30,
40 cachedInputTokens: 15,
41 outputTokens: 8,
42 reasoningTokens: 0,
43 });
44 });
45
46 it("keeps previous responses when compaction resets Codex cumulative counters", () => {
47 const state = makeCodexTurnTokenUsageState();
48 state.activeTurnId = "turn";
49 accumulateCodexTurnTokenUsage(state, "turn", update(breakdown(100, 40, 20)));
50 accumulateCodexTurnTokenUsage(state, "turn", update(breakdown(20, 5, 3)));
51 getCodexTurnAccumulator(state, "turn").hasSubagents = true;
52 expect(completeCodexTurnTokenUsage(state, "turn", false)).toMatchObject({
53 usageStatus: "partial",
54 hasSubagents: true,
55 inputTokens: 120,
56 cachedInputTokens: 45,
57 outputTokens: 23,
58 });
59 });
60
61 it("does not bill late Codex updates against the next active turn", () => {
62 const state = makeCodexTurnTokenUsageState();
63 state.activeTurnId = "next";
64 accumulateCodexTurnTokenUsage(state, "previous", update(breakdown(100, 10, 20)));
65 accumulateCodexTurnTokenUsage(
66 state,
67 "next",
68 update(breakdown(104, 12, 21), breakdown(4, 2, 1)),
69 );
70 expect(completeCodexTurnTokenUsage(state, "next", true)).toMatchObject({
71 inputTokens: 4,
72 outputTokens: 1,
73 });
74 expect(completeCodexTurnTokenUsage(state, "missing", false).usageStatus).toBe("unavailable");
75 });
76
77 it("counts Claude cached input inside total input and bounds thinking output", () => {
78 expect(
79 normalizeClaudeTurnTokenUsage(
80 {
81 subtype: "success",
82 usage: {
83 input_tokens: 100,
84 cache_read_input_tokens: 40,
85 cache_creation_input_tokens: 10,
86 output_tokens: 20,
87 output_tokens_details: { thinking_tokens: 25 },
88 },
89 },
90 true,
91 "completed",
92 ),
93 ).toEqual({
94 usageStatus: "complete",
95 usageScope: "main_agent",
96 hasSubagents: true,
97 inputTokens: 150,
98 cachedInputTokens: 40,
99 cacheCreationTokens: 10,
100 outputTokens: 20,
101 reasoningTokens: 20,
102 });
103 });
104
105 it("does not turn a zeroed Claude crash result into a measured zero-cost turn", () => {
106 expect(
107 normalizeClaudeTurnTokenUsage(
108 {
109 subtype: "error_during_execution",
110 usage: {
111 input_tokens: 0,
112 output_tokens: 0,
113 },
114 },
115 false,
116 "failed",
117 ).usageStatus,
118 ).toBe("unavailable");
119 expect(
120 normalizeClaudeTurnTokenUsage(
121 {
122 subtype: "success",
123 usage: {
124 input_tokens: 4,
125 output_tokens: 2,
126 },
127 },
128 false,
129 "interrupted",
130 ),
131 ).toMatchObject({ usageStatus: "partial", inputTokens: 4, outputTokens: 2 });
132 });
133});