[chore] mcp-servers: normalize exception bodies to Envelope + lift task_id/correlation_id on circuit_open (#232 #359 #57)

flow_server/do_server: the non-404 JSON path returned exception-handler bodies
raw (dict `error` from roboco/generic/http exception handlers, or a 422
`detail` list) — neither is the Envelope wire format the agent is prompted to
trust (string error kind + message + remediate + missing), so on any
service/validation failure the agent got no remediate and flailed until the
breaker tripped. _normalize_exception_envelope lifts the body into a real
Envelope (code -> counted string kind via _classify_dict_error_code, NOT_FOUND
-> not_found, message lifted, remediate synthesized, missing=[]; 422 -> incomplete_input with the validation detail preserved). The synthesized
envelope still flows through the breaker so a 500/422 storm trips it.

_record_and_check_circuit: the circuit_open substitution dropped task_id /
correlation_id from the top level (the SDK's envelope omits them); lift them
from the original rejection so the agent's envelope contract and ops audit-join
of the trip event still work, not just nested in inner.

intake_server._post_event: capture the relay response body under `detail` on
non-success so the grok intake agent gets the real reason (e.g. 'session not in
MegaTask scope' on a 422) instead of an opaque http_422 token with no
remediation.

TDD red->green; ruff + mypy clean; 157 mcp/SDK-breaker tests pass.
This commit is contained in:
Renn F
2026-06-30 18:38:57 +02:00
parent 0bf6c8484e
commit 0d714b6cc1
6 changed files with 543 additions and 15 deletions
+43 -1
View File
@@ -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