Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/coverage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Coverage targets (#64)

CI runs `pytest --cov=tool_semantics`. After this work:

- **Target:** no module below **80%** without an explicit waiver below.
- Hotspots historically: `runner.py` (HTTP adapter), `mcp_capture.py` (SSE
error branches), `cli.py` (header / SSE UX), `report.py` (model/stability
renderers).

## Waivers

| Module | Notes |
| --- | --- |
| `cli.py` | Remaining gaps are interactive Rich error paths and rare provenance races; covered by happy-path + header/SSE CLI tests. Raise further when adding commands. |
| `mcp_capture.py` | Deep stdio/HTTP notification and protocol-edge branches; SSE timeout / empty endpoint / POST auth are covered. Full matrix needs more fixture modes. |
41 changes: 38 additions & 3 deletions tests/fixtures/fake_mcp_sse_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,31 @@


class FakeMcpSseServer:
def __init__(self, *, require_auth: bool = False) -> None:
def __init__(
self,
*,
require_auth: bool = False,
hang_without_endpoint: bool = False,
empty_endpoint: bool = False,
post_fail_auth: bool = False,
notify_fail_auth: bool = False,
) -> None:
self.require_auth = require_auth
self.hang_without_endpoint = hang_without_endpoint
self.empty_endpoint = empty_endpoint
self.post_fail_auth = post_fail_auth
self.notify_fail_auth = notify_fail_auth
self._httpd: ThreadingHTTPServer | None = None
self._thread: threading.Thread | None = None
self.base_url = ""
self.sse_url = ""

def start(self) -> None:
require_auth = self.require_auth
hang_without_endpoint = self.hang_without_endpoint
empty_endpoint = self.empty_endpoint
post_fail_auth = self.post_fail_auth
notify_fail_auth = self.notify_fail_auth

class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
Expand Down Expand Up @@ -63,15 +79,28 @@ def do_GET(self) -> None: # noqa: N802
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.end_headers()
if hang_without_endpoint:
while getattr(self.server, "is_running", False):
threading.Event().wait(0.1)
return
host, port = self.server.server_address # type: ignore[attr-defined]
endpoint = f"http://{host}:{port}/message"
self.wfile.write(f"event: endpoint\ndata: {endpoint}\n\n".encode())
if empty_endpoint:
self.wfile.write(b"event: endpoint\ndata: \n\n")
else:
endpoint = f"http://{host}:{port}/message"
self.wfile.write(f"event: endpoint\ndata: {endpoint}\n\n".encode())
self.wfile.flush()
# Hold the SSE connection open until server shutdown.
while getattr(self.server, "is_running", False):
threading.Event().wait(0.1)

def do_POST(self) -> None: # noqa: N802
if post_fail_auth and self.headers.get("Authorization") != "Bearer secret-token":
self.send_response(401)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"error":"unauthorized"}')
return
if self._unauthorized():
return
if urlparse(self.path).path != "/message":
Expand All @@ -89,6 +118,12 @@ def do_POST(self) -> None: # noqa: N802
method = message.get("method")
request_id = message.get("id")
if method == "notifications/initialized":
if notify_fail_auth:
self.send_response(401)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"error":"unauthorized"}')
return
self.send_response(202)
self.end_headers()
return
Expand Down
51 changes: 51 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,54 @@ def test_compare_verbose_and_config_ignore(tmp_path: Path) -> None:
assert "Changes=" in result.stderr
assert result.exit_code == 0
assert "compatible" in result.stdout.lower()


def test_capture_mcp_header_parse_errors() -> None:
result = runner.invoke(
app,
[
"capture-mcp",
"-o",
"/tmp/x.json",
"--header",
"NoColon",
"--http",
"http://127.0.0.1:9/",
],
)
assert result.exit_code == 2
assert "Invalid --header" in result.stdout

result = runner.invoke(
app,
[
"capture-mcp",
"-o",
"/tmp/x.json",
"--header",
": value",
"--http",
"http://127.0.0.1:9/",
],
)
assert result.exit_code == 2
assert "Invalid --header name" in result.stdout


def test_capture_mcp_sse_cli_happy_path(tmp_path: Path) -> None:
from fixtures.fake_mcp_sse_server import FakeMcpSseServer

