diff --git a/roboco/mcp/do_server.py b/roboco/mcp/do_server.py index e782fe57..c06a1c2b 100644 --- a/roboco/mcp/do_server.py +++ b/roboco/mcp/do_server.py @@ -147,6 +147,76 @@ def _classify_rejection(payload: dict[str, Any]) -> str | None: return None +def _remediate_for_kind(kind: str, verb: str) -> str: + """A directed recovery hint for a synthesized Envelope kind. + + Mirrors flow_server: the orchestrator's exception handlers return a dict + `error` with no `remediate`; without a hint the agent has no directed next + action and flails until the breaker trips. + """ + if kind == "not_found": + return ( + "the call targeted a resource that does not exist; re-fetch state" + " and retry on a current id; do not retry the same id." + ) + if kind == "incomplete_input": + return ( + f"the {verb} call was rejected as incomplete input — re-issue it" + f" with the missing/invalid fields (see `detail` for the exact" + f" validation errors); do not retry blindly." + ) + if kind == "not_authorized": + return ( + f"you are not authorized for this {verb} action; use delegate /" + f" escalate_up, or call i_am_blocked(reason=...) if the gate is" + f" genuinely wrong; do not retry the same action." + ) + return ( + f"service error on {verb} — re-fetch state and re-issue; call" + f" i_am_blocked or i_am_idle if it persists; do not retry blindly." + ) + + +def _normalize_exception_envelope( + payload: dict[str, Any], path: str +) -> dict[str, Any] | None: + """Synthesize an Envelope-wire-format dict from an exception-handler body. + + Mirrors flow_server (#232): FastAPI's exception handlers return ``error`` + as a DICT (``{code, message, details?}``) or a bare ``detail`` list (422) — + neither is the Envelope wire format the agent trusts (string ``error`` + + ``message`` + ``remediate`` + ``missing``). This lifts the body into a real + Envelope so the agent gets a directed remediate instead of flailing until + the breaker trips. Returns None for a real Envelope (string ``error`` or + success) so those pass through unchanged. + """ + error = payload.get("error") + if isinstance(error, dict): + code = str(error.get("code") or "") + kind = _classify_dict_error_code(code) + if kind is None: + kind = "not_found" + verb = _verb_from_path(path) + message = str(error.get("message") or "") or f"orchestrator error ({code})" + return { + "error": kind, + "message": message, + "remediate": _remediate_for_kind(kind, verb), + "missing": [], + "details": error, + } + if "detail" in payload and "error" not in payload: + verb = _verb_from_path(path) + return { + "error": "incomplete_input", + "message": f"the {verb} call was rejected as incomplete input", + "remediate": _remediate_for_kind("incomplete_input", verb), + "missing": [], + "detail": payload.get("detail"), + } + return None + + mcp = FastMCP("roboco-do") log = structlog.get_logger() @@ -255,6 +325,14 @@ def _post(path: str, body: dict[str, Any]) -> dict[str, Any]: "missing": [], } # Outside the orchestrator client so the SDK call is its own connection. + # A non-404 JSON body that is NOT a real Envelope (dict `error` from an + # exception handler, or a 422 `detail` list) is normalized to the Envelope + # wire format so the agent gets a string `error` kind + remediate (#232). + # Mirrors flow_server; the synthesized Envelope still flows through the + # breaker below — its string kind is in the counted set. + normalized = _normalize_exception_envelope(payload, path) + if normalized is not None: + payload = normalized return _record_and_check_circuit(path, body, payload) @@ -326,6 +404,11 @@ def _record_and_check_circuit( # breaker tripped, not WHY the verb failed (#60). Mirrors flow_server. circuit_env: dict[str, Any] = dict(status["circuit_envelope"]) circuit_env["inner"] = payload + # Lift task_id/correlation_id from the original rejection to the top + # level (the SDK's envelope omits them) so the agent's envelope contract + # and ops audit-join still work — not just nested in `inner` (#359). + circuit_env["task_id"] = payload.get("task_id") + circuit_env["correlation_id"] = payload.get("correlation_id") log.info( "do_server: circuit_open substituted for rejection", verb=verb, diff --git a/roboco/mcp/flow_server.py b/roboco/mcp/flow_server.py index d8c88155..f47008a8 100644 --- a/roboco/mcp/flow_server.py +++ b/roboco/mcp/flow_server.py @@ -127,6 +127,92 @@ def _classify_dict_error_code(code: str) -> str | None: return "invalid_state" +def _remediate_for_kind(kind: str, verb: str) -> str: + """A directed recovery hint for a synthesized Envelope kind. + + The orchestrator's exception handlers return a dict `error` with no + `remediate`; without a hint the agent has no directed next action and + flails/respawn-loops until the breaker trips. This gives each counted kind + (and not_found) a one-line remedy that mirrors the string-kind envelopes. + """ + if kind == "not_found": + return ( + "the call targeted a resource that does not exist; re-fetch the" + " task state (give_me_work / resume) and retry on a current id;" + " do not retry the same id." + ) + if kind == "incomplete_input": + return ( + f"the {verb} call was rejected as incomplete input — re-issue it" + f" with the missing/invalid fields (see `detail` for the exact" + f" validation errors); do not retry blindly." + ) + if kind == "not_authorized": + return ( + f"you are not authorized for this {verb} action; use delegate /" + f" escalate_up, or call i_am_blocked(reason=...) if the gate is" + f" genuinely wrong; do not retry the same action." + ) + # invalid_state + any fallback (service/INTERNAL_ERROR). + return ( + f"service error on {verb} — the task may be in the wrong state for" + f" this verb. Re-fetch state (resume / give_me_work) and re-issue;" + f" call i_am_blocked or i_am_idle if it persists; do not retry blindly." + ) + + +def _normalize_exception_envelope( + payload: dict[str, Any], path: str +) -> dict[str, Any] | None: + """Synthesize an Envelope-wire-format dict from an exception-handler body. + + FastAPI's exception handlers return ``error`` as a DICT + (``{code, message, details?}`` from ``roboco_exception_handler`` / + ``http_exception_handler`` / ``generic_exception_handler``) or a bare + ``detail`` list (``request_validation_handler``, 422) — neither is the + Envelope wire format the agent is prompted to trust (string ``error`` kind + + ``message`` + ``remediate`` + ``missing``). Returning either raw violates + the contract: the agent has no ``remediate``/``next`` and flails until the + breaker trips (#232). This lifts the dict/422 body into a real Envelope: + + - dict ``error`` → map ``code`` via :func:`_classify_dict_error_code` to a + counted string kind (NOT_FOUND → ``not_found``), lift ``message``, synthesize + a ``remediate``, ``missing=[]``, and tuck the original body under + ``details`` for correlation traceability. + - 422 ``detail`` (no ``error``) → ``error='incomplete_input'`` with the + validation ``detail`` preserved so the agent sees WHICH fields failed. + + Returns None when ``payload`` is already a real Envelope (string ``error`` + or success) so successful/string-kind rejections pass through unchanged. + """ + error = payload.get("error") + if isinstance(error, dict): + code = str(error.get("code") or "") + kind = _classify_dict_error_code(code) + if kind is None: + kind = "not_found" + verb = _verb_from_path(path) + message = str(error.get("message") or "") or f"orchestrator error ({code})" + return { + "error": kind, + "message": message, + "remediate": _remediate_for_kind(kind, verb), + "missing": [], + "details": error, + } + if "detail" in payload and "error" not in payload: + # 422 request-validation body ({"detail": [...], "body": ...}). + verb = _verb_from_path(path) + return { + "error": "incomplete_input", + "message": f"the {verb} call was rejected as incomplete input", + "remediate": _remediate_for_kind("incomplete_input", verb), + "missing": [], + "detail": payload.get("detail"), + } + return None + + def _classify_rejection(payload: dict[str, Any]) -> str | None: """Return the breaker kind to forward for this payload, or None. @@ -284,6 +370,14 @@ def _post(path: str, body: dict[str, Any]) -> dict[str, Any]: } # Outside the orchestrator client context so the SDK call is its own # connection — keeps semantics independent and timeouts separated. + # A non-404 JSON body that is NOT a real Envelope (dict `error` from an + # exception handler, or a 422 `detail` list) is normalized to the Envelope + # wire format so the agent gets a string `error` kind + remediate (#232). + # The synthesized Envelope still flows through the breaker below — its + # string kind is in the counted set, so a 500/422 storm trips it. + normalized = _normalize_exception_envelope(payload, path) + if normalized is not None: + payload = normalized return _record_and_check_circuit(path, body, payload) @@ -358,6 +452,12 @@ def _record_and_check_circuit( # verb failed, and the agent still needs the underlying hint (#60). circuit_env: dict[str, Any] = dict(status["circuit_envelope"]) circuit_env["inner"] = payload + # The SDK's circuit_envelope omits task_id/correlation_id; lift them + # from the original rejection to the top level so the agent's envelope + # contract (read top-level task_id/correlation_id) and ops audit-join of + # the trip event still work — not just nested in `inner` (#359). + circuit_env["task_id"] = payload.get("task_id") + circuit_env["correlation_id"] = payload.get("correlation_id") log.info( "flow_server: circuit_open substituted for rejection", verb=verb, diff --git a/roboco/mcp/intake_server.py b/roboco/mcp/intake_server.py index 80ef49b5..7964a0c8 100644 --- a/roboco/mcp/intake_server.py +++ b/roboco/mcp/intake_server.py @@ -20,6 +20,7 @@ provides ``ROBOCO_API_URL`` + ``ROBOCO_PROMPTER_SESSION_ID``. from __future__ import annotations +import json import os from typing import Any @@ -55,7 +56,15 @@ async def _post_event( if owns: await http.aclose() if not resp.is_success: - return {"error": f"http_{resp.status_code}"} + # Capture the relay's body so the caller can surface the real reason + # (e.g. 'session not in MegaTask scope' on a 422) instead of an opaque + # `http_422` token with no remediation (#57). None when the body is + # absent or not JSON. + try: + body = resp.json() + except (ValueError, json.JSONDecodeError): + body = None + return {"error": f"http_{resp.status_code}", "detail": body} return {"ok": True} diff --git a/tests/unit/mcp_servers/test_do_server_circuit_breaker.py b/tests/unit/mcp_servers/test_do_server_circuit_breaker.py index 6ba1adf6..a845da4e 100644 --- a/tests/unit/mcp_servers/test_do_server_circuit_breaker.py +++ b/tests/unit/mcp_servers/test_do_server_circuit_breaker.py @@ -273,13 +273,15 @@ def test_verb_extracted_from_path(do_module: types.ModuleType) -> None: assert sdk_body["verb"] == "commit" -def test_dict_shaped_error_does_not_crash(do_module: types.ModuleType) -> None: +def test_dict_shaped_error_normalized_to_envelope(do_module: types.ModuleType) -> None: """A RobocoError.to_dict()-shaped response must not TypeError the breaker. A dict-shaped `error` is a retry-storm-worthy rejection (the orchestrator's exception handlers surface this shape on 4xx/5xx), so the breaker must count - it via the classifier rather than passing it through silently. The original - dict payload still reaches the agent (the breaker only substitutes when open). + it via the classifier. The dict body is normalized to an Envelope wire format + (#232): the agent receives a string `error` kind (not the dict, which violates + the Envelope contract) with the message lifted and a remediate. The breaker + still counts it (the synthesized string kind is in the counted set). """ factory, captured = _make_client( orchestrator_response={ @@ -299,13 +301,16 @@ def test_dict_shaped_error_does_not_crash(do_module: types.ModuleType) -> None: "circuit_envelope": None, }, ) - # No TypeError; the dict-shaped rejection is forwarded to the SDK. + # No TypeError; the dict-shaped rejection is normalized + forwarded to the SDK. with patch("httpx.Client", side_effect=factory): result = do_module.dm(recipient="qa-all", text="x") - # Original dict payload still reaches the agent (breaker not open). - assert isinstance(result["error"], dict) - assert result["error"]["code"] == "A2A_ACCESS_DENIED" - # SDK breaker MUST now be called so a storm of these counts. + # Normalized to a string-kind Envelope — the dict `error` never reaches the + # agent (#232). A2A_ACCESS_DENIED maps to not_authorized (exact-code, #161). + assert result["error"] == "not_authorized" + assert result["message"] == "be-qa cannot A2A with qa-all" + assert isinstance(result["remediate"], str) and result["remediate"] + assert result["missing"] == [] + # SDK breaker MUST still be called so a storm of these counts. sdk_calls = [(url, body) for url, body in captured if "test-sdk" in url] assert len(sdk_calls) == 1 # ACCESS_DENIED maps to the not_authorized counted kind. @@ -460,3 +465,123 @@ def test_missing_route_404_returns_envelope_and_counts( _, sdk_body = sdk_calls[0] assert sdk_body is not None assert sdk_body["rejection_kind"] == "invalid_state" + + +# --------------------------------------------------------------------------- +# #232: a non-404 JSON exception-handler body (dict `error` / 422 `detail`) +# must be normalized to an Envelope wire format before reaching the agent. +# Mirrors flow_server. +# --------------------------------------------------------------------------- + + +def test_422_detail_normalized_to_incomplete_input_envelope( + do_module: types.ModuleType, +) -> None: + """A 422 body (`{"detail": [...]}`, no `error`) is normalized to an Envelope + with `error='incomplete_input'`, a non-empty `remediate`, `missing=[]`, and + the raw validation `detail` preserved. The breaker still counts it. Mirrors + flow_server (#232).""" + detail_body = [ + {"loc": ["body", "text"], "msg": "field required", "type": "missing"} + ] + factory, captured = _make_client( + orchestrator_response={"detail": detail_body, "body": None}, + sdk_response={ + "verb": "note", + "task_id": None, + "attempts": 1, + "limit": 3, + "window_seconds": 60, + "open": False, + "circuit_envelope": None, + }, + ) + with patch("httpx.Client", side_effect=factory): + result = do_module.note(text="") + assert result["error"] == "incomplete_input" + assert isinstance(result["remediate"], str) and result["remediate"] + assert result["missing"] == [] + assert result["detail"] == detail_body + sdk_calls = [(url, body) for url, body in captured if "test-sdk" in url] + assert len(sdk_calls) == 1 + assert sdk_calls[0][1]["rejection_kind"] == "incomplete_input" + + +def test_dict_internal_error_normalized_to_invalid_state_envelope( + do_module: types.ModuleType, +) -> None: + """A dict-shaped INTERNAL_ERROR (generic_exception_handler) is normalized to + `error='invalid_state'` with the message lifted + a remediate. Mirrors + flow_server (#232).""" + factory, captured = _make_client( + orchestrator_response={ + "error": { + "code": "INTERNAL_ERROR", + "message": "An internal error occurred", + "details": {"correlation_id": "abc"}, + } + }, + sdk_response={ + "verb": "commit", + "task_id": None, + "attempts": 1, + "limit": 3, + "window_seconds": 60, + "open": False, + "circuit_envelope": None, + }, + ) + with patch("httpx.Client", side_effect=factory): + result = do_module.commit(message="[abc12345] a valid commit message here") + assert result["error"] == "invalid_state" + assert result["message"] == "An internal error occurred" + assert isinstance(result["remediate"], str) and result["remediate"] + assert result["missing"] == [] + sdk_calls = [(url, body) for url, body in captured if "test-sdk" in url] + assert len(sdk_calls) == 1 + assert sdk_calls[0][1]["rejection_kind"] == "invalid_state" + + +# --------------------------------------------------------------------------- +# #359: the circuit_open substitution lifts task_id/correlation_id from the +# original rejection to the top-level envelope. Mirrors flow_server. +# --------------------------------------------------------------------------- + + +def test_circuit_open_lifts_task_id_and_correlation_id( + do_module: types.ModuleType, +) -> None: + """The SDK's circuit_envelope omits task_id/correlation_id; the substitution + lifts them from the original rejection to the top level so the agent's + envelope contract and ops audit-join still work. Mirrors flow_server (#359).""" + circuit_env = { + "error": "circuit_open", + "message": "verb 'note' rejected too often (3 in 60s)", + "remediate": "fix the missing fields once, then retry", + "missing": [], + } + factory, _ = _make_client( + orchestrator_response={ + "error": "incomplete_input", + "missing": ["context"], + "remediate": "fill context", + "task_id": "T7", + "correlation_id": "C7", + }, + sdk_response={ + "verb": "note", + "task_id": "T7", + "attempts": 3, + "limit": 3, + "window_seconds": 60, + "open": True, + "circuit_envelope": circuit_env, + }, + ) + with patch("httpx.Client", side_effect=factory): + result = do_module.note(text="x", scope="decision") + assert result["error"] == "circuit_open" + assert result["task_id"] == "T7" + assert result["correlation_id"] == "C7" + assert result["inner"]["error"] == "incomplete_input" + assert result["inner"]["task_id"] == "T7" diff --git a/tests/unit/mcp_servers/test_flow_server_circuit_breaker.py b/tests/unit/mcp_servers/test_flow_server_circuit_breaker.py index 1fb29f78..0ed557c9 100644 --- a/tests/unit/mcp_servers/test_flow_server_circuit_breaker.py +++ b/tests/unit/mcp_servers/test_flow_server_circuit_breaker.py @@ -547,11 +547,13 @@ def test_dict_shaped_internal_error_counts_as_invalid_state( assert sdk_calls[0][1]["rejection_kind"] == "invalid_state" -def test_dict_shaped_not_found_does_not_count( +def test_dict_shaped_not_found_normalized_to_envelope_and_not_counted( flow_module: types.ModuleType, ) -> None: - """A dict-shaped NOT_FOUND (404 family) does NOT count — parity with the - string-error contract that a `not_found` rejection isn't counted. + """A dict-shaped NOT_FOUND (404 family) is normalized to an Envelope + (`error="not_found"` string + lifted message + remediate) — the agent + never sees a dict `error`, which violates the Envelope wire format — and + the breaker is NOT touched (parity with the string `not_found` contract). """ factory, captured = _make_client( orchestrator_response={ @@ -561,8 +563,13 @@ def test_dict_shaped_not_found_does_not_count( ) with patch("httpx.Client", side_effect=factory): result = flow_module.i_am_done("task-A") - assert isinstance(result["error"], dict) - assert result["error"]["code"] == "TASK_NOT_FOUND" + # Normalized to a string-kind Envelope — the dict `error` never reaches + # the agent (#232). + assert result["error"] == "not_found" + assert result["message"] == "no such task" + assert isinstance(result["remediate"], str) and result["remediate"] + assert result["missing"] == [] + # NOT_FOUND still does not count toward the breaker. assert all("test-sdk" not in url for url, _ in captured) @@ -630,3 +637,165 @@ def test_missing_route_404_returns_envelope_and_counts( _, sdk_body = sdk_calls[0] assert sdk_body is not None assert sdk_body["rejection_kind"] == "invalid_state" + + +# --------------------------------------------------------------------------- +# #232: a non-404 JSON exception-handler body (dict `error` / 422 `detail`) +# must be normalized to an Envelope wire format before reaching the agent. +# --------------------------------------------------------------------------- + + +def test_exception_dict_500_normalized_to_invalid_state_envelope( + flow_module: types.ModuleType, +) -> None: + """A 500 with a dict-shaped error (generic_exception_handler) is normalized + to an Envelope: top-level `error='invalid_state'` (string kind), the dict's + `message` lifted to top-level `message`, a non-empty `remediate`, `missing=[]`. + The agent never receives a dict `error` (which violates the Envelope contract + it's prompted to trust). The breaker still counts it as `invalid_state`.""" + factory, captured = _make_client( + orchestrator_response={ + "error": { + "code": "INTERNAL_ERROR", + "message": "An internal error occurred", + "details": {"correlation_id": "abc"}, + } + }, + sdk_response={ + "verb": "i_am_done", + "task_id": "task-A", + "attempts": 1, + "limit": 3, + "window_seconds": 60, + "open": False, + "circuit_envelope": None, + }, + ) + with patch("httpx.Client", side_effect=factory): + result = flow_module.i_am_done("task-A") + assert result["error"] == "invalid_state" + assert result["message"] == "An internal error occurred" + assert isinstance(result["remediate"], str) and result["remediate"] + assert result["missing"] == [] + # The synthesized Envelope still flows through the breaker (string kind in + # the counted set) so a 500 storm trips it. + sdk_calls = [(url, body) for url, body in captured if "test-sdk" in url] + assert len(sdk_calls) == 1 + assert sdk_calls[0][1]["rejection_kind"] == "invalid_state" + + +def test_422_detail_normalized_to_incomplete_input_envelope( + flow_module: types.ModuleType, +) -> None: + """A 422 request-validation body (`{"detail": [...]}`, no `error`) is + normalized to an Envelope with `error='incomplete_input'`, a non-empty + `remediate`, and `missing=[]`; the raw validation `detail` is preserved so + the agent can see WHICH fields failed. The breaker still counts it.""" + detail_body = [ + {"loc": ["body", "task_id"], "msg": "field required", "type": "missing"} + ] + factory, captured = _make_client( + orchestrator_response={"detail": detail_body, "body": None}, + sdk_response={ + "verb": "i_am_done", + "task_id": "task-A", + "attempts": 1, + "limit": 3, + "window_seconds": 60, + "open": False, + "circuit_envelope": None, + }, + ) + with patch("httpx.Client", side_effect=factory): + result = flow_module.i_am_done("task-A") + assert result["error"] == "incomplete_input" + assert isinstance(result["remediate"], str) and result["remediate"] + assert result["missing"] == [] + # The validation detail survives so the agent knows which fields to fix. + assert result["detail"] == detail_body + sdk_calls = [(url, body) for url, body in captured if "test-sdk" in url] + assert len(sdk_calls) == 1 + assert sdk_calls[0][1]["rejection_kind"] == "incomplete_input" + + +def test_dict_authorized_normalized_to_not_authorized_envelope( + flow_module: types.ModuleType, +) -> None: + """A dict-shaped PERMISSION_DENIED maps to `error='not_authorized'` in the + normalized Envelope (exact-code map, not a substring accident — #161), with + the message lifted and a remediate. The breaker counts it as not_authorized.""" + factory, captured = _make_client( + orchestrator_response={ + "error": { + "code": "PERMISSION_DENIED", + "message": "you may not merge that PR", + "details": {}, + } + }, + sdk_response={ + "verb": "i_am_done", + "task_id": "task-A", + "attempts": 1, + "limit": 3, + "window_seconds": 60, + "open": False, + "circuit_envelope": None, + }, + ) + with patch("httpx.Client", side_effect=factory): + result = flow_module.i_am_done("task-A") + assert result["error"] == "not_authorized" + assert result["message"] == "you may not merge that PR" + assert isinstance(result["remediate"], str) and result["remediate"] + assert result["missing"] == [] + sdk_calls = [(url, body) for url, body in captured if "test-sdk" in url] + assert len(sdk_calls) == 1 + assert sdk_calls[0][1]["rejection_kind"] == "not_authorized" + + +# --------------------------------------------------------------------------- +# #359: the circuit_open substitution lifts task_id/correlation_id from the +# original rejection to the top-level envelope (the SDK's envelope omits them). +# --------------------------------------------------------------------------- + + +def test_circuit_open_lifts_task_id_and_correlation_id( + flow_module: types.ModuleType, +) -> None: + """When the breaker trips, the SDK's circuit_envelope carries no task_id / + correlation_id, but the original rejection payload does (the orchestrator + stamps them on every envelope). The substitution must lift them to the + top-level so the agent's envelope contract (read top-level task_id / + correlation_id) and ops audit-join still work — not leave them only nested + in `inner`.""" + circuit_env: dict[str, Any] = { + "error": "circuit_open", + "message": "verb 'i_am_done' rejected 3 times in 60s — breaker open", + "remediate": "call i_am_blocked or i_am_idle", + } + factory, _ = _make_client( + orchestrator_response={ + "error": "tracing_gap", + "remediate": "open the PR", + "task_id": "T1", + "correlation_id": "C1", + }, + sdk_response={ + "verb": "i_am_done", + "task_id": "T1", + "attempts": 3, + "limit": 3, + "window_seconds": 60, + "open": True, + "circuit_envelope": circuit_env, + }, + ) + with patch("httpx.Client", side_effect=factory): + result = flow_module.i_am_done("task-A") + assert result["error"] == "circuit_open" + # Lifted from the original rejection to the top level (not just in inner). + assert result["task_id"] == "T1" + assert result["correlation_id"] == "C1" + # The original rejection still survives nested. + assert result["inner"]["error"] == "tracing_gap" + assert result["inner"]["task_id"] == "T1" diff --git a/tests/unit/mcp_servers/test_intake_server.py b/tests/unit/mcp_servers/test_intake_server.py index 1fe9b33e..31795770 100644 --- a/tests/unit/mcp_servers/test_intake_server.py +++ b/tests/unit/mcp_servers/test_intake_server.py @@ -70,7 +70,49 @@ async def test_post_draft_reports_http_error() -> None: async with _client(handler) as client: result = await intake_server.post_draft("s", {}, client=client) - assert result == {"error": "http_503"} + # A no-body failure still surfaces the status; ``detail`` is None when the + # body is absent/unparseable (#57 — the body is now captured, not dropped). + assert result == {"error": "http_503", "detail": None} + + +@pytest.mark.asyncio +async def test_post_event_captures_body_on_non_success() -> None: + """#57: on a non-success response the relay's body carries the real reason + (e.g. 'session not in MegaTask scope' on a 422); _post_event must capture it + under ``detail`` so the intake agent gets actionable remediation instead of an + opaque ``http_422`` token.""" + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(422, json={"detail": "session not in MegaTask scope"}) + + async with _client(handler) as client: + result = await intake_server.post_draft("s", {}, client=client) + assert result["error"] == "http_422" + assert result["detail"] == {"detail": "session not in MegaTask scope"} + + +@pytest.mark.asyncio +async def test_propose_batch_result_string_includes_relay_detail( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The propose_batch failure string surfaces the captured relay body so the + grok intake agent can tell whether to re-shape the draft or surface to the + CEO (#57). ``post_batch`` is stubbed to the dict _post_event now returns when + the relay rejects with a body.""" + + async def _relay_rejected( + _session_id: str, _payload: dict[str, Any], **_kw: Any + ) -> dict[str, Any]: + return { + "error": "http_422", + "detail": {"detail": "session not in MegaTask scope"}, + } + + monkeypatch.setattr(intake_server, "post_batch", _relay_rejected) + monkeypatch.setenv("ROBOCO_PROMPTER_SESSION_ID", "sess-1") + msg = await intake_server.propose_batch([{"title": "A"}], "MegaTask") + assert "Could not submit the MegaTask to the panel" in msg + assert "session not in MegaTask scope" in msg @pytest.mark.asyncio