chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""HTTP round-trip tests: POST -> FastAPI route -> JSON/SSE response.
|
||||
|
||||
These exercise the same wiring as the `local_responses` sample: helpers from
|
||||
`agent_framework_hosting_responses` convert between the Responses protocol and
|
||||
Agent Framework run values, `agent_framework_hosting`'s `AgentState` /
|
||||
`SessionStore` hold shared execution state, and a small FastAPI route owns
|
||||
everything else (parsing, policy, response construction). Requests go through
|
||||
`httpx.AsyncClient` with `ASGITransport` -- no real server process or live
|
||||
model is involved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Awaitable, Mapping
|
||||
from typing import Any, Literal, overload
|
||||
|
||||
import httpx
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentRunInputs,
|
||||
AgentSession,
|
||||
Content,
|
||||
Message,
|
||||
ResponseStream,
|
||||
)
|
||||
from agent_framework_hosting import AgentState
|
||||
from fastapi import Body, FastAPI, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from agent_framework_hosting_responses import (
|
||||
create_response_id,
|
||||
responses_from_run,
|
||||
responses_from_streaming_run,
|
||||
responses_session_id,
|
||||
responses_to_run,
|
||||
)
|
||||
|
||||
|
||||
class _StubAgent:
|
||||
"""Deterministic ``SupportsAgentRun`` stub that tracks session continuity.
|
||||
|
||||
Each call records the ``session_id`` of the ``AgentSession`` it was
|
||||
invoked with and a per-session turn counter, so tests can assert that a
|
||||
chain of requests reused one session instead of silently starting fresh
|
||||
ones.
|
||||
"""
|
||||
|
||||
id = "stub-agent"
|
||||
name: str | None = "stub-agent"
|
||||
description: str | None = "stub agent for HTTP round-trip tests"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.session_ids_seen: list[str | None] = []
|
||||
self.turn_counts: dict[str | None, int] = {}
|
||||
|
||||
def create_session(self, *, session_id: str | None = None) -> AgentSession:
|
||||
return AgentSession(session_id=session_id)
|
||||
|
||||
def get_session(self, service_session_id: Any, *, session_id: str | None = None) -> AgentSession:
|
||||
return AgentSession(session_id=session_id, service_session_id=service_session_id)
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
stream: Literal[False] = ...,
|
||||
session: AgentSession | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
) -> Awaitable[AgentResponse[Any]]: ...
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
stream: Literal[True],
|
||||
session: AgentSession | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
|
||||
|
||||
def run(
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
|
||||
session_id = session.session_id if session is not None else None
|
||||
self.session_ids_seen.append(session_id)
|
||||
self.turn_counts[session_id] = self.turn_counts.get(session_id, 0) + 1
|
||||
text = f"turn {self.turn_counts[session_id]} for session {session_id}"
|
||||
|
||||
if stream:
|
||||
|
||||
async def _stream() -> AsyncIterator[AgentResponseUpdate]:
|
||||
yield AgentResponseUpdate(contents=[Content.from_text(text=text)], role="assistant")
|
||||
|
||||
return ResponseStream(_stream(), finalizer=lambda updates: AgentResponse.from_updates(updates))
|
||||
|
||||
async def _get_response() -> AgentResponse[Any]:
|
||||
return AgentResponse(messages=Message(role="assistant", contents=[Content.from_text(text=text)]))
|
||||
|
||||
return _get_response()
|
||||
|
||||
|
||||
def _build_app(agent: _StubAgent) -> FastAPI:
|
||||
"""Build a minimal FastAPI app mirroring the `local_responses` sample's route."""
|
||||
app = FastAPI()
|
||||
state = AgentState(agent)
|
||||
|
||||
@app.post("/responses", response_model=None)
|
||||
async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | StreamingResponse: # noqa: B008
|
||||
try:
|
||||
run = responses_to_run(body)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
session_id = responses_session_id(body)
|
||||
response_id = create_response_id()
|
||||
|
||||
target = await state.get_target()
|
||||
lookup_id = session_id or response_id
|
||||
session = await state.get_or_create_session(lookup_id)
|
||||
|
||||
if run["stream"]:
|
||||
stream = target.run(run["messages"], stream=True, session=session)
|
||||
if not isinstance(stream, ResponseStream):
|
||||
raise HTTPException(status_code=500, detail="agent did not return a response stream")
|
||||
|
||||
async def stream_events() -> AsyncIterator[str]:
|
||||
async for event in responses_from_streaming_run(
|
||||
stream,
|
||||
response_id=response_id,
|
||||
session_id=session_id,
|
||||
):
|
||||
yield event
|
||||
await state.set_session(response_id, session)
|
||||
|
||||
return StreamingResponse(
|
||||
stream_events(),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
result = await target.run(run["messages"], session=session)
|
||||
await state.set_session(response_id, session)
|
||||
return JSONResponse(responses_from_run(result, response_id=response_id, session_id=session_id))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _post(app: FastAPI, payload: dict[str, Any]) -> httpx.Response:
|
||||
"""Send a POST /responses request through the ASGI app, no real socket involved."""
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
return await client.post("/responses", json=payload, timeout=30)
|
||||
|
||||
|
||||
def _parse_sse_events(body: str) -> list[dict[str, Any]]:
|
||||
"""Parse SSE text into a list of `{"event": ..., "data": ...}` dicts."""
|
||||
events: list[dict[str, Any]] = []
|
||||
for block in body.split("\n\n"):
|
||||
if not block.strip():
|
||||
continue
|
||||
event_type: str | None = None
|
||||
data: str | None = None
|
||||
for line in block.split("\n"):
|
||||
if line.startswith("event: "):
|
||||
event_type = line[len("event: ") :]
|
||||
elif line.startswith("data: "):
|
||||
data = line[len("data: ") :]
|
||||
if event_type is not None and data is not None:
|
||||
events.append({"event": event_type, "data": json.loads(data)})
|
||||
return events
|
||||
|
||||
|
||||
class TestNonStreamingRoundTrip:
|
||||
async def test_returns_responses_shaped_payload(self) -> None:
|
||||
app = _build_app(_StubAgent())
|
||||
response = await _post(app, {"input": "hello"})
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["object"] == "response"
|
||||
assert payload["status"] == "completed"
|
||||
assert payload["id"].startswith("resp_")
|
||||
assert any(item["type"] == "message" for item in payload["output"])
|
||||
|
||||
async def test_invalid_input_returns_400_not_500(self) -> None:
|
||||
app = _build_app(_StubAgent())
|
||||
response = await _post(app, {})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "input" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestStreamingRoundTrip:
|
||||
async def test_stream_emits_created_delta_and_completed_events(self) -> None:
|
||||
app = _build_app(_StubAgent())
|
||||
response = await _post(app, {"input": "hello", "stream": True})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "text/event-stream" in response.headers["content-type"]
|
||||
|
||||
events = _parse_sse_events(response.text)
|
||||
event_types = [e["event"] for e in events]
|
||||
assert event_types[0] == "response.created"
|
||||
assert event_types[-1] == "response.completed"
|
||||
assert "response.output_text.delta" in event_types
|
||||
|
||||
completed = events[-1]["data"]["response"]
|
||||
assert completed["status"] == "completed"
|
||||
assert completed["id"].startswith("resp_")
|
||||
|
||||
|
||||
class TestSessionContinuity:
|
||||
"""Regression coverage for the `previous_response_id` aliasing fix.
|
||||
|
||||
`previous_response_id` rotates every turn. Without aliasing the newly
|
||||
minted response id to the same session, turn 3 would silently resolve to
|
||||
a brand-new, empty session instead of the one from turns 1-2.
|
||||
"""
|
||||
|
||||
async def test_previous_response_id_chain_preserves_session_across_three_turns(self) -> None:
|
||||
agent = _StubAgent()
|
||||
app = _build_app(agent)
|
||||
|
||||
turn1 = await _post(app, {"input": "hi"})
|
||||
assert turn1.status_code == 200
|
||||
turn2 = await _post(app, {"input": "still there?", "previous_response_id": turn1.json()["id"]})
|
||||
assert turn2.status_code == 200
|
||||
turn3 = await _post(app, {"input": "still there?", "previous_response_id": turn2.json()["id"]})
|
||||
assert turn3.status_code == 200
|
||||
|
||||
assert len(agent.session_ids_seen) == 3
|
||||
# All three turns must have run against the same underlying session,
|
||||
# not three independent ones.
|
||||
first_session_id = agent.session_ids_seen[0]
|
||||
assert first_session_id is not None
|
||||
assert agent.session_ids_seen == [first_session_id] * 3
|
||||
assert agent.turn_counts[first_session_id] == 3
|
||||
|
||||
async def test_conversation_id_preserves_session_across_turns(self) -> None:
|
||||
agent = _StubAgent()
|
||||
app = _build_app(agent)
|
||||
|
||||
turn1 = await _post(app, {"input": "hi", "conversation_id": "conv_stable"})
|
||||
assert turn1.status_code == 200
|
||||
turn2 = await _post(app, {"input": "still there?", "conversation_id": "conv_stable"})
|
||||
assert turn2.status_code == 200
|
||||
|
||||
assert agent.session_ids_seen == ["conv_stable", "conv_stable"]
|
||||
assert agent.turn_counts["conv_stable"] == 2
|
||||
|
||||
async def test_unrelated_requests_get_independent_sessions(self) -> None:
|
||||
agent = _StubAgent()
|
||||
app = _build_app(agent)
|
||||
|
||||
first = await _post(app, {"input": "hi"})
|
||||
second = await _post(app, {"input": "unrelated"})
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert agent.session_ids_seen[0] != agent.session_ids_seen[1]
|
||||
@@ -0,0 +1,289 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for the OpenAI Responses request-body parser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message, ResponseStream
|
||||
|
||||
from agent_framework_hosting_responses import (
|
||||
create_response_id,
|
||||
messages_from_responses_input,
|
||||
responses_from_run,
|
||||
responses_from_streaming_run,
|
||||
responses_session_id,
|
||||
responses_to_run,
|
||||
)
|
||||
|
||||
|
||||
def _sse_payload(event: str) -> dict[str, object]:
|
||||
data_line = next(line for line in event.splitlines() if line.startswith("data: "))
|
||||
return cast("dict[str, object]", json.loads(data_line.removeprefix("data: ")))
|
||||
|
||||
|
||||
class TestMessagesFromResponsesInput:
|
||||
def test_string_input_becomes_single_user_message(self) -> None:
|
||||
msgs = messages_from_responses_input("hello")
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0].role == "user"
|
||||
assert msgs[0].text == "hello"
|
||||
|
||||
def test_input_text_items_collapse_into_one_user_message(self) -> None:
|
||||
msgs = messages_from_responses_input([{"type": "input_text", "text": "a"}, {"type": "input_text", "text": "b"}])
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0].role == "user"
|
||||
assert msgs[0].text == "a b"
|
||||
|
||||
def test_message_envelope_with_string_content(self) -> None:
|
||||
msgs = messages_from_responses_input([
|
||||
{"type": "message", "role": "system", "content": "be brief"},
|
||||
{"type": "message", "role": "user", "content": "hi"},
|
||||
])
|
||||
assert [m.role for m in msgs] == ["system", "user"]
|
||||
assert msgs[0].text == "be brief"
|
||||
|
||||
def test_message_envelope_with_content_parts(self) -> None:
|
||||
msgs = messages_from_responses_input([
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "describe this"}],
|
||||
}
|
||||
])
|
||||
assert msgs[0].text == "describe this"
|
||||
|
||||
def test_message_envelope_rejects_non_object_content_item(self) -> None:
|
||||
with pytest.raises(ValueError, match="content.*object"):
|
||||
messages_from_responses_input([{"type": "message", "role": "user", "content": ["bad"]}])
|
||||
|
||||
def test_message_envelope_rejects_invalid_content_shape(self) -> None:
|
||||
with pytest.raises(ValueError, match="content.*string or list"):
|
||||
messages_from_responses_input([{"type": "message", "role": "user", "content": 42}])
|
||||
|
||||
def test_input_file_via_url(self) -> None:
|
||||
msgs = messages_from_responses_input([
|
||||
{"type": "input_file", "file_url": "https://example.com/report.pdf", "mime_type": "application/pdf"}
|
||||
])
|
||||
assert msgs[0].contents[0].uri == "https://example.com/report.pdf"
|
||||
|
||||
def test_input_file_via_file_id(self) -> None:
|
||||
msgs = messages_from_responses_input([{"type": "input_file", "file_id": "file_123"}])
|
||||
assert msgs[0].contents[0].file_id == "file_123"
|
||||
|
||||
def test_input_file_missing_anchor_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="input_file"):
|
||||
messages_from_responses_input([{"type": "input_file"}])
|
||||
|
||||
def test_pending_text_flushes_before_message_envelope(self) -> None:
|
||||
msgs = messages_from_responses_input([
|
||||
{"type": "input_text", "text": "first"},
|
||||
{"type": "message", "role": "user", "content": "second"},
|
||||
])
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0].text == "first"
|
||||
assert msgs[1].text == "second"
|
||||
|
||||
def test_image_url_via_string(self) -> None:
|
||||
msgs = messages_from_responses_input([{"type": "input_image", "image_url": "https://example.com/cat.png"}])
|
||||
assert len(msgs) == 1
|
||||
# Image content present.
|
||||
assert any(getattr(c, "uri", None) == "https://example.com/cat.png" for c in msgs[0].contents)
|
||||
|
||||
def test_image_url_via_object(self) -> None:
|
||||
msgs = messages_from_responses_input([
|
||||
{"type": "input_image", "image_url": {"url": "https://example.com/cat.png"}}
|
||||
])
|
||||
assert any(getattr(c, "uri", None) == "https://example.com/cat.png" for c in msgs[0].contents)
|
||||
|
||||
def test_unknown_input_type_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="Unsupported"):
|
||||
messages_from_responses_input([{"type": "weird"}])
|
||||
|
||||
def test_empty_list_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
messages_from_responses_input([])
|
||||
|
||||
def test_non_string_non_list_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
messages_from_responses_input(42) # type: ignore[arg-type]
|
||||
|
||||
def test_image_url_missing_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="image_url"):
|
||||
messages_from_responses_input([{"type": "input_image"}])
|
||||
|
||||
|
||||
class TestResponsesRunHelpers:
|
||||
def test_create_response_id_shape(self) -> None:
|
||||
response_id = create_response_id()
|
||||
|
||||
assert response_id.startswith("resp_")
|
||||
|
||||
def test_responses_session_id_prefers_previous_response(self) -> None:
|
||||
assert responses_session_id({"previous_response_id": "resp_1", "conversation_id": "conv_1"}) == "resp_1"
|
||||
|
||||
def test_responses_session_id_uses_conversation_id(self) -> None:
|
||||
assert responses_session_id({"conversation_id": "conv_1"}) == "conv_1"
|
||||
|
||||
def test_responses_session_id_returns_none_when_absent(self) -> None:
|
||||
assert responses_session_id({"input": "hi"}) is None
|
||||
|
||||
def test_responses_to_run_returns_messages_options_and_stream(self) -> None:
|
||||
run = responses_to_run({
|
||||
"input": "hi",
|
||||
"stream": True,
|
||||
"previous_response_id": "resp_1",
|
||||
"conversation_id": "conv_1",
|
||||
"max_output_tokens": 32,
|
||||
"model": "gpt-x",
|
||||
})
|
||||
|
||||
# `responses_to_run` always produces a `list[Message]`; the TypedDict
|
||||
# field is typed as the wider `Agent.run` input shape, so narrow here.
|
||||
messages = cast("list[Message]", run["messages"])
|
||||
assert messages[0].text == "hi"
|
||||
assert run["stream"] is True
|
||||
assert run["options"] == {"max_tokens": 32, "model": "gpt-x"}
|
||||
|
||||
def test_responses_from_run_returns_response_payload(self) -> None:
|
||||
result = AgentResponse(
|
||||
messages=Message(role="assistant", contents=[Content.from_text("hello")]),
|
||||
additional_properties={"model": "test-model"},
|
||||
)
|
||||
|
||||
payload = responses_from_run(result, response_id="resp_new")
|
||||
|
||||
assert payload["id"] == "resp_new"
|
||||
assert payload["model"] == "test-model"
|
||||
assert payload["output"][0]["content"][0]["text"] == "hello"
|
||||
|
||||
def test_responses_from_run_preserves_multimodal_output_items(self) -> None:
|
||||
result = AgentResponse(
|
||||
messages=Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_text_reasoning(id="rs_1", text="checking"),
|
||||
Content.from_function_call("call_1", "collect_media", arguments={"city": "Seattle"}),
|
||||
Content.from_function_result(
|
||||
"call_1",
|
||||
result=[
|
||||
Content.from_text("caption"),
|
||||
Content.from_uri("https://example.com/cat.png", media_type="image/png"),
|
||||
Content.from_hosted_file("file_pdf", media_type="application/pdf"),
|
||||
],
|
||||
),
|
||||
Content.from_text("done"),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
payload = responses_from_run(result, response_id="resp_new")
|
||||
|
||||
output = payload["output"]
|
||||
assert [item["type"] for item in output] == [
|
||||
"reasoning",
|
||||
"function_call",
|
||||
"function_call_output",
|
||||
"message",
|
||||
]
|
||||
assert output[0]["content"][0]["text"] == "checking"
|
||||
assert output[1]["name"] == "collect_media"
|
||||
assert output[1]["arguments"] == '{"city": "Seattle"}'
|
||||
assert output[2]["output"] == [
|
||||
{"text": "caption", "type": "input_text"},
|
||||
{"detail": "auto", "type": "input_image", "image_url": "https://example.com/cat.png"},
|
||||
{"type": "input_file", "file_id": "file_pdf"},
|
||||
]
|
||||
assert output[3]["content"][0]["text"] == "done"
|
||||
|
||||
def test_responses_from_run_maps_conversation_session(self) -> None:
|
||||
result = AgentResponse(messages=Message(role="assistant", contents=[Content.from_text("hello")]))
|
||||
|
||||
payload = responses_from_run(result, response_id="resp_new", session_id="conv_1")
|
||||
|
||||
assert payload["conversation"] == {"id": "conv_1"}
|
||||
|
||||
def test_responses_from_run_omits_previous_response_session(self) -> None:
|
||||
result = AgentResponse(messages=Message(role="assistant", contents=[Content.from_text("hello")]))
|
||||
|
||||
payload = responses_from_run(result, response_id="resp_new", session_id="resp_1")
|
||||
|
||||
assert "conversation" not in payload
|
||||
|
||||
async def test_responses_from_streaming_run(self) -> None:
|
||||
async def updates() -> AsyncIterator[AgentResponseUpdate]:
|
||||
yield AgentResponseUpdate(contents=[Content.from_text("hel")], role="assistant")
|
||||
yield AgentResponseUpdate(contents=[Content.from_text("lo")], role="assistant")
|
||||
|
||||
def finalizer(items: Sequence[AgentResponseUpdate]) -> AgentResponse:
|
||||
return AgentResponse.from_updates(items)
|
||||
|
||||
stream = ResponseStream(updates(), finalizer=finalizer)
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in responses_from_streaming_run(
|
||||
stream,
|
||||
response_id="resp_new",
|
||||
session_id="conv_1",
|
||||
)
|
||||
]
|
||||
|
||||
assert events[0].startswith("event: response.created")
|
||||
assert "response.output_text.delta" in events[1]
|
||||
assert "hel" in events[1]
|
||||
assert "lo" in events[2]
|
||||
assert events[-1].startswith("event: response.completed")
|
||||
assert '"conversation":{"id":"conv_1"}' in events[-1]
|
||||
|
||||
async def test_responses_from_streaming_run_emits_failed_when_iteration_raises(self) -> None:
|
||||
async def updates() -> AsyncIterator[AgentResponseUpdate]:
|
||||
yield AgentResponseUpdate(contents=[Content.from_text("partial")], role="assistant")
|
||||
raise RuntimeError("upstream blew up")
|
||||
|
||||
stream = ResponseStream(updates(), finalizer=AgentResponse.from_updates)
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in responses_from_streaming_run(
|
||||
stream,
|
||||
response_id="resp_new",
|
||||
session_id="conv_1",
|
||||
)
|
||||
]
|
||||
|
||||
assert events[0].startswith("event: response.created")
|
||||
assert "response.output_text.delta" in events[1]
|
||||
assert events[-1].startswith("event: response.failed")
|
||||
payload = _sse_payload(events[-1])
|
||||
response = cast("dict[str, object]", payload["response"])
|
||||
error = cast("dict[str, object]", response["error"])
|
||||
assert payload["type"] == "response.failed"
|
||||
assert response["status"] == "failed"
|
||||
assert response["conversation"] == {"id": "conv_1"}
|
||||
assert error["message"] == "upstream blew up"
|
||||
assert "partial" in events[-1]
|
||||
|
||||
async def test_responses_from_streaming_run_emits_failed_when_finalizer_raises(self) -> None:
|
||||
async def updates() -> AsyncIterator[AgentResponseUpdate]:
|
||||
yield AgentResponseUpdate(contents=[Content.from_text("partial")], role="assistant")
|
||||
|
||||
def finalizer(items: Sequence[AgentResponseUpdate]) -> AgentResponse:
|
||||
raise RuntimeError("finalizer blew up")
|
||||
|
||||
stream = ResponseStream(updates(), finalizer=finalizer)
|
||||
|
||||
events = [event async for event in responses_from_streaming_run(stream, response_id="resp_new")]
|
||||
|
||||
assert events[0].startswith("event: response.created")
|
||||
assert "response.output_text.delta" in events[1]
|
||||
assert events[-1].startswith("event: response.failed")
|
||||
payload = _sse_payload(events[-1])
|
||||
response = cast("dict[str, object]", payload["response"])
|
||||
error = cast("dict[str, object]", response["error"])
|
||||
assert response["status"] == "failed"
|
||||
assert error["message"] == "finalizer blew up"
|
||||
Reference in New Issue
Block a user