Foundry hosted agent responses emit failed events

This commit is contained in:
Tao Chen
2026-06-12 15:07:11 -07:00
parent 3d5421edc1
commit d169d15822
2 changed files with 374 additions and 202 deletions
@@ -2869,7 +2869,8 @@ class TestFunctionApprovalRoundTrip:
async def test_approval_response_referencing_unknown_id_fails(self) -> None:
"""Sending an `mcp_approval_response` for a request id that was
never persisted must fail (storage raises KeyError)."""
never persisted must surface as a ``response.failed`` event whose
``error.message`` contains the missing approval request id."""
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])])
)
@@ -2889,9 +2890,15 @@ class TestFunctionApprovalRoundTrip:
"stream": False,
},
)
# The handler raises a KeyError when the storage lookup misses;
# the hosting layer surfaces this as a 5xx response.
assert resp.status_code >= 500
# The handler converts the underlying KeyError into a terminal
# ``response.failed`` event, so non-streaming callers see HTTP 200
# with status="failed" and a meaningful error message rather than
# a generic 5xx response.
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "failed"
error = body.get("error") or {}
assert "apr_unknown" in (error.get("message") or "")
# endregion
@@ -3185,11 +3192,21 @@ class TestCheckpointContextPathValidation:
with patch.object(ResponseContext, "get_input_items", new=AsyncMock(return_value=[])):
context = ResponseContext(**kwargs)
before = sorted(p.name for p in tmp_path.iterdir())
with pytest.raises(RuntimeError, match="Invalid checkpoint context id"):
async for _ in server._handle_inner_workflow(request, context): # pyright: ignore[reportPrivateUsage]
pass
# The handler converts the underlying ``RuntimeError`` into a
# terminal ``response.failed`` event whose error message names
# the rejected context id, so the SSE / non-streaming consumer
# observes a well-formed failure rather than a raw exception.
events = [event async for event in server._handle_inner_workflow(request, context)] # pyright: ignore[reportPrivateUsage]
after = sorted(p.name for p in tmp_path.iterdir())
failed = [e for e in events if getattr(e, "type", None) == "response.failed"]
assert len(failed) == 1, (
f"Expected exactly one response.failed event, got types={[getattr(e, 'type', None) for e in events]}"
)
response_obj = getattr(failed[0], "response", None)
error = getattr(response_obj, "error", None) if response_obj is not None else None
assert error is not None
assert "Invalid checkpoint context id" in (error.message or "")
assert before == after, f"Unexpected filesystem artifacts created for {context_field}={bad_id!r}"
assert list(root.iterdir()) == [], f"Checkpoint dir created inside root for {context_field}={bad_id!r}"
@@ -3204,7 +3221,8 @@ class TestCheckpointContextPathValidation:
("previous_response_id", "caresp_x/../../service-data/api-made-dir" + "A" * 14),
# Restore sink: server-issued conversation id (defense in depth).
# Reaches the checkpoint code and is rejected there, surfacing as
# an HTTP 5xx without creating any filesystem artifacts.
# a terminal ``response.failed`` (HTTP 200, status="failed")
# without creating any filesystem artifacts.
("conversation", "../../escape"),
("conversation", "/tmp/escape-abs"),
],
@@ -3254,12 +3272,20 @@ class TestCheckpointContextPathValidation:
resp = await client.post("/responses", json=payload)
after = sorted(p.name for p in tmp_path.iterdir())
# The request must not succeed; either request validation rejects it
# (4xx) or the checkpoint layer raises and the server returns 5xx.
# Either way, no successful response may be produced.
assert resp.status_code >= 400, (
f"Expected non-2xx for {context_field}={bad_id!r}, got {resp.status_code}: {resp.text[:200]}"
)
# The request must not succeed: either request validation rejects it
# (HTTP 4xx) before reaching the handler, or the checkpoint layer
# raises and the handler converts the failure into a
# ``response.failed`` terminal event (HTTP 200, status="failed").
# Either way, no successful response and no filesystem artifacts.
if resp.status_code == 200:
body = resp.json()
assert body.get("status") == "failed", (
f"Expected status='failed' for {context_field}={bad_id!r}, got {body.get('status')!r}"
)
else:
assert resp.status_code >= 400, (
f"Expected non-2xx for {context_field}={bad_id!r}, got {resp.status_code}: {resp.text[:200]}"
)
assert before == after, (
f"Unexpected filesystem artifacts under tmp_path for {context_field}={bad_id!r}: "
f"before={before} after={after}"
@@ -3445,11 +3471,14 @@ class TestOAuthConsentSurfacing:
resp = await _post(server, input_text="hello", stream=False)
# Non-consent errors are not swallowed: the response is marked failed
# and no `oauth_consent_request` item is emitted.
# and no `oauth_consent_request` item is emitted. The exception
# message is propagated to the client via ``error.message``.
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "failed"
assert not any(it["type"] == "oauth_consent_request" for it in body.get("output", []))
error = body.get("error") or {}
assert error.get("message") == "boom"
agent.run.assert_not_called()
async def test_retry_after_consent_succeeds(self) -> None:
@@ -3477,6 +3506,130 @@ class TestOAuthConsentSurfacing:
agent.run.assert_awaited_once()
# endregion
# region Error handling (response.failed surfacing)
class TestResponseFailedSurfacing:
"""Tests that exceptions raised by the hosted agent are converted into
terminal ``response.failed`` events carrying the exception message,
rather than propagating as 5xx HTTP errors or being replaced by the
orchestrator's generic ``"An internal server error occurred."``
fallback.
"""
async def test_non_streaming_run_failure_emits_response_failed(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
async def _raise(*args: Any, **kwargs: Any) -> AgentResponse:
raise RuntimeError("non-stream kaboom")
agent.run = AsyncMock(side_effect=_raise)
server = _make_server(agent)
resp = await _post(server, input_text="hello", stream=False)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "failed"
error = body.get("error") or {}
assert error.get("message") == "non-stream kaboom"
async def test_streaming_run_failure_emits_response_failed(self) -> None:
async def _raise_stream() -> AsyncIterator[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text("partial ")], role="assistant")
raise RuntimeError("stream kaboom")
agent = MagicMock(spec=RawAgent)
agent.id = "test-agent"
agent.name = "Test Agent"
agent.description = "A mock agent for testing"
agent.context_providers = []
def run_streaming(*args: Any, **kwargs: Any) -> Any:
if kwargs.get("stream"):
return ResponseStream(_raise_stream()) # type: ignore[arg-type]
raise NotImplementedError("Only streaming is configured on this mock")
agent.run = MagicMock(side_effect=run_streaming)
server = _make_server(agent)
resp = await _post(server, input_text="hello", stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[1] == "response.in_progress"
# Last lifecycle event must be ``response.failed``, never ``response.completed``.
assert types[-1] == "response.failed"
assert "response.completed" not in types
failed = [e for e in events if e["event"] == "response.failed"]
assert len(failed) == 1
response_payload = failed[0]["data"].get("response") or {}
error = response_payload.get("error") or {}
assert error.get("message") == "stream kaboom"
async def test_streaming_run_failure_drains_pending_output_item(self) -> None:
"""If a streaming output item was open when the failure happens, the
handler must close it before emitting ``response.failed`` so the SSE
stream stays well-formed (every ``output_item.added`` has a matching
``output_item.done``).
"""
async def _raise_stream() -> AsyncIterator[AgentResponseUpdate]:
# Open a text output item, then blow up before it closes.
yield AgentResponseUpdate(contents=[Content.from_text("hello ")], role="assistant")
raise RuntimeError("mid-item kaboom")
agent = MagicMock(spec=RawAgent)
agent.id = "test-agent"
agent.name = "Test Agent"
agent.description = "A mock agent for testing"
agent.context_providers = []
def run_streaming(*args: Any, **kwargs: Any) -> Any:
return ResponseStream(_raise_stream()) # type: ignore[arg-type]
agent.run = MagicMock(side_effect=run_streaming)
server = _make_server(agent)
resp = await _post(server, input_text="hello", stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types.count("response.output_item.added") == types.count("response.output_item.done")
assert types[-1] == "response.failed"
async def test_workflow_agent_run_failure_emits_response_failed(self) -> None:
"""Exceptions raised by a hosted ``WorkflowAgent`` are converted into a
terminal ``response.failed`` event in the same way as the regular
agent path.
"""
workflow_agent = _build_text_workflow_agent("ignored")
async def _raise(*args: Any, **kwargs: Any) -> AgentResponse:
raise RuntimeError("workflow kaboom")
# Patch the public ``run`` to fail. ``_handle_inner_workflow`` only
# invokes the agent once (no checkpoint to restore on a fresh
# request), so this is the call that will raise.
with patch.object(workflow_agent, "run", side_effect=_raise):
server = _make_server(workflow_agent)
resp = await _post(server, input_text="hello", stream=False)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "failed"
error = body.get("error") or {}
assert error.get("message") == "workflow kaboom"
# endregion
# region Workflow agent hosting (end-to-end)