mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
d75f55ee2c
* feat(hosting-responses): add OpenAI Responses-shaped channel package
New ``agent-framework-hosting-responses`` package implementing the
OpenAI Responses-shaped HTTP channel for the Hosting framework. Mounts
``POST /responses`` (and a ``/responses/{response_id}`` GET) onto an
``AgentFrameworkHost`` and translates the OpenAI Responses wire shape
to/from the channel-neutral ``ChannelRequest`` / ``HostedRunResult``
plumbing.
Surface (re-exported from ``agent_framework_hosting_responses``):
- ``ResponsesChannel`` -- concrete ``Channel`` implementation. Owns the
Starlette route(s), parses inbound JSON into ``ChannelRequest``, runs
the optional ``ChannelRunHook``, calls back into the
``ChannelContext`` to invoke the agent target, builds Responses
envelopes (sync JSON or SSE), and respects
``DeliveryReport.include_originating`` so cross-channel push routes
only ack to the originating Responses caller.
- The minted ``response_id`` is propagated via the host's ContextVar
machinery so storage-side history providers (e.g.
``FoundryHostedAgentHistoryProvider``) persist envelopes against the
same id the channel returns.
- 48 unit tests covering route wiring, parsing of each Responses input
shape, hook composition, sync vs streaming paths, and originating
vs non-originating delivery branches.
Registers the package in ``python/pyproject.toml`` ``[tool.uv.sources]``
and adds the matching pyright ``executionEnvironments`` entry.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* review: address PR-3 round 2 feedback
- consume IsolationKeys.chat_key from the host-bound contextvar instead
of the raw `x-agent-chat-isolation-key` header off the wire so the
host's ASGI isolation middleware (or any operator-supplied
replacement) is the authoritative point at which the caller is
authenticated and the bucket key is established
- expand `response_id_factory` docstring to call out partition
co-location vs. partition-ownership enforcement: the channel forwards
`previous_response_id` as a hint to the factory; the storage layer
validates the embedded partition against the bound user/chat
isolation keys
- on mid-stream failure, call `deliver_response` with the accumulated
text before emitting `response.failed` so host-side history /
push-channel state stays consistent with the partial deltas the
client already saw
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* docs(hosting-responses): fix quickstart to use current Agent API
ChatAgent was renamed to Agent and ChatMessage to Message. Update the
README quickstart to use client.as_agent(...) and refresh the stale
docstring reference in _channel.py.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(hosting-responses): adapt to hosted run result wrapper
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(hosting-responses): add response hooks
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(hosting-responses): keep instructions in chat options
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
288 lines
11 KiB
Python
288 lines
11 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
"""End-to-end tests for :class:`ResponsesChannel` via Starlette's ``TestClient``."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncIterator
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from agent_framework_hosting import (
|
|
AgentFrameworkHost,
|
|
ChannelIdentity,
|
|
HostedRunResult,
|
|
)
|
|
from starlette.testclient import TestClient
|
|
|
|
from agent_framework_hosting_responses import ResponsesChannel
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Fakes #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@dataclass
|
|
class _FakeAgentResponse:
|
|
text: str
|
|
|
|
|
|
@dataclass
|
|
class _FakeUpdate:
|
|
text: str
|
|
|
|
|
|
class _FakeStream:
|
|
"""Minimal stand-in for AF's ``ResponseStream`` returned by ``run(stream=True)``."""
|
|
|
|
def __init__(self, chunks: list[str]) -> None:
|
|
self._chunks = chunks
|
|
self._final = _FakeAgentResponse(text="".join(chunks))
|
|
|
|
def __aiter__(self) -> AsyncIterator[_FakeUpdate]:
|
|
async def _gen() -> AsyncIterator[_FakeUpdate]:
|
|
for c in self._chunks:
|
|
yield _FakeUpdate(c)
|
|
|
|
return _gen()
|
|
|
|
async def get_final_response(self) -> _FakeAgentResponse:
|
|
return self._final
|
|
|
|
|
|
class _FakeAgent:
|
|
def __init__(self, reply: str = "hello", chunks: list[str] | None = None) -> None:
|
|
self._reply = reply
|
|
self._chunks = chunks or [reply]
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
def create_session(self, *, session_id: str | None = None) -> Any:
|
|
return {"session_id": session_id}
|
|
|
|
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
|
|
self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs})
|
|
if stream:
|
|
return _FakeStream(self._chunks)
|
|
|
|
async def _coro() -> _FakeAgentResponse:
|
|
return _FakeAgentResponse(text=self._reply)
|
|
|
|
return _coro()
|
|
|
|
|
|
class _RecordingPushChannel:
|
|
name = "telegram"
|
|
path = "/telegram"
|
|
|
|
def __init__(self) -> None:
|
|
self.pushes: list[tuple[ChannelIdentity, HostedRunResult]] = []
|
|
|
|
def contribute(self, _ctx: Any) -> Any:
|
|
from agent_framework_hosting import ChannelContribution
|
|
|
|
return ChannelContribution()
|
|
|
|
async def push(self, identity: ChannelIdentity, payload: HostedRunResult) -> None:
|
|
self.pushes.append((identity, payload))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Tests #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _make_client(agent: _FakeAgent | None = None) -> tuple[TestClient, AgentFrameworkHost, _FakeAgent]:
|
|
agent = agent or _FakeAgent()
|
|
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()])
|
|
return TestClient(host.app), host, agent
|
|
|
|
|
|
class TestResponsesChannelNonStreaming:
|
|
def test_post_responses_returns_completed_envelope(self) -> None:
|
|
client, _host, agent = _make_client(_FakeAgent(reply="hi back"))
|
|
with client:
|
|
r = client.post("/responses", json={"input": "hi"})
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["status"] == "completed"
|
|
assert body["object"] == "response"
|
|
assert body["id"].startswith("resp_")
|
|
assert body["output"][0]["content"][0]["text"] == "hi back"
|
|
assert len(agent.calls) == 1
|
|
|
|
def test_invalid_json_returns_400(self) -> None:
|
|
client, *_ = _make_client()
|
|
with client:
|
|
r = client.post("/responses", content=b"{not json", headers={"content-type": "application/json"})
|
|
assert r.status_code == 400
|
|
|
|
def test_invalid_input_returns_422(self) -> None:
|
|
client, *_ = _make_client()
|
|
with client:
|
|
r = client.post("/responses", json={"input": 42})
|
|
assert r.status_code == 422
|
|
|
|
def test_options_propagate_to_target_run(self) -> None:
|
|
client, _host, agent = _make_client()
|
|
with client:
|
|
r = client.post("/responses", json={"input": "x", "temperature": 0.5, "max_output_tokens": 64})
|
|
assert r.status_code == 200
|
|
opts = agent.calls[0]["kwargs"]["options"]
|
|
assert opts == {"temperature": 0.5, "max_tokens": 64}
|
|
|
|
def test_previous_response_id_creates_session(self) -> None:
|
|
client, _host, agent = _make_client()
|
|
with client:
|
|
client.post("/responses", json={"input": "x", "previous_response_id": "resp_42"})
|
|
# AgentFrameworkHost converts the channel session into an AgentSession.
|
|
sess = agent.calls[0]["kwargs"].get("session")
|
|
assert sess is not None
|
|
# _FakeAgent.create_session stashes the session_id on the dict it returns.
|
|
assert sess["session_id"] == "resp_42"
|
|
|
|
def test_chat_isolation_header_creates_session_when_no_prev_id(self) -> None:
|
|
"""Foundry-style ``x-agent-chat-isolation-key`` falls back to a session anchor.
|
|
|
|
First-turn requests have no ``previous_response_id`` (the client
|
|
doesn't have one yet), but Foundry Hosted Agents always inject
|
|
the isolation headers. The channel must derive a session from the
|
|
chat key so the host can build a stable per-conversation session
|
|
that history providers persist under.
|
|
"""
|
|
client, _host, agent = _make_client()
|
|
with client:
|
|
client.post(
|
|
"/responses",
|
|
json={"input": "x"},
|
|
headers={"x-agent-chat-isolation-key": "chat-abc"},
|
|
)
|
|
sess = agent.calls[0]["kwargs"].get("session")
|
|
assert sess is not None
|
|
assert sess["session_id"] == "chat-abc"
|
|
|
|
def test_prev_response_id_wins_over_chat_isolation_header(self) -> None:
|
|
"""When both anchors are present, ``previous_response_id`` wins.
|
|
|
|
``previous_response_id`` is the protocol-native chain anchor; the
|
|
header fallback is only meant to bootstrap when no protocol
|
|
anchor exists.
|
|
"""
|
|
client, _host, agent = _make_client()
|
|
with client:
|
|
client.post(
|
|
"/responses",
|
|
json={"input": "x", "previous_response_id": "resp_99"},
|
|
headers={"x-agent-chat-isolation-key": "chat-abc"},
|
|
)
|
|
sess = agent.calls[0]["kwargs"].get("session")
|
|
assert sess is not None
|
|
assert sess["session_id"] == "resp_99"
|
|
|
|
def test_response_target_channel_returns_ack_text_when_pushed(self) -> None:
|
|
agent = _FakeAgent(reply="real reply")
|
|
push_ch = _RecordingPushChannel()
|
|
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(), push_ch])
|
|
|
|
with TestClient(host.app) as client:
|
|
r = client.post(
|
|
"/responses",
|
|
json={
|
|
"input": "hi",
|
|
"response_target": "telegram:42",
|
|
},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
text = body["output"][0]["content"][0]["text"]
|
|
assert "delivered out-of-band" in text
|
|
assert push_ch.pushes and push_ch.pushes[0][1].result.text == "real reply"
|
|
assert push_ch.pushes[0][0].native_id == "42"
|
|
|
|
def test_response_hook_can_rewrite_originating_reply(self) -> None:
|
|
contexts: list[Any] = []
|
|
|
|
def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult:
|
|
contexts.append(kwargs["context"])
|
|
return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session)
|
|
|
|
agent = _FakeAgent(reply="hooked")
|
|
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(response_hook=hook)])
|
|
|
|
with TestClient(host.app) as client:
|
|
r = client.post("/responses", json={"input": "hi"})
|
|
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["output"][0]["content"][0]["text"] == "HOOKED"
|
|
assert contexts
|
|
assert contexts[0].channel_name == "responses"
|
|
assert contexts[0].originating is True
|
|
assert contexts[0].destination_identity is None
|
|
|
|
|
|
class TestResponsesChannelStreaming:
|
|
def test_sse_emits_created_delta_completed(self) -> None:
|
|
agent = _FakeAgent(reply="hello world", chunks=["hello", " ", "world"])
|
|
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()])
|
|
with TestClient(host.app) as client:
|
|
r = client.post("/responses", json={"input": "hi", "stream": True})
|
|
assert r.status_code == 200
|
|
body = r.text
|
|
|
|
# SSE event lines look like "event: <type>\ndata: <json>\n\n".
|
|
events = [line[len("event: ") :] for line in body.splitlines() if line.startswith("event: ")]
|
|
assert events[0] == "response.created"
|
|
assert events[-1] == "response.completed"
|
|
assert events.count("response.output_text.delta") == 3
|
|
|
|
def test_sse_transform_hook_can_rewrite_chunks(self) -> None:
|
|
agent = _FakeAgent(reply="hello", chunks=["he", "llo"])
|
|
|
|
def transform(update: _FakeUpdate) -> _FakeUpdate:
|
|
return _FakeUpdate(text=update.text.upper())
|
|
|
|
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(stream_transform_hook=transform)])
|
|
with TestClient(host.app) as client:
|
|
r = client.post("/responses", json={"input": "hi", "stream": True})
|
|
|
|
assert r.status_code == 200
|
|
assert '"delta":"HE"' in r.text
|
|
assert '"delta":"LLO"' in r.text
|
|
assert '"text":"HELLO"' in r.text
|
|
|
|
def test_sse_emits_failed_when_stream_raises(self) -> None:
|
|
# Regression: ResponseOutputMessage.status only accepts in_progress/
|
|
# completed/incomplete, so building an OpenAIResponse with status="failed"
|
|
# used to crash with a pydantic ValidationError. The channel must map the
|
|
# nested message status to "incomplete" while keeping the top-level
|
|
# Response.status="failed".
|
|
class _BoomStream:
|
|
def __aiter__(self) -> AsyncIterator[_FakeUpdate]:
|
|
async def _gen() -> AsyncIterator[_FakeUpdate]:
|
|
yield _FakeUpdate("partial")
|
|
raise RuntimeError("upstream blew up")
|
|
|
|
return _gen()
|
|
|
|
async def get_final_response(self) -> _FakeAgentResponse: # pragma: no cover
|
|
return _FakeAgentResponse(text="")
|
|
|
|
class _BoomAgent(_FakeAgent):
|
|
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
|
|
self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs})
|
|
if stream:
|
|
return _BoomStream()
|
|
raise AssertionError("non-streaming path not exercised here")
|
|
|
|
host = AgentFrameworkHost(target=_BoomAgent(), channels=[ResponsesChannel()])
|
|
with TestClient(host.app) as client:
|
|
r = client.post("/responses", json={"input": "hi", "stream": True})
|
|
assert r.status_code == 200
|
|
body = r.text
|
|
|
|
events = [line[len("event: ") :] for line in body.splitlines() if line.startswith("event: ")]
|
|
assert events[0] == "response.created"
|
|
assert events[-1] == "response.failed"
|
|
# The failed envelope must serialize cleanly — i.e. no ValidationError raised.
|
|
assert "upstream blew up" in body
|