mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Simplify Python hosting core (#6492)
Remove linking, multicast, durable delivery, and host push machinery from the v1 hosting core. Keep those scenarios in a proposed follow-up ADR and update channel packages, samples, docs, tests, and workspace metadata around the smaller host/channel contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
e5a6e35843
commit
36ce0950e4
@@ -7,7 +7,6 @@ import importlib.metadata
|
||||
from ._channel import ResponsesChannel
|
||||
from ._parsing import (
|
||||
messages_from_responses_input,
|
||||
parse_response_target,
|
||||
parse_responses_identity,
|
||||
parse_responses_request,
|
||||
)
|
||||
@@ -21,7 +20,6 @@ __all__ = [
|
||||
"ResponsesChannel",
|
||||
"__version__",
|
||||
"messages_from_responses_input",
|
||||
"parse_response_target",
|
||||
"parse_responses_identity",
|
||||
"parse_responses_request",
|
||||
]
|
||||
|
||||
@@ -17,22 +17,17 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
||||
from typing import Any, cast
|
||||
from collections.abc import AsyncIterator, Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentResponse, Content, Message
|
||||
from agent_framework_hosting import (
|
||||
ChannelContext,
|
||||
ChannelContribution,
|
||||
ChannelRequest,
|
||||
ChannelResponseContext,
|
||||
ChannelResponseHook,
|
||||
ChannelRunHook,
|
||||
ChannelSession,
|
||||
ChannelStreamTransformHook,
|
||||
HostedRunResult,
|
||||
apply_response_hook,
|
||||
apply_run_hook,
|
||||
ChannelStreamUpdateHook,
|
||||
get_current_isolation_keys,
|
||||
logger,
|
||||
)
|
||||
@@ -53,25 +48,11 @@ from starlette.responses import JSONResponse, Response, StreamingResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from ._parsing import (
|
||||
parse_response_target,
|
||||
parse_responses_identity,
|
||||
parse_responses_request,
|
||||
)
|
||||
|
||||
|
||||
def _ack_text() -> str:
|
||||
"""Tiny acknowledgement string for the originating wire.
|
||||
|
||||
Used when the agent reply is delivered out-of-band via :class:`ChannelPush`.
|
||||
"""
|
||||
return "[delivered out-of-band]"
|
||||
|
||||
|
||||
def _text_result(text: str) -> HostedRunResult[AgentResponse]:
|
||||
"""Build a host delivery payload from text accumulated by this channel."""
|
||||
return HostedRunResult(AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(text=text)])]))
|
||||
|
||||
|
||||
class ResponsesChannel:
|
||||
"""Minimal OpenAI-Responses-shaped surface.
|
||||
|
||||
@@ -88,7 +69,7 @@ class ResponsesChannel:
|
||||
path: str = "/responses",
|
||||
run_hook: ChannelRunHook | None = None,
|
||||
response_hook: ChannelResponseHook | None = None,
|
||||
stream_transform_hook: ChannelStreamTransformHook | None = None,
|
||||
stream_update_hook: ChannelStreamUpdateHook | None = None,
|
||||
response_id_factory: Callable[..., str] | None = None,
|
||||
) -> None:
|
||||
"""Create a Responses channel.
|
||||
@@ -97,15 +78,13 @@ class ResponsesChannel:
|
||||
path: Endpoint path on the host. Default ``"/responses"`` matches
|
||||
the upstream OpenAI surface; use ``""`` to expose this channel
|
||||
at the app root.
|
||||
run_hook: Optional :data:`ChannelRunHook` invoked with the
|
||||
parsed :class:`ChannelRequest` before the agent target
|
||||
run_hook: Optional :data:`ChannelRunHook` the host invokes with
|
||||
the parsed :class:`ChannelRequest` before the agent target
|
||||
runs. May return a replacement request.
|
||||
response_hook: Optional :data:`ChannelResponseHook` invoked
|
||||
response_hook: Optional :data:`ChannelResponseHook` the host invokes
|
||||
before the channel serializes an originating
|
||||
:class:`HostedRunResult` into a Responses envelope. The
|
||||
host also invokes this hook when delivering to this
|
||||
channel as a non-originating push destination.
|
||||
stream_transform_hook: Optional per-update transform hook
|
||||
:class:`HostedRunResult` into a Responses envelope.
|
||||
stream_update_hook: Optional per-update hook
|
||||
applied while streaming Server-Sent Events. Return a
|
||||
replacement update, or ``None`` to drop the update.
|
||||
response_id_factory: Optional callable that mints the
|
||||
@@ -138,7 +117,7 @@ class ResponsesChannel:
|
||||
self.path = path
|
||||
self._hook = run_hook
|
||||
self.response_hook = response_hook
|
||||
self._stream_transform_hook = stream_transform_hook
|
||||
self._stream_update_hook = stream_update_hook
|
||||
self._ctx: ChannelContext | None = None
|
||||
self._response_id_factory: Callable[..., str] = (
|
||||
response_id_factory if response_id_factory is not None else (lambda *_a, **_kw: f"resp_{uuid.uuid4().hex}")
|
||||
@@ -156,8 +135,6 @@ class ResponsesChannel:
|
||||
``options`` / ``ChannelSession`` triples via :mod:`._parsing`,
|
||||
applies the optional ``run_hook``, and either streams an SSE
|
||||
response stream or returns a one-shot OpenAI ``Response`` envelope.
|
||||
Non-originating ``response_target`` values resolve to a delivery
|
||||
acknowledgement instead of echoing the agent text on this wire.
|
||||
"""
|
||||
if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle
|
||||
return JSONResponse({"error": "channel not initialized"}, status_code=500)
|
||||
@@ -218,8 +195,8 @@ class ResponsesChannel:
|
||||
attributes["previous_response_id"] = previous_response_id
|
||||
|
||||
# Honor the OpenAI-Responses ``stream`` flag — non-streaming by
|
||||
# default, SSE when the caller opts in. Run hooks may still flip
|
||||
# this per-request (e.g. force non-streaming for a particular user).
|
||||
# default, SSE when the caller opts in. The channel chooses the
|
||||
# transport before run hooks execute.
|
||||
channel_request = ChannelRequest(
|
||||
channel=self.name,
|
||||
operation="message.create",
|
||||
@@ -228,18 +205,9 @@ class ResponsesChannel:
|
||||
options=options or None,
|
||||
stream=bool(body.get("stream", False)),
|
||||
identity=parse_responses_identity(body, self.name),
|
||||
response_target=parse_response_target(body),
|
||||
attributes=attributes,
|
||||
)
|
||||
|
||||
if self._hook is not None:
|
||||
channel_request = await apply_run_hook(
|
||||
self._hook,
|
||||
channel_request,
|
||||
target=self._ctx.target,
|
||||
protocol_request=body,
|
||||
)
|
||||
|
||||
if channel_request.stream:
|
||||
return StreamingResponse(
|
||||
self._stream_events(channel_request, body, response_id=response_id),
|
||||
@@ -247,32 +215,17 @@ class ResponsesChannel:
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
result = await self._ctx.run(channel_request)
|
||||
include_originating = await self._ctx.deliver_response(channel_request, result)
|
||||
if include_originating:
|
||||
result = await self._apply_response_hook(result, channel_request)
|
||||
text = result.result.text if include_originating else _ack_text()
|
||||
result = await self._ctx.run(
|
||||
channel_request,
|
||||
run_hook=self._hook,
|
||||
protocol_request=body,
|
||||
response_hook=self.response_hook,
|
||||
channel_name=self.name,
|
||||
)
|
||||
text = result.result.text
|
||||
envelope = self._build_response(body, text, status="completed", response_id=response_id)
|
||||
return JSONResponse(envelope.model_dump(mode="json", exclude_none=True))
|
||||
|
||||
async def _apply_response_hook(
|
||||
self,
|
||||
result: HostedRunResult[AgentResponse],
|
||||
request: ChannelRequest,
|
||||
) -> HostedRunResult[AgentResponse]:
|
||||
"""Apply the channel-level response hook for an originating reply."""
|
||||
if self.response_hook is None:
|
||||
return result
|
||||
context = ChannelResponseContext(
|
||||
request=request,
|
||||
channel_name=self.name,
|
||||
destination_identity=None,
|
||||
originating=True,
|
||||
is_echo=False,
|
||||
)
|
||||
shaped = await apply_response_hook(self.response_hook, result, context=context)
|
||||
return cast("HostedRunResult[AgentResponse]", shaped)
|
||||
|
||||
def _build_response(
|
||||
self,
|
||||
body: Mapping[str, Any],
|
||||
@@ -350,13 +303,15 @@ class ResponsesChannel:
|
||||
|
||||
accumulated = ""
|
||||
try:
|
||||
stream = self._ctx.run_stream(request)
|
||||
stream = await self._ctx.run_stream(
|
||||
request,
|
||||
run_hook=self._hook,
|
||||
protocol_request=body,
|
||||
stream_update_hook=self._stream_update_hook,
|
||||
response_hook=self.response_hook,
|
||||
channel_name=self.name,
|
||||
)
|
||||
async for update in stream:
|
||||
if self._stream_transform_hook is not None:
|
||||
transformed = self._stream_transform_hook(update)
|
||||
update = await transformed if isinstance(transformed, Awaitable) else transformed
|
||||
if update is None:
|
||||
continue
|
||||
chunk = getattr(update, "text", None)
|
||||
if chunk:
|
||||
accumulated += chunk
|
||||
@@ -374,24 +329,12 @@ class ResponsesChannel:
|
||||
try:
|
||||
# Finalize so context-provider / history hooks on the agent
|
||||
# still run even though we are emitting our own SSE.
|
||||
await stream.get_final_response()
|
||||
final_response = await stream.get_final_response()
|
||||
except Exception: # pragma: no cover - finalize is best-effort
|
||||
logger.exception("Responses stream finalize failed")
|
||||
final_response = None
|
||||
except Exception as exc:
|
||||
logger.exception("Responses stream consumption failed")
|
||||
# Mid-stream failure: the wire already saw partial deltas
|
||||
# so host-side state must reflect that — call
|
||||
# ``deliver_response`` with the accumulated text (best-effort)
|
||||
# before signalling failure to the client. Without this,
|
||||
# next turn's chain anchored on this ``response_id`` would
|
||||
# be inconsistent with what the user actually saw, and any
|
||||
# non-originating push targets would silently miss the turn.
|
||||
# ``deliver_response`` itself is best-effort; we swallow its
|
||||
# exceptions so the failure event still reaches the client.
|
||||
try:
|
||||
await self._ctx.deliver_response(request, _text_result(accumulated))
|
||||
except Exception: # pragma: no cover - delivery is best-effort
|
||||
logger.exception("Responses stream failure deliver_response failed")
|
||||
failed = self._build_response(body, accumulated, status="failed", response_id=response_id)
|
||||
failed.error = ResponseError(code="server_error", message=str(exc))
|
||||
yield sse(
|
||||
@@ -403,14 +346,7 @@ class ResponsesChannel:
|
||||
)
|
||||
return
|
||||
|
||||
completed_text = accumulated
|
||||
result = _text_result(accumulated)
|
||||
include_originating = await self._ctx.deliver_response(request, result)
|
||||
if include_originating:
|
||||
result = await self._apply_response_hook(result, request)
|
||||
completed_text = result.result.text
|
||||
else:
|
||||
completed_text = _ack_text()
|
||||
completed_text = getattr(final_response, "text", None) or accumulated
|
||||
completed = self._build_response(body, completed_text, status="completed", response_id=response_id)
|
||||
# Reuse the same message id we emitted deltas under.
|
||||
if completed.output and isinstance(completed.output[0], ResponseOutputMessage):
|
||||
|
||||
@@ -15,7 +15,7 @@ from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import Content, Message
|
||||
from agent_framework_hosting import ChannelIdentity, ChannelSession, ResponseTarget, logger
|
||||
from agent_framework_hosting import ChannelIdentity, ChannelSession
|
||||
|
||||
# OpenAI Responses field name → Agent Framework ChatOptions field name.
|
||||
_RESPONSES_OPTION_REMAP = {
|
||||
@@ -43,71 +43,7 @@ _RESPONSES_OPTION_PASSTHROUGH = {
|
||||
"logit_bias",
|
||||
}
|
||||
# Fields the Responses transport owns; they must not be forwarded as options.
|
||||
_RESPONSES_TRANSPORT_KEYS = {"input", "model", "stream", "previous_response_id", "response_target"}
|
||||
|
||||
|
||||
def parse_response_target(body: Mapping[str, Any]) -> ResponseTarget:
|
||||
"""Translate the OpenAI Responses ``response_target`` field into a :class:`ResponseTarget`.
|
||||
|
||||
Accepted shapes:
|
||||
|
||||
- ``"originating"`` / ``"active"`` / ``"all_linked"`` / ``"none"`` — bare strings.
|
||||
- ``"telegram"`` / ``"telegram:<chat_id>"`` — single channel destination.
|
||||
- ``["telegram:<id>", "originating"]`` — list of destinations; the
|
||||
pseudo-name ``"originating"`` includes the originating channel.
|
||||
- ``{"channels": [...]}`` — same list semantics with the explicit key.
|
||||
- ``{"kind": "active"}`` / ``{"kind": "all_linked"}`` — explicit kind.
|
||||
|
||||
Anything malformed is logged at WARNING and falls back to ``originating``.
|
||||
"""
|
||||
raw = body.get("response_target")
|
||||
if raw is None:
|
||||
return ResponseTarget.originating # type: ignore[attr-defined,no-any-return]
|
||||
if isinstance(raw, str):
|
||||
keyword = raw.strip()
|
||||
if keyword == "originating":
|
||||
return ResponseTarget.originating # type: ignore[attr-defined,no-any-return]
|
||||
if keyword == "active":
|
||||
return ResponseTarget.active # type: ignore[attr-defined,no-any-return]
|
||||
if keyword == "all_linked":
|
||||
return ResponseTarget.all_linked # type: ignore[attr-defined,no-any-return]
|
||||
if keyword == "none":
|
||||
return ResponseTarget.none # type: ignore[attr-defined,no-any-return]
|
||||
# Treat any other bare string as a single channel destination.
|
||||
return ResponseTarget.channel(keyword)
|
||||
if isinstance(raw, list):
|
||||
return _parse_channels_list(cast("list[Any]", raw)) # type: ignore[redundant-cast]
|
||||
if isinstance(raw, Mapping):
|
||||
raw_map = cast("Mapping[str, Any]", raw)
|
||||
channels = raw_map.get("channels")
|
||||
if isinstance(channels, list):
|
||||
return _parse_channels_list(cast("list[Any]", channels)) # type: ignore[redundant-cast]
|
||||
kind = raw_map.get("kind")
|
||||
if kind == "active":
|
||||
return ResponseTarget.active # type: ignore[attr-defined,no-any-return]
|
||||
if kind == "all_linked":
|
||||
return ResponseTarget.all_linked # type: ignore[attr-defined,no-any-return]
|
||||
if kind == "none":
|
||||
return ResponseTarget.none # type: ignore[attr-defined,no-any-return]
|
||||
if kind == "originating":
|
||||
return ResponseTarget.originating # type: ignore[attr-defined,no-any-return]
|
||||
logger.warning("responses: ignoring malformed response_target=%r", cast("Any", raw))
|
||||
return ResponseTarget.originating # type: ignore[attr-defined,no-any-return]
|
||||
|
||||
|
||||
def _parse_channels_list(raw: list[Any]) -> ResponseTarget:
|
||||
"""Build a ``ResponseTarget.channels`` from a raw list, dropping non-string entries.
|
||||
|
||||
An empty list (or one with no usable strings) collapses back to
|
||||
``originating`` so we never silently produce a target that nobody
|
||||
will deliver to.
|
||||
"""
|
||||
tokens = [t for t in raw if isinstance(t, str) and t]
|
||||
if len(tokens) != len(raw):
|
||||
logger.warning("responses: dropping non-string entries from response_target=%r", raw)
|
||||
if not tokens:
|
||||
return ResponseTarget.originating # type: ignore[attr-defined,no-any-return]
|
||||
return ResponseTarget.channels(tokens)
|
||||
_RESPONSES_TRANSPORT_KEYS = {"input", "model", "stream", "previous_response_id"}
|
||||
|
||||
|
||||
def parse_responses_identity(body: Mapping[str, Any], channel_name: str) -> ChannelIdentity | None:
|
||||
@@ -228,7 +164,6 @@ def parse_responses_request(
|
||||
|
||||
__all__ = [
|
||||
"messages_from_responses_input",
|
||||
"parse_response_target",
|
||||
"parse_responses_identity",
|
||||
"parse_responses_request",
|
||||
]
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Any
|
||||
|
||||
from agent_framework_hosting import (
|
||||
AgentFrameworkHost,
|
||||
ChannelIdentity,
|
||||
HostedRunResult,
|
||||
)
|
||||
from starlette.testclient import TestClient
|
||||
@@ -70,22 +69,6 @@ class _FakeAgent:
|
||||
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 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -151,7 +134,17 @@ class TestResponsesChannelNonStreaming:
|
||||
# _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:
|
||||
def test_chat_isolation_header_ignored_outside_foundry(self) -> None:
|
||||
client, _host, agent = _make_client()
|
||||
with client:
|
||||
client.post(
|
||||
"/responses",
|
||||
json={"input": "x"},
|
||||
headers={"x-agent-chat-isolation-key": "chat-abc"},
|
||||
)
|
||||
assert "session" not in agent.calls[0]["kwargs"]
|
||||
|
||||
def test_chat_isolation_header_creates_session_in_foundry(self, monkeypatch: Any) -> None:
|
||||
"""Foundry-style ``x-agent-chat-isolation-key`` falls back to a session anchor.
|
||||
|
||||
First-turn requests have no ``previous_response_id`` (the client
|
||||
@@ -160,6 +153,7 @@ class TestResponsesChannelNonStreaming:
|
||||
chat key so the host can build a stable per-conversation session
|
||||
that history providers persist under.
|
||||
"""
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
client, _host, agent = _make_client()
|
||||
with client:
|
||||
client.post(
|
||||
@@ -171,13 +165,14 @@ class TestResponsesChannelNonStreaming:
|
||||
assert sess is not None
|
||||
assert sess["session_id"] == "chat-abc"
|
||||
|
||||
def test_prev_response_id_wins_over_chat_isolation_header(self) -> None:
|
||||
def test_prev_response_id_wins_over_chat_isolation_header(self, monkeypatch: Any) -> 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.
|
||||
"""
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
client, _host, agent = _make_client()
|
||||
with client:
|
||||
client.post(
|
||||
@@ -189,31 +184,11 @@ class TestResponsesChannelNonStreaming:
|
||||
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] = []
|
||||
seen_kwargs: list[dict[str, Any]] = []
|
||||
|
||||
def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult:
|
||||
contexts.append(kwargs["context"])
|
||||
seen_kwargs.append(dict(kwargs))
|
||||
return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session)
|
||||
|
||||
agent = _FakeAgent(reply="hooked")
|
||||
@@ -225,10 +200,8 @@ class TestResponsesChannelNonStreaming:
|
||||
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
|
||||
assert seen_kwargs
|
||||
assert seen_kwargs[0]["channel_name"] == "responses"
|
||||
|
||||
|
||||
class TestResponsesChannelStreaming:
|
||||
@@ -252,14 +225,15 @@ class TestResponsesChannelStreaming:
|
||||
def transform(update: _FakeUpdate) -> _FakeUpdate:
|
||||
return _FakeUpdate(text=update.text.upper())
|
||||
|
||||
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(stream_transform_hook=transform)])
|
||||
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(stream_update_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
|
||||
# Stream update hooks are update-only; they do not rewrite get_final_response().
|
||||
assert '"text":"hello"' in r.text
|
||||
|
||||
def test_sse_emits_failed_when_stream_raises(self) -> None:
|
||||
# Regression: ResponseOutputMessage.status only accepts in_progress/
|
||||
|
||||
@@ -5,11 +5,9 @@
|
||||
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,
|
||||
)
|
||||
@@ -127,64 +125,6 @@ class TestParseResponsesRequest:
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user