Skip to content

Commit 6d9efa0

Browse files
committed
test: add comprehensive tests for _sanitize_for_json
27 tests covering: - Passthrough of valid types (floats, ints, strings, booleans, None) - NaN and +/-Infinity replacement with None - Recursive sanitization through dicts, lists, and tuples - Real-world event structures (on_chain_end, streaming chunks, state sync)
1 parent d0b7b69 commit 6d9efa0

1 file changed

Lines changed: 211 additions & 0 deletions

File tree

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""Tests for _sanitize_for_json in langgraph_agent.py.
2+
3+
Covers the NaN/Infinity sanitization fix for issue #1955:
4+
- Valid floats, ints, strings, booleans, None pass through unchanged
5+
- NaN and +/-Infinity are replaced with None
6+
- Sanitization recurses into dicts, lists, and tuples
7+
- Nested structures with mixed valid/invalid values are handled correctly
8+
"""
9+
10+
import math
11+
12+
from copilotkit.langgraph_agent import _sanitize_for_json
13+
14+
15+
class TestSanitizePassthrough:
16+
"""Values that should pass through unchanged."""
17+
18+
def test_regular_float(self):
19+
assert _sanitize_for_json(3.14) == 3.14
20+
21+
def test_zero_float(self):
22+
assert _sanitize_for_json(0.0) == 0.0
23+
24+
def test_negative_float(self):
25+
assert _sanitize_for_json(-1.5) == -1.5
26+
27+
def test_integer(self):
28+
assert _sanitize_for_json(42) == 42
29+
30+
def test_string(self):
31+
assert _sanitize_for_json("hello") == "hello"
32+
33+
def test_boolean_true(self):
34+
assert _sanitize_for_json(True) is True
35+
36+
def test_boolean_false(self):
37+
assert _sanitize_for_json(False) is False
38+
39+
def test_none(self):
40+
assert _sanitize_for_json(None) is None
41+
42+
def test_empty_dict(self):
43+
assert _sanitize_for_json({}) == {}
44+
45+
def test_empty_list(self):
46+
assert _sanitize_for_json([]) == []
47+
48+
def test_dict_with_valid_values(self):
49+
data = {"a": 1, "b": "two", "c": 3.0, "d": None, "e": True}
50+
assert _sanitize_for_json(data) == data
51+
52+
def test_list_with_valid_values(self):
53+
data = [1, "two", 3.0, None, True]
54+
assert _sanitize_for_json(data) == data
55+
56+
57+
class TestSanitizeNanInfinity:
58+
"""NaN and Infinity values must be replaced with None."""
59+
60+
def test_nan_becomes_none(self):
61+
assert _sanitize_for_json(float("nan")) is None
62+
63+
def test_positive_infinity_becomes_none(self):
64+
assert _sanitize_for_json(float("inf")) is None
65+
66+
def test_negative_infinity_becomes_none(self):
67+
assert _sanitize_for_json(float("-inf")) is None
68+
69+
def test_math_nan_becomes_none(self):
70+
assert _sanitize_for_json(math.nan) is None
71+
72+
def test_math_inf_becomes_none(self):
73+
assert _sanitize_for_json(math.inf) is None
74+
75+
76+
class TestSanitizeNestedStructures:
77+
"""Sanitization must recurse into nested dicts and lists."""
78+
79+
def test_nan_in_dict_value(self):
80+
result = _sanitize_for_json({"score": float("nan")})
81+
assert result == {"score": None}
82+
83+
def test_inf_in_dict_value(self):
84+
result = _sanitize_for_json({"score": float("inf")})
85+
assert result == {"score": None}
86+
87+
def test_nan_in_list(self):
88+
result = _sanitize_for_json([1.0, float("nan"), 3.0])
89+
assert result == [1.0, None, 3.0]
90+
91+
def test_nan_in_tuple(self):
92+
result = _sanitize_for_json((1.0, float("nan"), 3.0))
93+
assert result == [1.0, None, 3.0]
94+
95+
def test_deeply_nested_nan(self):
96+
data = {
97+
"outer": {
98+
"inner": {
99+
"values": [1.0, float("nan"), {"deep": float("inf")}]
100+
}
101+
}
102+
}
103+
result = _sanitize_for_json(data)
104+
assert result == {
105+
"outer": {
106+
"inner": {
107+
"values": [1.0, None, {"deep": None}]
108+
}
109+
}
110+
}
111+
112+
def test_mixed_valid_and_invalid_in_dict(self):
113+
data = {
114+
"valid_int": 42,
115+
"valid_str": "hello",
116+
"nan_val": float("nan"),
117+
"inf_val": float("inf"),
118+
"neg_inf_val": float("-inf"),
119+
"valid_float": 3.14,
120+
"nested": {"also_nan": float("nan"), "ok": True},
121+
}
122+
result = _sanitize_for_json(data)
123+
assert result == {
124+
"valid_int": 42,
125+
"valid_str": "hello",
126+
"nan_val": None,
127+
"inf_val": None,
128+
"neg_inf_val": None,
129+
"valid_float": 3.14,
130+
"nested": {"also_nan": None, "ok": True},
131+
}
132+
133+
134+
class TestSanitizeEventLikeStructures:
135+
"""Simulate real LangGraph event structures to ensure they're sanitized.
136+
137+
These tests verify that the kind of data flowing through the event stream
138+
(line 507 in langgraph_agent.py) is properly sanitized before JSON serialization.
139+
Without sanitization, langchain_dumps() would raise ValueError for NaN/Infinity.
140+
"""
141+
142+
def test_event_with_nan_in_data_output(self):
143+
"""Simulates an on_chain_end event where output state contains NaN."""
144+
event = {
145+
"event": "on_chain_end",
146+
"name": "agent_node",
147+
"run_id": "abc-123",
148+
"metadata": {},
149+
"data": {
150+
"output": {
151+
"score": float("nan"),
152+
"result": "success",
153+
"confidence": float("inf"),
154+
}
155+
},
156+
}
157+
result = _sanitize_for_json(event)
158+
assert result["data"]["output"]["score"] is None
159+
assert result["data"]["output"]["confidence"] is None
160+
assert result["data"]["output"]["result"] == "success"
161+
assert result["event"] == "on_chain_end"
162+
163+
def test_event_with_nan_in_streaming_chunk(self):
164+
"""Simulates a streaming event where a chunk contains NaN values."""
165+
event = {
166+
"event": "on_chat_model_stream",
167+
"name": "model",
168+
"run_id": "def-456",
169+
"metadata": {"copilotkit:emit-intermediate-state": True},
170+
"data": {
171+
"chunk": {
172+
"content": "",
173+
"tool_call_chunks": [
174+
{
175+
"args": '{"temperature": NaN}',
176+
"score": float("nan"),
177+
}
178+
],
179+
}
180+
},
181+
}
182+
result = _sanitize_for_json(event)
183+
assert result["data"]["chunk"]["tool_call_chunks"][0]["score"] is None
184+
# String "NaN" inside args is NOT a float, so it stays as-is
185+
assert result["data"]["chunk"]["tool_call_chunks"][0]["args"] == '{"temperature": NaN}'
186+
187+
def test_event_with_no_problematic_values_unchanged(self):
188+
"""Normal events with no NaN/Infinity should pass through with same structure."""
189+
event = {
190+
"event": "on_chain_start",
191+
"name": "agent_node",
192+
"run_id": "ghi-789",
193+
"metadata": {"key": "value"},
194+
"data": {"input": {"query": "hello", "count": 5}},
195+
}
196+
result = _sanitize_for_json(event)
197+
assert result == event
198+
199+
def test_state_sync_payload_with_nan(self):
200+
"""Simulates the state dict passed to _emit_state_sync_event."""
201+
state = {
202+
"messages": [],
203+
"score": float("nan"),
204+
"embedding": [0.1, float("inf"), 0.3, float("-inf")],
205+
"metadata": {"loss": float("nan"), "accuracy": 0.95},
206+
}
207+
result = _sanitize_for_json(state)
208+
assert result["score"] is None
209+
assert result["embedding"] == [0.1, None, 0.3, None]
210+
assert result["metadata"]["loss"] is None
211+
assert result["metadata"]["accuracy"] == 0.95

0 commit comments

Comments
 (0)