Skip to content

Commit c8e8140

Browse files
Copilotdgenio
andcommitted
test: improve coverage for summarize.py, http.py, and transform.py to ≥95%
Co-authored-by: dgenio <12731907+dgenio@users.noreply.github.com>
1 parent 3b96ae5 commit c8e8140

3 files changed

Lines changed: 264 additions & 8 deletions

File tree

src/agent_kernel/firewall/transform.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -233,12 +233,6 @@ def _make_table(self, data: Any, *, max_rows: int) -> list[dict[str, Any]]:
233233
return result
234234

235235

236-
def _truncate_str(s: str, max_chars: int) -> str:
237-
if len(s) <= max_chars:
238-
return s
239-
return s[:max_chars]
240-
241-
242236
def _cap_facts(facts: list[str], max_chars: int) -> list[str]:
243237
"""Return as many facts as fit within *max_chars* total."""
244238
total = 0

tests/test_drivers.py

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any
66
from unittest.mock import AsyncMock, MagicMock, patch
77

8+
import httpx
89
import pytest
910

1011
from agent_kernel import DriverError, InMemoryDriver
@@ -158,8 +159,6 @@ async def test_httpdriver_unknown_operation_raises() -> None:
158159

159160
@pytest.mark.asyncio
160161
async def test_httpdriver_http_error_raises(monkeypatch: pytest.MonkeyPatch) -> None:
161-
import httpx
162-
163162
driver = HTTPDriver()
164163
endpoint = HTTPEndpoint(url="http://localhost:9999/fail", method="GET")
165164
driver.register_endpoint("fail_op", endpoint)
@@ -183,3 +182,136 @@ async def test_httpdriver_http_error_raises(monkeypatch: pytest.MonkeyPatch) ->
183182
)
184183
with pytest.raises(DriverError, match="HTTP 500"):
185184
await driver.execute(ctx)
185+
186+
187+
@pytest.mark.asyncio
188+
async def test_httpdriver_execute_post() -> None:
189+
driver = HTTPDriver()
190+
endpoint = HTTPEndpoint(url="http://localhost:9999/items", method="POST")
191+
driver.register_endpoint("create_item", endpoint)
192+
193+
mock_response = MagicMock()
194+
mock_response.json.return_value = {"created": True}
195+
mock_response.status_code = 201
196+
mock_response.raise_for_status = MagicMock()
197+
198+
mock_client = AsyncMock()
199+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
200+
mock_client.__aexit__ = AsyncMock(return_value=False)
201+
mock_client.post = AsyncMock(return_value=mock_response)
202+
203+
with patch("agent_kernel.drivers.http.httpx.AsyncClient", return_value=mock_client):
204+
ctx = ExecutionContext(
205+
capability_id="cap.x",
206+
principal_id="u1",
207+
args={"operation": "create_item", "name": "test"},
208+
)
209+
result = await driver.execute(ctx)
210+
assert result.data == {"created": True}
211+
mock_client.post.assert_called_once()
212+
213+
214+
@pytest.mark.asyncio
215+
async def test_httpdriver_execute_put() -> None:
216+
driver = HTTPDriver()
217+
endpoint = HTTPEndpoint(url="http://localhost:9999/items/1", method="PUT")
218+
driver.register_endpoint("update_item", endpoint)
219+
220+
mock_response = MagicMock()
221+
mock_response.json.return_value = {"updated": True}
222+
mock_response.status_code = 200
223+
mock_response.raise_for_status = MagicMock()
224+
225+
mock_client = AsyncMock()
226+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
227+
mock_client.__aexit__ = AsyncMock(return_value=False)
228+
mock_client.put = AsyncMock(return_value=mock_response)
229+
230+
with patch("agent_kernel.drivers.http.httpx.AsyncClient", return_value=mock_client):
231+
ctx = ExecutionContext(
232+
capability_id="cap.x",
233+
principal_id="u1",
234+
args={"operation": "update_item", "name": "updated"},
235+
)
236+
result = await driver.execute(ctx)
237+
assert result.data == {"updated": True}
238+
mock_client.put.assert_called_once()
239+
240+
241+
@pytest.mark.asyncio
242+
async def test_httpdriver_execute_delete() -> None:
243+
driver = HTTPDriver()
244+
endpoint = HTTPEndpoint(url="http://localhost:9999/items/1", method="DELETE")
245+
driver.register_endpoint("delete_item", endpoint)
246+
247+
mock_response = MagicMock()
248+
mock_response.json.return_value = {"deleted": True}
249+
mock_response.status_code = 200
250+
mock_response.raise_for_status = MagicMock()
251+
252+
mock_client = AsyncMock()
253+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
254+
mock_client.__aexit__ = AsyncMock(return_value=False)
255+
mock_client.delete = AsyncMock(return_value=mock_response)
256+
257+
with patch("agent_kernel.drivers.http.httpx.AsyncClient", return_value=mock_client):
258+
ctx = ExecutionContext(
259+
capability_id="cap.x",
260+
principal_id="u1",
261+
args={"operation": "delete_item"},
262+
)
263+
result = await driver.execute(ctx)
264+
assert result.data == {"deleted": True}
265+
mock_client.delete.assert_called_once()
266+
267+
268+
@pytest.mark.asyncio
269+
async def test_httpdriver_execute_patch_uses_request() -> None:
270+
driver = HTTPDriver()
271+
endpoint = HTTPEndpoint(url="http://localhost:9999/items/1", method="PATCH")
272+
driver.register_endpoint("patch_item", endpoint)
273+
274+
mock_response = MagicMock()
275+
mock_response.json.return_value = {"patched": True}
276+
mock_response.status_code = 200
277+
mock_response.raise_for_status = MagicMock()
278+
279+
mock_client = AsyncMock()
280+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
281+
mock_client.__aexit__ = AsyncMock(return_value=False)
282+
mock_client.request = AsyncMock(return_value=mock_response)
283+
284+
with patch("agent_kernel.drivers.http.httpx.AsyncClient", return_value=mock_client):
285+
ctx = ExecutionContext(
286+
capability_id="cap.x",
287+
principal_id="u1",
288+
args={"operation": "patch_item", "field": "value"},
289+
)
290+
result = await driver.execute(ctx)
291+
assert result.data == {"patched": True}
292+
mock_client.request.assert_called_once_with(
293+
"PATCH", "http://localhost:9999/items/1", json={"field": "value"}
294+
)
295+
296+
297+
@pytest.mark.asyncio
298+
async def test_httpdriver_request_error_raises() -> None:
299+
driver = HTTPDriver()
300+
endpoint = HTTPEndpoint(url="http://localhost:9999/unreachable", method="GET")
301+
driver.register_endpoint("unreachable_op", endpoint)
302+
303+
mock_client = AsyncMock()
304+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
305+
mock_client.__aexit__ = AsyncMock(return_value=False)
306+
mock_client.get = AsyncMock(
307+
side_effect=httpx.ConnectError("Connection refused", request=MagicMock())
308+
)
309+
310+
with patch("agent_kernel.drivers.http.httpx.AsyncClient", return_value=mock_client):
311+
ctx = ExecutionContext(
312+
capability_id="cap.x",
313+
principal_id="u1",
314+
args={"operation": "unreachable_op"},
315+
)
316+
with pytest.raises(DriverError, match="Request to .* failed"):
317+
await driver.execute(ctx)

