mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: add agent-framework-hosting-responses channel (#5639)
* 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>
This commit is contained in:
committed by
GitHub
Unverified
parent
4c317eb7cf
commit
d75f55ee2c
@@ -0,0 +1,287 @@
|
||||
# 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
|
||||
@@ -0,0 +1,204 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for the OpenAI Responses request-body parser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from agent_framework_hosting import ResponseTarget, ResponseTargetKind
|
||||
|
||||
from agent_framework_hosting_responses import (
|
||||
messages_from_responses_input,
|
||||
parse_response_target,
|
||||
parse_responses_identity,
|
||||
parse_responses_request,
|
||||
)
|
||||
|
||||
|
||||
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_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 TestParseResponsesRequest:
|
||||
def test_instructions_are_forwarded_as_chat_options(self) -> None:
|
||||
msgs, opts, sess = parse_responses_request({"input": "hi", "instructions": "be brief"})
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0].role == "user"
|
||||
assert msgs[0].text == "hi"
|
||||
assert opts["instructions"] == "be brief"
|
||||
assert sess is None
|
||||
|
||||
def test_options_passthrough(self) -> None:
|
||||
_, opts, _ = parse_responses_request({"input": "x", "temperature": 0.4, "top_p": 0.9, "tool_choice": "auto"})
|
||||
assert opts["temperature"] == 0.4
|
||||
assert opts["top_p"] == 0.9
|
||||
assert opts["tool_choice"] == "auto"
|
||||
|
||||
def test_options_remap(self) -> None:
|
||||
_, opts, _ = parse_responses_request({"input": "x", "max_output_tokens": 256, "parallel_tool_calls": False})
|
||||
assert opts == {"max_tokens": 256, "allow_multiple_tool_calls": False}
|
||||
|
||||
def test_transport_keys_not_forwarded(self) -> None:
|
||||
_, opts, _ = parse_responses_request({
|
||||
"input": "x",
|
||||
"model": "gpt-x",
|
||||
"stream": True,
|
||||
"previous_response_id": "r",
|
||||
})
|
||||
for key in ("input", "model", "stream", "previous_response_id"):
|
||||
assert key not in opts
|
||||
|
||||
def test_unknown_keys_silently_dropped(self) -> None:
|
||||
_, opts, _ = parse_responses_request({"input": "x", "truncation": "auto", "reasoning": {"effort": "low"}})
|
||||
assert opts == {}
|
||||
|
||||
def test_none_values_dropped(self) -> None:
|
||||
_, opts, _ = parse_responses_request({"input": "x", "temperature": None})
|
||||
assert "temperature" not in opts
|
||||
|
||||
def test_previous_response_id_becomes_session(self) -> None:
|
||||
_, _, sess = parse_responses_request({"input": "x", "previous_response_id": "resp_42"})
|
||||
assert sess is not None
|
||||
assert sess.isolation_key == "resp_42"
|
||||
|
||||
|
||||
class TestParseResponseTarget:
|
||||
def test_default_originating_when_missing(self) -> None:
|
||||
assert parse_response_target({}).kind is ResponseTargetKind.ORIGINATING
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected_kind",
|
||||
[
|
||||
("originating", ResponseTargetKind.ORIGINATING),
|
||||
("active", ResponseTargetKind.ACTIVE),
|
||||
("all_linked", ResponseTargetKind.ALL_LINKED),
|
||||
("none", ResponseTargetKind.NONE),
|
||||
],
|
||||
)
|
||||
def test_bare_string_kinds(self, value: str, expected_kind: ResponseTargetKind) -> None:
|
||||
assert parse_response_target({"response_target": value}).kind is expected_kind
|
||||
|
||||
def test_bare_string_other_becomes_channel(self) -> None:
|
||||
target = parse_response_target({"response_target": "telegram"})
|
||||
assert target == ResponseTarget.channel("telegram")
|
||||
|
||||
def test_bare_string_with_native_id_becomes_channel(self) -> None:
|
||||
target = parse_response_target({"response_target": "telegram:42"})
|
||||
assert target.kind is ResponseTargetKind.CHANNELS
|
||||
assert target.targets == ("telegram:42",)
|
||||
|
||||
def test_list_form(self) -> None:
|
||||
target = parse_response_target({"response_target": ["telegram:42", "originating"]})
|
||||
assert target == ResponseTarget.channels(["telegram:42", "originating"])
|
||||
|
||||
def test_list_drops_non_strings(self) -> None:
|
||||
target = parse_response_target({"response_target": ["telegram", 42, ""]})
|
||||
assert target.targets == ("telegram",)
|
||||
|
||||
def test_empty_list_falls_back_to_originating(self) -> None:
|
||||
target = parse_response_target({"response_target": []})
|
||||
assert target.kind is ResponseTargetKind.ORIGINATING
|
||||
|
||||
def test_dict_with_channels(self) -> None:
|
||||
target = parse_response_target({"response_target": {"channels": ["a", "b"]}})
|
||||
assert target == ResponseTarget.channels(["a", "b"])
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind,expected",
|
||||
[
|
||||
("active", ResponseTargetKind.ACTIVE),
|
||||
("all_linked", ResponseTargetKind.ALL_LINKED),
|
||||
("none", ResponseTargetKind.NONE),
|
||||
("originating", ResponseTargetKind.ORIGINATING),
|
||||
],
|
||||
)
|
||||
def test_dict_kind(self, kind: str, expected: ResponseTargetKind) -> None:
|
||||
assert parse_response_target({"response_target": {"kind": kind}}).kind is expected
|
||||
|
||||
def test_malformed_falls_back_to_originating(self) -> None:
|
||||
target = parse_response_target({"response_target": 42})
|
||||
assert target.kind is ResponseTargetKind.ORIGINATING
|
||||
|
||||
|
||||
class TestParseResponsesIdentity:
|
||||
def test_safety_identifier_preferred(self) -> None:
|
||||
ident = parse_responses_identity({"safety_identifier": "abc", "user": "legacy"}, "responses")
|
||||
assert ident is not None
|
||||
assert ident.native_id == "abc"
|
||||
assert ident.channel == "responses"
|
||||
|
||||
def test_fallback_to_user(self) -> None:
|
||||
ident = parse_responses_identity({"user": "legacy"}, "responses")
|
||||
assert ident is not None
|
||||
assert ident.native_id == "legacy"
|
||||
|
||||
def test_returns_none_when_absent(self) -> None:
|
||||
assert parse_responses_identity({}, "responses") is None
|
||||
|
||||
def test_returns_none_for_non_string(self) -> None:
|
||||
assert parse_responses_identity({"safety_identifier": 42}, "responses") is None
|
||||
Reference in New Issue
Block a user