diff --git a/examples/harness/provider_voice_environment/import_backend/agent.py b/examples/harness/provider_voice_environment/import_backend/agent.py index 5c3e11dde..5e56f59fc 100644 --- a/examples/harness/provider_voice_environment/import_backend/agent.py +++ b/examples/harness/provider_voice_environment/import_backend/agent.py @@ -15,6 +15,77 @@ app = FastAPI() _trace = Path(os.getenv("PROVIDER_TRACE_PATH", "/tmp/provider-trace.jsonl")) +_patients = [ + { + "patient_id": "pat_001", + "full_name": "Jane Doe", + "date_of_birth": "1985-04-12", + "phone": "+15551234567", + }, + { + "patient_id": "pat_002", + "full_name": "John Smith", + "date_of_birth": "1972-09-28", + "phone": "+15559876543", + }, + { + "patient_id": "pat_003", + "full_name": "Alice Taylor", + "date_of_birth": "1990-11-05", + "phone": "+15554567890", + }, +] +_prescriptions = [ + { + "rx_id": "rx_101", + "patient_id": "pat_001", + "medication_name": "Amoxicillin 500mg", + "status": "active", + "refills_remaining": 2, + "is_controlled": False, + }, + { + "rx_id": "rx_102", + "patient_id": "pat_001", + "medication_name": "Lisinopril 10mg", + "status": "no_refills_remaining", + "refills_remaining": 0, + "is_controlled": False, + }, + { + "rx_id": "rx_103", + "patient_id": "pat_002", + "medication_name": "Zolpidem 5mg", + "status": "controlled_substance", + "refills_remaining": 1, + "is_controlled": True, + }, + { + "rx_id": "rx_104", + "patient_id": "pat_002", + "medication_name": "Atorvastatin 20mg", + "status": "expired", + "refills_remaining": 3, + "is_controlled": False, + }, + { + "rx_id": "rx_105", + "patient_id": "pat_003", + "medication_name": "Omeprazole 20mg", + "status": "too_soon", + "refills_remaining": 1, + "is_controlled": False, + }, +] +_refills = { + "ref_201": { + "refill_id": "ref_201", + "rx_id": "rx_101", + "status": "ready_for_collection", + }, + "ref_202": {"refill_id": "ref_202", "rx_id": "rx_105", "status": "processing"}, +} + def _record(kind: str, body: Any) -> None: _trace.parent.mkdir(parents=True, exist_ok=True) @@ -33,6 +104,24 @@ def _record(kind: str, body: Any) -> None: ) +def _arguments(body: Any) -> dict[str, Any]: + if not isinstance(body, dict): + return {} + arguments = body.get("args") or body.get("arguments") or body + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except ValueError: + return {} + return arguments if isinstance(arguments, dict) else {} + + +async def _tool_request(request: Request, name: str) -> dict[str, Any]: + body = await request.json() + _record(f"tool.{name}", body) + return _arguments(body) + + @app.get("/health") def health() -> dict[str, bool]: return {"ok": True} @@ -48,21 +137,94 @@ async def provider_events(request: Request) -> dict[str, bool]: async def record_preference(request: Request) -> dict[str, Any]: body = await request.json() _record("tool.record_preference", body) - preference = body.get("preference") if isinstance(body, dict) else None - if preference is None and isinstance(body, dict): - arguments = body.get("args") or body.get("arguments") - if isinstance(arguments, str): - try: - arguments = json.loads(arguments) - except ValueError: - arguments = None - if isinstance(arguments, dict): - preference = arguments.get("preference") + preference = _arguments(body).get("preference") return { "recorded": True, "preference": preference, } +@app.post("/provider/tools/find_patient") +async def find_patient(request: Request) -> dict[str, Any]: + arguments = await _tool_request(request, "find_patient") + phone = str(arguments.get("phone") or "").strip() + full_name = str(arguments.get("full_name") or "").strip().casefold() + date_of_birth = str(arguments.get("date_of_birth") or "").strip() + for patient in _patients: + phone_match = phone and patient["phone"] == phone + identity_match = ( + full_name + and date_of_birth + and patient["full_name"].casefold() == full_name + and patient["date_of_birth"] == date_of_birth + ) + if phone_match or identity_match: + return {"found": True, "patient": patient} + return {"found": False, "patient": None} + + +@app.post("/provider/tools/list_prescriptions") +async def list_prescriptions(request: Request) -> dict[str, Any]: + arguments = await _tool_request(request, "list_prescriptions") + patient_id = str(arguments.get("patient_id") or "").strip() + return { + "patient_id": patient_id, + "prescriptions": [ + prescription + for prescription in _prescriptions + if prescription["patient_id"] == patient_id + ], + } + + +@app.post("/provider/tools/request_refill") +async def request_refill(request: Request) -> dict[str, Any]: + arguments = await _tool_request(request, "request_refill") + patient_id = str(arguments.get("patient_id") or "").strip() + rx_id = str(arguments.get("rx_id") or "").strip() + prescription = next( + ( + item + for item in _prescriptions + if item["patient_id"] == patient_id and item["rx_id"] == rx_id + ), + None, + ) + if prescription is None: + return {"accepted": False, "reason": "prescription_not_found"} + refusal = { + "too_soon": "too_soon", + "no_refills_remaining": "no_refills_remaining", + "expired": "prescription_expired", + "controlled_substance": "controlled_medication_requires_prescriber", + }.get(str(prescription["status"])) + if refusal: + return {"accepted": False, "reason": refusal, "rx_id": rx_id} + refill_id = f"ref_{rx_id.removeprefix('rx_')}" + _refills[refill_id] = { + "refill_id": refill_id, + "rx_id": rx_id, + "status": "processing", + } + return {"accepted": True, **_refills[refill_id]} + + +@app.post("/provider/tools/check_refill_status") +async def check_refill_status(request: Request) -> dict[str, Any]: + arguments = await _tool_request(request, "check_refill_status") + refill_id = str(arguments.get("refill_id") or "").strip() + refill = _refills.get(refill_id) + return {"found": refill is not None, "refill": refill} + + +@app.post("/provider/tools/transfer_to_pharmacist") +async def transfer_to_pharmacist(request: Request) -> dict[str, Any]: + arguments = await _tool_request(request, "transfer_to_pharmacist") + return { + "transferred": True, + "reason": str(arguments.get("reason") or "clinical_question"), + } + + if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "8080"))) diff --git a/src/fi/alk/harness/bundle_author_v2.py b/src/fi/alk/harness/bundle_author_v2.py index 17138949d..5d029fd89 100644 --- a/src/fi/alk/harness/bundle_author_v2.py +++ b/src/fi/alk/harness/bundle_author_v2.py @@ -407,20 +407,24 @@ def _contract_sql_type(declaration: str) -> str | None: ("UNION[", "OPTIONAL[", "LIST[", "DICT[", "MAPPING[", "TUPLE[", "SET[") ) if language_type: + # Preserve the outer value shape before considering words that describe + # members. Human-readable contracts commonly say things such as + # ``list of dicts with qty (int), cost_basis (float)``; classifying that + # declaration as a number destroys the authored JSON value. + if tokens & {"DICT", "MAPPING", "OBJECT", "JSON", "ANY"}: + return "jsonb" + if tokens & {"LIST", "ARRAY", "TUPLE", "SET"}: + return "jsonb" if tokens & {"FLOAT", "NUMBER", "DECIMAL", "DOUBLE"}: return "double precision" if tokens & {"INT", "INTEGER"}: return "bigint" if tokens & {"BOOL", "BOOLEAN"}: return "boolean" - if tokens & {"DICT", "MAPPING", "OBJECT", "JSON", "ANY"}: - return "jsonb" - if tokens & {"LIST", "ARRAY", "TUPLE", "SET"}: - return "jsonb" if tokens & {"STR", "STRING"}: return "text" patterns = ( - (r"^BOOLEAN\b", "boolean"), + (r"^(?:BOOL|BOOLEAN)\b", "boolean"), (r"^(?:BIGINT|INTEGER|INT|SMALLINT)\b", "bigint"), (r"^(?:DOUBLE PRECISION|REAL|FLOAT)\b", "double precision"), ( @@ -440,17 +444,17 @@ def _contract_sql_type(declaration: str) -> str | None: # Application contracts often use language-level unions instead of SQL # declarations. Interpret the whole declaration as a type set, with the wider # compatible representation winning independently of token order. + if re.match(r"^(?:DICT|MAPPING|OBJECT|JSON|ANY)\b", normalized): + return "jsonb" + if re.match(r"^(?:LIST|ARRAY|TUPLE|SET)\b", normalized): + # Without a proven homogeneous leaf type, JSONB preserves the value shape. + return "jsonb" if tokens & {"FLOAT", "NUMBER", "DECIMAL", "DOUBLE"}: return "double precision" if tokens & {"INT", "INTEGER"}: return "bigint" if tokens & {"BOOL", "BOOLEAN"}: return "boolean" - if tokens & {"DICT", "MAPPING", "OBJECT", "JSON", "ANY"}: - return "jsonb" - if tokens & {"LIST", "ARRAY", "TUPLE", "SET"}: - # Without a proven homogeneous leaf type, JSONB preserves the value shape. - return "jsonb" if tokens & {"STR", "STRING"}: return "text" return None @@ -1665,7 +1669,7 @@ def resolve_environment_plan( environment = {**declared_runtime_environment, **environment} environment.setdefault( "LIVEKIT_AGENT_NAME", - "voice-booking-{{JOB_ID}}-w{{WORLD_INDEX}}", + "voice-booking-{{JOB_ID}}-w{{WORLD_INDEX}}", ) environment.setdefault( "HARNESS_TOOL_TRACE", @@ -2668,7 +2672,13 @@ def _schema_column_types(paths: list[Path]) -> dict[tuple[str, str], str]: if len(words) < 2: continue name = words[0].strip('"') - if name.upper() in ("PRIMARY", "FOREIGN", "UNIQUE", "CHECK", "CONSTRAINT"): + if name.upper() in ( + "PRIMARY", + "FOREIGN", + "UNIQUE", + "CHECK", + "CONSTRAINT", + ): continue kind = words[1].upper() rest = " ".join(words[1:]).upper() diff --git a/src/fi/alk/harness/chat_worker.py b/src/fi/alk/harness/chat_worker.py index 879b889b9..948b28d4e 100644 --- a/src/fi/alk/harness/chat_worker.py +++ b/src/fi/alk/harness/chat_worker.py @@ -21,6 +21,13 @@ # Model configuration only: no platform callback capability or source-fetch credential. _CHAT_ENV = frozenset( { + # Platform-owned model gateway credentials are simulator configuration, + # not target-agent or callback credentials. The isolated worker needs + # them when the Claude SDK wire protocol is routed to a non-Claude model. + "AGENTCC_API_KEY", + "AGENTCC_BASE_URL", + "ALK_CLAUDE_GATEWAY_API_KEY", + "ALK_CLAUDE_GATEWAY_URL", "ALK_HARNESS", "ALK_HARNESS_MODEL", "ALK_HARNESS_THINKING", @@ -85,7 +92,11 @@ async def run( work_directory=context.work_directory / "chat-workers", ) if "error" in result: - raise CallAborted(f"chat_worker_failed: {result['error']}") + raise CallAborted( + f"chat_worker_failed: {result['error']}", + marker=result.get("error_marker"), + code=result.get("error_code") or "call_failed", + ) artifact_ids = {} for artifact in result["artifacts"]: artifact_ids[artifact["id"]] = await self._adapter.upload_artifact( @@ -154,6 +165,16 @@ async def main() -> None: EnvironmentRuntime.model_validate(payload["runtime"]), ) result = {"outcome": asdict(outcome), "artifacts": collector.artifacts} + except CallAborted as exc: + # CallAborted messages and structured failure fields are already designed for + # result receipts. Preserve them across the worker boundary; returning only the + # exception class made every chat failure indistinguishable and discarded useful + # retry/attribution codes. Arbitrary exceptions remain type-only below. + result = { + "error": str(exc), + "error_code": exc.code, + "error_marker": exc.marker, + } except Exception as exc: # noqa: BLE001 - return a secret-safe worker failure result = {"error": type(exc).__name__} fd = os.open(sys.argv[1], os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) diff --git a/src/fi/alk/harness/usage.py b/src/fi/alk/harness/usage.py index e1b376397..8b2d9c4c3 100644 --- a/src/fi/alk/harness/usage.py +++ b/src/fi/alk/harness/usage.py @@ -8,6 +8,7 @@ import os import tempfile import threading +import time import uuid from collections.abc import Mapping from datetime import datetime, timezone @@ -248,30 +249,42 @@ def __init__( self._network_lock = threading.RLock() def check(self, action: UsageAction) -> None: - try: - with self._network_lock: - response = self._transport.request( - "POST", - self._endpoint, - headers=self._capabilities.auth_headers(), - json_body={"operation": "check", "action": action}, - ) - except TransportError as exc: - raise UsageUnavailable(f"usage check unavailable: {exc}") from exc - body = response.body - result = ( - body.get("result") - if isinstance(body, dict) and isinstance(body.get("result"), dict) - else body - ) - if response.status_code == 402: - raise UsageDenied(body) - if response.status_code < 200 or response.status_code >= 300: - raise UsageUnavailable( - f"usage check failed with HTTP {response.status_code}" + last_transport_error: TransportError | None = None + for attempt in range(3): + try: + with self._network_lock: + response = self._transport.request( + "POST", + self._endpoint, + headers=self._capabilities.auth_headers(), + json_body={"operation": "check", "action": action}, + ) + except TransportError as exc: + last_transport_error = exc + if attempt < 2: + time.sleep(0.5 * (2**attempt)) + continue + raise UsageUnavailable(f"usage check unavailable: {exc}") from exc + body = response.body + result = ( + body.get("result") + if isinstance(body, dict) and isinstance(body.get("result"), dict) + else body ) - if not isinstance(result, dict) or result.get("allowed") is not True: - raise UsageUnavailable("usage check response did not allow the action") + if response.status_code == 402: + raise UsageDenied(body) + if 500 <= response.status_code < 600 and attempt < 2: + time.sleep(0.5 * (2**attempt)) + continue + if response.status_code < 200 or response.status_code >= 300: + raise UsageUnavailable( + f"usage check failed with HTTP {response.status_code}" + ) + if not isinstance(result, dict) or result.get("allowed") is not True: + raise UsageUnavailable("usage check response did not allow the action") + return + # The loop always returns or raises. Keep the invariant explicit for type checkers. + raise UsageUnavailable(f"usage check unavailable: {last_transport_error}") def record( self, diff --git a/tests/harness/test_bundle_author_v2.py b/tests/harness/test_bundle_author_v2.py index 432a77c4f..543548aea 100644 --- a/tests/harness/test_bundle_author_v2.py +++ b/tests/harness/test_bundle_author_v2.py @@ -12,6 +12,7 @@ BundleAuthorError, _compile_source_tool_handlers, _contract_column_declarations, + _contract_sql_type, _sqlite_sql, author_bundle_v2, resolve_environment_plan, @@ -1316,6 +1317,62 @@ def test_bundle_uses_widest_numeric_type_from_language_union(tmp_path: Path) -> assert '"score" double precision' in compiled_world +@pytest.mark.parametrize( + ("declaration", "expected"), + [ + ("bool, whether the rider is on the block list", "boolean"), + ("number of items in the cart list", "double precision"), + ], +) +def test_contract_scalar_type_ignores_container_words_in_prose( + declaration: str, expected: str +) -> None: + assert _contract_sql_type(declaration) == expected + + +def test_bundle_preserves_container_shape_from_descriptive_contract( + tmp_path: Path, +) -> None: + authoring = _authoring(tmp_path) + (authoring / "contract.json").write_text( + json.dumps( + { + "modality": "chat", + "data_schema": { + "clients": { + "holdings": ( + "list of dicts with ticker (str), qty (int), " + "cost_basis (float)" + ) + } + }, + } + ), + encoding="utf-8", + ) + database = sqlite3.connect(authoring / "world.sqlite") + try: + database.execute("CREATE TABLE clients (holdings TEXT)") + database.execute( + "INSERT INTO clients VALUES (?)", + ('[{"ticker":"NVDA","qty":2,"cost_basis":100.5}]',), + ) + database.commit() + finally: + database.close() + + compiled_world = _sqlite_sql( + authoring / "world.sqlite", + contract_declarations=_contract_column_declarations( + json.loads((authoring / "contract.json").read_text(encoding="utf-8")) + ), + ) + + assert '"holdings" jsonb' in compiled_world + assert '"cost_basis": 100.5' in compiled_world + assert '"ticker": "NVDA"' in compiled_world + + def test_adopted_source_schema_applies_defaults_for_authored_nulls( tmp_path: Path, ) -> None: diff --git a/tests/harness/test_chat_worker.py b/tests/harness/test_chat_worker.py index b587a1cb6..e77d4c258 100644 --- a/tests/harness/test_chat_worker.py +++ b/tests/harness/test_chat_worker.py @@ -7,6 +7,8 @@ import os from types import SimpleNamespace +import pytest + from test_chat_call_runner import ( _Adapter, _ToolWorld, @@ -15,6 +17,7 @@ ) from fi.alk.harness import chat_call_runner, chat_worker +from fi.alk.harness.hosted_scheduler import CallAborted from fi.alk.harness.process_runtime import ( EnvironmentRuntime, RuntimeEndpoint, @@ -86,6 +89,10 @@ def test_parallel_chat_workers_keep_routing_private_and_upload_in_parent( context = _context(tmp_path) adapter = _Adapter() monkeypatch.setenv("HARNESS_PLATFORM_API_KEY", "parent-only") + monkeypatch.setenv("AGENTCC_API_KEY", "simulator-model-key") + monkeypatch.setenv("AGENTCC_BASE_URL", "https://gateway.example") + monkeypatch.setenv("ALK_CLAUDE_GATEWAY_API_KEY", "simulator-claude-key") + monkeypatch.setenv("ALK_CLAUDE_GATEWAY_URL", "https://claude.example") original = dict(os.environ) payloads = [] @@ -94,6 +101,10 @@ async def run(): async def worker(module, payload, *, environ, work_directory): assert "HARNESS_PLATFORM_API_KEY" not in environ + assert environ["AGENTCC_API_KEY"] == "simulator-model-key" + assert environ["AGENTCC_BASE_URL"] == "https://gateway.example" + assert environ["ALK_CLAUDE_GATEWAY_API_KEY"] == "simulator-claude-key" + assert environ["ALK_CLAUDE_GATEWAY_URL"] == "https://claude.example" payloads.append(payload) if len(payloads) == 2: both.set() @@ -151,3 +162,84 @@ async def worker(module, payload, *, environ, work_directory): for payload in payloads } == {("trial-0", "authored-0"), ("trial-1", "authored-1")} assert dict(os.environ) == original + + +def test_isolated_chat_runner_preserves_structured_worker_failure( + tmp_path, monkeypatch +): + context = _context(tmp_path) + + async def worker(*_args, **_kwargs): + return { + "error": "chat_target_failed: upstream unavailable", + "error_code": "target_agent_failed", + "error_marker": "target_unavailable", + } + + monkeypatch.setattr(chat_worker, "run_json_worker", worker) + runner = chat_worker.IsolatedChatCallRunner(_Adapter(), context) + + with pytest.raises(CallAborted) as error: + asyncio.run( + runner.run( + SimpleNamespace( + scenario_key="trial-key", + source_scenario_key="one", + scenario_id="trial-id", + ), + EnvironmentRuntime( + runtime_id="runtime-1", + world_index=0, + bundle_digest="sha256:" + "a" * 64, + state=RuntimeState.READY, + ), + ) + ) + + assert str(error.value) == ( + "chat_worker_failed: chat_target_failed: upstream unavailable" + ) + assert error.value.code == "target_agent_failed" + assert error.value.marker == "target_unavailable" + + +def test_chat_worker_returns_call_aborted_details_only(tmp_path, monkeypatch): + context = _context(tmp_path) + runtime = EnvironmentRuntime( + runtime_id="runtime-1", + world_index=0, + bundle_digest="sha256:" + "a" * 64, + state=RuntimeState.READY, + ) + payload = { + "job": context.job.model_dump(mode="json"), + "bundle_dir": str(context.bundle_dir), + "work_directory": str(context.work_directory), + "source_directory": None, + "target_secrets": {}, + "attempt_number": 1, + "runtime": runtime.model_dump(mode="json"), + "scenario_key": "trial-key", + "source_scenario_key": "one", + "scenario_id": "trial-id", + } + result_path = tmp_path / "worker-error.json" + + async def fail(*_args, **_kwargs): + raise CallAborted( + "chat target did not start", + code="target_agent_failed", + marker="target_unavailable", + ) + + monkeypatch.setattr(chat_call_runner.HostedChatCallRunner, "run", fail) + monkeypatch.setattr(chat_worker.sys, "stdin", io.StringIO(json.dumps(payload))) + monkeypatch.setattr(chat_worker.sys, "argv", ["chat_worker", str(result_path)]) + + asyncio.run(chat_worker.main()) + + assert json.loads(result_path.read_text()) == { + "error": "chat target did not start", + "error_code": "target_agent_failed", + "error_marker": "target_unavailable", + } diff --git a/tests/harness/test_provider_voice_environment_examples.py b/tests/harness/test_provider_voice_environment_examples.py index dbbb702f9..faa6e1e5c 100644 --- a/tests/harness/test_provider_voice_environment_examples.py +++ b/tests/harness/test_provider_voice_environment_examples.py @@ -189,3 +189,67 @@ def test_conversation_flow_import_backend_implements_all_declared_tool_routes( "create_booking", ): assert f'"kind": "tool.{name}"' in trace + + +def test_import_backend_supports_preference_and_pharmacy_fixtures( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("PROVIDER_TRACE_PATH", str(tmp_path / "provider-trace.jsonl")) + path = ROOT / "import_backend" / "agent.py" + spec = importlib.util.spec_from_file_location("provider_import_backend", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + client = TestClient(module.app) + + preference = client.post( + "/provider/tools/record_preference", + json={"args": {"preference": "weekday mornings"}}, + ).json() + assert preference == {"recorded": True, "preference": "weekday mornings"} + + patient = client.post( + "/provider/tools/find_patient", + json={"arguments": {"phone": "+15551234567"}}, + ).json() + assert patient["found"] is True + assert patient["patient"]["patient_id"] == "pat_001" + + prescriptions = client.post( + "/provider/tools/list_prescriptions", + json={"patient_id": "pat_001"}, + ).json() + assert {item["rx_id"] for item in prescriptions["prescriptions"]} == { + "rx_101", + "rx_102", + } + + accepted = client.post( + "/provider/tools/request_refill", + json={"patient_id": "pat_001", "rx_id": "rx_101"}, + ).json() + assert accepted["accepted"] is True + assert accepted["status"] == "processing" + + refused = client.post( + "/provider/tools/request_refill", + json={"patient_id": "pat_002", "rx_id": "rx_103"}, + ).json() + assert refused == { + "accepted": False, + "reason": "controlled_medication_requires_prescriber", + "rx_id": "rx_103", + } + + status = client.post( + "/provider/tools/check_refill_status", + json={"refill_id": "ref_201"}, + ).json() + assert status["found"] is True + assert status["refill"]["status"] == "ready_for_collection" + + transfer = client.post( + "/provider/tools/transfer_to_pharmacist", + json={"reason": "interaction question"}, + ).json() + assert transfer == {"transferred": True, "reason": "interaction question"} diff --git a/tests/harness/test_usage.py b/tests/harness/test_usage.py index c02e7dd79..62403b4bb 100644 --- a/tests/harness/test_usage.py +++ b/tests/harness/test_usage.py @@ -100,6 +100,19 @@ def request(self, *args, **kwargs): return TransportResponse(200, {"result": {"allowed": True}}, {}) +class _TransientTransport: + def __init__(self) -> None: + self.calls = 0 + + def request(self, *args, **kwargs): + self.calls += 1 + if self.calls == 1: + raise TransportError("temporary TLS failure") + if self.calls == 2: + return TransportResponse(503, {"detail": "busy"}, {}) + return TransportResponse(200, {"result": {"allowed": True}}, {}) + + def test_paid_usage_check_fails_closed_on_transport_error(tmp_path) -> None: reporter = UsageReporter( _capabilities(), @@ -130,6 +143,7 @@ def test_usage_record_logs_when_report_is_not_delivered(tmp_path, caplog) -> Non assert "journaled but not delivered" in caplog.text assert str(record.id) in caplog.text + def test_usage_check_sends_only_the_call_action(tmp_path) -> None: transport = _RecordingTransport() reporter = UsageReporter( @@ -141,3 +155,19 @@ def test_usage_check_sends_only_the_call_action(tmp_path) -> None: reporter.check("text_call") assert transport.body == {"operation": "check", "action": "text_call"} + + +def test_usage_check_retries_transient_transport_and_server_failures( + tmp_path, monkeypatch +) -> None: + transport = _TransientTransport() + reporter = UsageReporter( + _capabilities(), + transport, + UsageJournal(tmp_path / "usage.json", attempt_id="attempt-1"), + ) + monkeypatch.setattr("fi.alk.harness.usage.time.sleep", lambda _seconds: None) + + reporter.check("voice_call") + + assert transport.calls == 3