mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Foundry hosted agent responses emit failed events
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user