tests/test_firewall.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from agent_kernel import Firewall
88
from agent_kernel.firewall.budgets import Budgets
9+
from agent_kernel.firewall.summarize import summarize
910
from agent_kernel.models import Handle, RawResult
1011

1112

@@ -155,3 +156,132 @@ def test_max_depth_limiting() -> None:
155156
budgets = Budgets(max_depth=2)
156157
frame = _transform(deep, "summary", budgets=budgets)
157158
assert frame.response_mode == "summary" # type: ignore[union-attr]
159+
160+
161+
# ── Raw mode budget warning ────────────────────────────────────────────────────
162+
163+
164+
def test_raw_mode_oversized_data_adds_warning() -> None:
165+
large_data = {"payload": "x" * 10_000}
166+
budgets = Budgets(max_chars=100)
167+
frame = _transform(large_data, "raw", principal_roles=["admin"], budgets=budgets)
168+
assert frame.response_mode == "raw" # type: ignore[union-attr]
169+
assert any("exceeds budget" in w for w in frame.warnings) # type: ignore[union-attr]
170+
171+
172+
# ── Table mode with non-list data ──────────────────────────────────────────────
173+
174+
175+
def test_table_mode_single_dict() -> None:
176+
frame = _transform({"a": 1, "b": 2}, "table")
177+
assert frame.response_mode == "table" # type: ignore[union-attr]
178+
assert len(frame.table_preview) == 1 # type: ignore[union-attr]
179+
assert frame.table_preview[0]["a"] == 1 # type: ignore[union-attr]
180+
181+
182+
def test_table_mode_non_dict_rows() -> None:
183+
frame = _transform([1, 2, 3], "table")
184+
assert frame.response_mode == "table" # type: ignore[union-attr]
185+
assert frame.table_preview[0] == {"value": 1} # type: ignore[union-attr]
186+
187+
188+
def test_table_mode_scalar_data() -> None:
189+
frame = _transform(42, "table")
190+
assert frame.response_mode == "table" # type: ignore[union-attr]
191+
assert frame.table_preview == [{"value": 42}] # type: ignore[union-attr]
192+
193+
194+
# ── _cap_facts via public interface ────────────────────────────────────────────
195+
196+
197+
def test_summary_cap_facts_stops_at_budget() -> None:
198+
# "Keys: key1, key2" (16 chars) fits in max_chars=20; the next fact (46+ chars)
199+
# pushes the running total over budget, triggering the break in _cap_facts.
200+
data = {"key1": "v" * 40, "key2": "v" * 40}
201+
budgets = Budgets(max_chars=20)
202+
frame = _transform(data, "summary", budgets=budgets)
203+
assert frame.response_mode == "summary" # type: ignore[union-attr]
204+
assert len(frame.facts) == 1 # type: ignore[union-attr]
205+
assert "Keys" in frame.facts[0] # type: ignore[union-attr]
206+
207+
208+
def test_cap_facts_all_fit() -> None:
209+
# Both short facts fit well within a generous budget — no break triggered.
210+
data = {"a": 1, "b": 2}
211+
budgets = Budgets(max_chars=10_000)
212+
frame = _transform(data, "summary", budgets=budgets)
213+
assert frame.response_mode == "summary" # type: ignore[union-attr]
214+
assert len(frame.facts) >= 2 # type: ignore[union-attr]
215+
216+
217+
# ── summarize() edge cases ─────────────────────────────────────────────────────
218+
219+
220+
def test_summarize_plain_list() -> None:
221+
facts = summarize([1, 2, 3, "hello"])
222+
assert facts[0] == "List of 4 items"
223+
assert "1" in facts[1]
224+
225+
226+
def test_summarize_other_type_int() -> None:
227+
facts = summarize(42)
228+
assert facts == ["42"]
229+
230+
231+
def test_summarize_other_type_none() -> None:
232+
facts = summarize(None)
233+
assert facts == ["None"]
234+
235+
236+
def test_summarize_string_truncation() -> None:
237+
long_str = "a" * 600
238+
facts = summarize(long_str)
239+
assert len(facts) == 1
240+
assert "600 chars total" in facts[0]
241+
assert facts[0].startswith("a" * 500)
242+
243+
244+
def test_summarize_list_of_dicts_numeric_max_facts() -> None:
245+
rows = [{"n1": i, "n2": i * 2, "n3": i * 3} for i in range(5)]
246+
# max_facts=3: "Total rows" + "Top keys" = 2, then 1 numeric fact hits limit
247+
facts = summarize(rows, max_facts=3)
248+
assert len(facts) <= 3
249+
250+
251+
def test_summarize_list_of_dicts_categorical_distribution() -> None:
252+
rows = [{"status": s} for s in ["open", "closed", "open", "pending", "closed"]]
253+
facts = summarize(rows)
254+
assert any("distribution" in f for f in facts)
255+
256+
257+
def test_summarize_list_of_dicts_no_string_values_in_field() -> None:
258+
# List values are not strings and not numeric — categorical loop skips them
259+
rows = [{"items": [1, 2]}, {"items": [3, 4]}, {"items": [5]}]
260+
facts = summarize(rows)
261+
assert any("Total rows" in f for f in facts)
262+
263+
264+
def test_summarize_list_of_dicts_categorical_max_facts() -> None:
265+
rows = [{"status": s, "kind": k} for s, k in [("a", "x"), ("b", "y"), ("a", "z"), ("b", "x")]]
266+
# max_facts=3: "Total rows" + "Top keys" + 1 categorical fact, then break
267+
facts = summarize(rows, max_facts=3)
268+
assert len(facts) <= 3
269+
270+
271+
def test_summarize_dict_list_value() -> None:
272+
data = {"items": [1, 2, 3], "count": 3}
273+
facts = summarize(data)
274+
assert any("list of 3 items" in f for f in facts)
275+
276+
277+
def test_summarize_dict_other_value_type() -> None:
278+
# Tuple is not int/float/str/list/dict — falls through to repr()
279+
data = {"pair": (1, 2), "count": 1}
280+
facts = summarize(data)
281+
assert any("(1, 2)" in f for f in facts)
282+
283+
284+
def test_summarize_dict_max_facts() -> None:
285+
data = {"a": 1, "b": 2, "c": 3}
286+
facts = summarize(data, max_facts=2)
287+
assert len(facts) <= 2

0 commit comments

Comments
 (0)