server = FakeMcpSseServer()
server.start()
try:
out = tmp_path / "sse.json"
result = runner.invoke(
app,
["capture-mcp", "-o", str(out), "--sse", server.sse_url],
)
assert result.exit_code == 0, result.stdout
payload = json.loads(out.read_text(encoding="utf-8"))
assert payload["protocol"] == "mcp-sse"
assert payload["metadata"]["transport"] == "sse"
finally:
server.stop()
43 changes: 43 additions & 0 deletions tests/test_mcp_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,46 @@ def test_capture_mcp_remote_auth_does_not_fallback() -> None:
def test_capture_mcp_remote_total_failure() -> None:
with pytest.raises(McpCaptureError, match=r"\[unsupported_server\].*Remote MCP capture failed"):
capture_mcp_remote("http://127.0.0.1:9/no-mcp-here", timeout=1.0)


def test_capture_mcp_sse_timeout_waiting_for_endpoint() -> None:
server = FakeMcpSseServer(hang_without_endpoint=True)
server.start()
try:
with pytest.raises(McpCaptureError, match="[Tt]imeout|endpoint"):
capture_mcp_sse(server.sse_url, timeout=0.3)
finally:
server.stop()


def test_capture_mcp_sse_empty_endpoint_event() -> None:
server = FakeMcpSseServer(empty_endpoint=True)
server.start()
try:
with pytest.raises(McpCaptureError, match="empty URL|endpoint"):
capture_mcp_sse(server.sse_url, timeout=2.0)
finally:
server.stop()


def test_capture_mcp_sse_post_auth_failure() -> None:
server = FakeMcpSseServer(post_fail_auth=True)
server.start()
try:
with pytest.raises(McpCaptureError, match="POST failed with HTTP 401"):
capture_mcp_sse(server.sse_url, timeout=5.0)
finally:
server.stop()


def test_capture_mcp_sse_notification_auth_failure() -> None:
server = FakeMcpSseServer(notify_fail_auth=True)
server.start()
try:
with pytest.raises(
McpCaptureError,
match="notification.*401|authentication/HTTP error 401",
):
capture_mcp_sse(server.sse_url, timeout=5.0)
finally:
server.stop()
95 changes: 95 additions & 0 deletions tests/test_report_render.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Tests for model-probe / stability report renderers (#64)."""

from __future__ import annotations

import json

from tool_semantics.probes import (
ModelProbeOutcome,
ModelProbeReport,
ModelProbeResult,
ProbeMetrics,
StabilityProbeSummary,
StabilityReport,
TrialDetail,
)
from tool_semantics.report import (
render_model_probe_report_markdown,
render_stability_json,
render_stability_markdown,
)
from tool_semantics.runner import RunnerMetadata


def test_render_model_probe_report_markdown() -> None:
report = ModelProbeReport(
results=[
ModelProbeResult(
probe_id="ok",
passed=True,
message="Selected search_issues.",
selected_tool="search_issues",
outcome=ModelProbeOutcome.OK,
tool_selection_correct=True,
arguments_valid=True,
runner=RunnerMetadata(provider="fake", model="m"),
),
ModelProbeResult(
probe_id="miss",
passed=False,
message="Model returned no tool call.",
outcome=ModelProbeOutcome.MISSING_DATA,
tool_selection_correct=False,
),
]
)
md = render_model_probe_report_markdown(report)
assert "Probe metrics" in md
assert "Per-probe results" in md
assert "`ok`" in md
assert "`miss`" in md
assert "search_issues" in md


def test_render_stability_json_and_markdown() -> None:
report = StabilityReport(
trial_count=2,
seed=3,
summaries=[
StabilityProbeSummary(
probe_id="search",
trials=[
TrialDetail(
trial_index=0,
selected_tool="search_issues",
arguments={"query": "x"},
passed=True,
outcome=ModelProbeOutcome.OK,
message="ok",
),
TrialDetail(
trial_index=1,
selected_tool="search_issues",
arguments={"query": "x"},
passed=True,
outcome=ModelProbeOutcome.OK,
message="ok",
),
],
stability_score=1.0,
unstable=False,
deterministic_failure=False,
aggregate_passed=True,
message="Stable across trials.",
)
],
metrics=ProbeMetrics(probe_count=2, evaluated_count=2, tool_selection_accuracy=1.0),
)
payload = json.loads(render_stability_json(report))
assert payload["trial_count"] == 2
assert payload["seed"] == 3
assert payload["summaries"][0]["probe_id"] == "search"
md = render_stability_markdown(report)
assert "Probe stability report" in md
assert "Aggregate metrics" in md
assert "`search`" in md
Loading