Skip to content

Commit 5029d8e

Browse files
wpak-aicursoragent
andcommitted
test: add regression tests for null usage token fields (#10)
14 tests covering _process_assistant, parse_session (integration), and _estimate_cost to prevent reintroduction of the null-usage TypeError fixed in the previous commit. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9e797e0 commit 5029d8e

1 file changed

Lines changed: 272 additions & 0 deletions

File tree

tests/test_null_usage_tokens.py

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
"""Regression tests for null usage token fields.
2+
3+
When the Claude API emits a usage object where a token field is present but
4+
null (e.g. ``"cache_read_input_tokens": null``), the old code raised:
5+
6+
TypeError: unsupported operand type(s) for +=: 'int' and 'NoneType'
7+
8+
because ``dict.get(key, 0)`` returns ``None`` when the key exists with a null
9+
value -- the default only fires when the key is *absent*.
10+
"""
11+
12+
import json
13+
import os
14+
import sys
15+
import tempfile
16+
17+
import pytest
18+
19+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
20+
21+
from utils.jsonl_parser import parse_session, _process_assistant
22+
from utils.session_stats import _estimate_cost
23+
24+
25+
# ---------------------------------------------------------------------------
26+
# Helpers
27+
# ---------------------------------------------------------------------------
28+
29+
def _fresh_metadata() -> dict:
30+
"""Return a minimal metadata dict matching what parse_session initialises."""
31+
return {
32+
"models_used": set(),
33+
"total_input_tokens": 0,
34+
"total_output_tokens": 0,
35+
"total_cache_read_tokens": 0,
36+
"total_cache_creation_tokens": 0,
37+
"total_tool_calls": 0,
38+
"tool_call_counts": {},
39+
"first_timestamp": None,
40+
"last_timestamp": None,
41+
"total_ephemeral_5m_tokens": 0,
42+
"total_ephemeral_1h_tokens": 0,
43+
"service_tiers": set(),
44+
"stop_reasons": {},
45+
"api_errors": 0,
46+
"files_read": set(),
47+
"files_written": set(),
48+
"files_created": set(),
49+
"bash_commands": [],
50+
"web_fetches": [],
51+
"sidechain_messages": 0,
52+
"entry_counts": {},
53+
"compactions": 0,
54+
"compact_boundaries": [],
55+
"session_wall_time_seconds": None,
56+
"version": None,
57+
"cwd": None,
58+
"git_branch": None,
59+
"permission_mode": None,
60+
}
61+
62+
63+
def _assistant_entry(usage: dict) -> dict:
64+
"""Build a minimal assistant JSONL entry with the given usage dict."""
65+
return {
66+
"type": "assistant",
67+
"uuid": "test-uuid",
68+
"parentUuid": None,
69+
"timestamp": "2026-01-01T00:00:00.000Z",
70+
"message": {
71+
"model": "claude-sonnet-4-5",
72+
"content": [{"type": "text", "text": "Hello"}],
73+
"stop_reason": "end_turn",
74+
"usage": usage,
75+
},
76+
}
77+
78+
79+
# ---------------------------------------------------------------------------
80+
# _process_assistant: null fields must not raise
81+
# ---------------------------------------------------------------------------
82+
83+
class TestProcessAssistantNullUsage:
84+
"""Unit tests for _process_assistant with null token values."""
85+
86+
def test_null_cache_read_tokens(self):
87+
meta = _fresh_metadata()
88+
entry = _assistant_entry({"input_tokens": 100, "output_tokens": 50,
89+
"cache_read_input_tokens": None,
90+
"cache_creation_input_tokens": 0})
91+
_process_assistant(entry, [], meta)
92+
assert meta["total_input_tokens"] == 100
93+
assert meta["total_output_tokens"] == 50
94+
assert meta["total_cache_read_tokens"] == 0
95+
96+
def test_null_cache_creation_tokens(self):
97+
meta = _fresh_metadata()
98+
entry = _assistant_entry({"input_tokens": 200, "output_tokens": 80,
99+
"cache_read_input_tokens": 0,
100+
"cache_creation_input_tokens": None})
101+
_process_assistant(entry, [], meta)
102+
assert meta["total_cache_creation_tokens"] == 0
103+
104+
def test_null_input_tokens(self):
105+
meta = _fresh_metadata()
106+
entry = _assistant_entry({"input_tokens": None, "output_tokens": 30})
107+
_process_assistant(entry, [], meta)
108+
assert meta["total_input_tokens"] == 0
109+
assert meta["total_output_tokens"] == 30
110+
111+
def test_null_output_tokens(self):
112+
meta = _fresh_metadata()
113+
entry = _assistant_entry({"input_tokens": 10, "output_tokens": None})
114+
_process_assistant(entry, [], meta)
115+
assert meta["total_input_tokens"] == 10
116+
assert meta["total_output_tokens"] == 0
117+
118+
def test_all_null_usage_fields(self):
119+
meta = _fresh_metadata()
120+
entry = _assistant_entry({
121+
"input_tokens": None,
122+
"output_tokens": None,
123+
"cache_read_input_tokens": None,
124+
"cache_creation_input_tokens": None,
125+
})
126+
_process_assistant(entry, [], meta)
127+
assert meta["total_input_tokens"] == 0
128+
assert meta["total_output_tokens"] == 0
129+
assert meta["total_cache_read_tokens"] == 0
130+
assert meta["total_cache_creation_tokens"] == 0
131+
132+
def test_null_ephemeral_tokens(self):
133+
meta = _fresh_metadata()
134+
entry = _assistant_entry({
135+
"input_tokens": 10,
136+
"output_tokens": 5,
137+
"cache_creation": {
138+
"ephemeral_5m_input_tokens": None,
139+
"ephemeral_1h_input_tokens": None,
140+
},
141+
})
142+
_process_assistant(entry, [], meta)
143+
assert meta["total_ephemeral_5m_tokens"] == 0
144+
assert meta["total_ephemeral_1h_tokens"] == 0
145+
146+
def test_per_message_usage_dict_has_no_null(self):
147+
"""The usage dict stored on the message itself must never contain None."""
148+
messages = []
149+
meta = _fresh_metadata()
150+
entry = _assistant_entry({
151+
"input_tokens": None,
152+
"output_tokens": None,
153+
"cache_read_input_tokens": None,
154+
"cache_creation_input_tokens": None,
155+
})
156+
_process_assistant(entry, messages, meta)
157+
assert len(messages) == 1
158+
usage = messages[0]["usage"]
159+
assert usage["input_tokens"] == 0
160+
assert usage["output_tokens"] == 0
161+
assert usage["cache_read"] == 0
162+
assert usage["cache_creation"] == 0
163+
164+
def test_normal_values_still_accumulate(self):
165+
"""Sanity check: valid integer values are accumulated correctly."""
166+
meta = _fresh_metadata()
167+
for _ in range(3):
168+
entry = _assistant_entry({
169+
"input_tokens": 100,
170+
"output_tokens": 50,
171+
"cache_read_input_tokens": 20,
172+
"cache_creation_input_tokens": 10,
173+
})
174+
_process_assistant(entry, [], meta)
175+
assert meta["total_input_tokens"] == 300
176+
assert meta["total_output_tokens"] == 150
177+
assert meta["total_cache_read_tokens"] == 60
178+
assert meta["total_cache_creation_tokens"] == 30
179+
180+
181+
# ---------------------------------------------------------------------------
182+
# parse_session (integration): null usage survives round-trip via temp file
183+
# ---------------------------------------------------------------------------
184+
185+
class TestParseSessionNullUsage:
186+
"""Integration tests: parse_session must not raise on null usage fields."""
187+
188+
def _write_session(self, entries: list) -> str:
189+
f = tempfile.NamedTemporaryFile(
190+
mode="w", suffix=".jsonl", delete=False, encoding="utf-8"
191+
)
192+
for entry in entries:
193+
f.write(json.dumps(entry) + "\n")
194+
f.close()
195+
return f.name
196+
197+
def test_null_cache_read_does_not_crash(self):
198+
path = self._write_session([
199+
_assistant_entry({
200+
"input_tokens": 500,
201+
"output_tokens": 100,
202+
"cache_read_input_tokens": None,
203+
"cache_creation_input_tokens": None,
204+
})
205+
])
206+
try:
207+
session = parse_session(path)
208+
assert session["metadata"]["total_input_tokens"] == 500
209+
assert session["metadata"]["total_cache_read_tokens"] == 0
210+
finally:
211+
os.unlink(path)
212+
213+
def test_mixed_null_and_normal_entries(self):
214+
"""A session with some null-usage entries and some normal ones should
215+
accumulate only the non-null values."""
216+
path = self._write_session([
217+
_assistant_entry({"input_tokens": 100, "output_tokens": 40,
218+
"cache_read_input_tokens": None}),
219+
_assistant_entry({"input_tokens": 200, "output_tokens": 80,
220+
"cache_read_input_tokens": 30}),
221+
])
222+
try:
223+
session = parse_session(path)
224+
assert session["metadata"]["total_input_tokens"] == 300
225+
assert session["metadata"]["total_output_tokens"] == 120
226+
assert session["metadata"]["total_cache_read_tokens"] == 30
227+
finally:
228+
os.unlink(path)
229+
230+
231+
# ---------------------------------------------------------------------------
232+
# _estimate_cost: null tokens must not crash cost calculation
233+
# ---------------------------------------------------------------------------
234+
235+
class TestEstimateCostNullUsage:
236+
"""Unit tests for _estimate_cost with null token values."""
237+
238+
def _make_messages(self, usage_list: list) -> list:
239+
return [
240+
{"role": "assistant", "model": model, "usage": usage}
241+
for model, usage in usage_list
242+
]
243+
244+
def test_null_output_tokens_with_valid_input(self):
245+
messages = self._make_messages([
246+
("claude-sonnet-4-5", {"input_tokens": 1_000_000, "output_tokens": None}),
247+
])
248+
cost = _estimate_cost(messages, {})
249+
assert cost is not None
250+
assert cost == pytest.approx(3.0, rel=1e-3)
251+
252+
def test_null_input_tokens_with_valid_output(self):
253+
messages = self._make_messages([
254+
("claude-sonnet-4-5", {"input_tokens": None, "output_tokens": 1_000_000}),
255+
])
256+
cost = _estimate_cost(messages, {})
257+
assert cost is not None
258+
assert cost == pytest.approx(15.0, rel=1e-3)
259+
260+
def test_all_null_tokens_returns_none(self):
261+
messages = self._make_messages([
262+
("claude-sonnet-4-5", {"input_tokens": None, "output_tokens": None}),
263+
])
264+
cost = _estimate_cost(messages, {})
265+
assert cost is None
266+
267+
def test_normal_values_unaffected(self):
268+
messages = self._make_messages([
269+
("claude-sonnet-4-5", {"input_tokens": 1_000_000, "output_tokens": 1_000_000}),
270+
])
271+
cost = _estimate_cost(messages, {})
272+
assert cost == pytest.approx(18.0, rel=1e-3)

0 commit comments

Comments
 (0)