Skip to content

Commit 884fc76

Browse files
authored
Merge pull request #63 from dgenio/copilot/fix-stale-issues-and-fill-coverage-gaps
test: close coverage gaps in summarize.py, http.py, and transform.py
2 parents 51198b4 + ba8cc45 commit 884fc76

5 files changed

Lines changed: 272 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111
- GitHub Release step in publish workflow — creates a release with auto-generated notes and artifacts before publishing to PyPI.
1212

13+
### Fixed
14+
- `HTTPDriver`: DELETE requests now forward args as query params instead of silently dropping them.
15+
16+
### Removed
17+
- Dead `_truncate_str` helper in `firewall/transform.py` (defined but never called).
18+
1319
## [0.3.0] - 2026-03-09
1420

1521
### Added

src/agent_kernel/drivers/http.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ async def execute(self, ctx: ExecutionContext) -> RawResult:
8383
params: dict[str, Any] = {}
8484
json_body: dict[str, Any] | None = None
8585

86-
if endpoint.method.upper() == "GET":
86+
if endpoint.method.upper() in ("GET", "DELETE"):
8787
params = {k: v for k, v in ctx.args.items() if k != "operation"}
8888
else:
8989
json_body = {k: v for k, v in ctx.args.items() if k != "operation"}

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

0 commit comments

Comments
 (0)