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:
Eduard van Valkenburg
2026-06-12 08:34:08 +02:00
committed by GitHub
Unverified
parent e5a6e35843
commit 36ce0950e4
50 changed files with 1290 additions and 11651 deletions
@@ -1,580 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for the authorization and identity-linking seam."""
from __future__ import annotations
from collections.abc import Collection
from typing import Any
import pytest
from agent_framework_hosting import (
AgentFrameworkHost,
AllOfAllowlists,
AllowAll,
Allowed,
AllowlistDecision,
AnyOfAllowlists,
AuthorizationContext,
AuthPolicy,
CallableAllowlist,
ChannelConfigurationError,
ChannelContext,
ChannelContribution,
ChannelIdentity,
Denied,
LinkChallenge,
LinkedClaimAllowlist,
LinkedIdentity,
LinkRequired,
NativeIdAllowlist,
)
# --------------------------------------------------------------------------- #
# Fakes #
# --------------------------------------------------------------------------- #
class _ChannelStub:
name: str = "stub"
path: str = "/stub"
require_link: bool = False
allowlist: Any = "inherit"
emits_verified_claims: bool = False
def __init__(
self,
*,
name: str = "stub",
require_link: bool = False,
allowlist: Any = "inherit",
emits_verified_claims: bool = False,
) -> None:
self.name = name
self.path = f"/{name}"
self.require_link = require_link
self.allowlist = allowlist
self.emits_verified_claims = emits_verified_claims
def contribute(self, context: ChannelContext) -> ChannelContribution:
return ChannelContribution(routes=[])
class _AgentStub:
"""Bare minimum target — the validators run during ``__init__``,
not on first request, so the target is never actually invoked."""
async def run(self, *args: Any, **kwargs: Any) -> Any: # pragma: no cover
raise NotImplementedError
class _StaticLinker:
"""Test linker returning either a linked identity or a challenge."""
def __init__(self, result: LinkedIdentity | LinkChallenge) -> None:
self.result = result
self.calls: list[ChannelIdentity] = []
async def resolve(self, identity: ChannelIdentity) -> LinkedIdentity | LinkChallenge:
self.calls.append(identity)
return self.result
def _ctx_pre_link(channel: str = "telegram", native_id: str = "42") -> AuthorizationContext:
return AuthorizationContext(
identity=ChannelIdentity(channel=channel, native_id=native_id),
phase="pre_link",
)
def _ctx_post_link(claims: dict[str, str] | None = None) -> AuthorizationContext:
return AuthorizationContext(
identity=ChannelIdentity(channel="telegram", native_id="42"),
phase="post_link",
isolation_key="alice",
verified_claims=claims or {},
claim_source="linker",
)
# --------------------------------------------------------------------------- #
# Built-in allowlists #
# --------------------------------------------------------------------------- #
class TestAllowAll:
async def test_allows_both_phases(self) -> None:
a = AllowAll()
assert await a.evaluate(_ctx_pre_link()) is AllowlistDecision.ALLOW
assert await a.evaluate(_ctx_post_link()) is AllowlistDecision.ALLOW
def test_does_not_require_linked_claims(self) -> None:
assert AllowAll().requires_linked_claims is False
class TestNativeIdAllowlist:
async def test_allows_listed_id(self) -> None:
a = NativeIdAllowlist({"42", "99"})
assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW
async def test_denies_unlisted_id(self) -> None:
a = NativeIdAllowlist({"42"})
assert await a.evaluate(_ctx_pre_link(native_id="99")) is AllowlistDecision.DENY
async def test_channel_filter_abstains_for_other_channels(self) -> None:
# The native-id list is scoped to "telegram" — a request from
# another channel should ABSTAIN so a combinator can give a
# parallel allowlist a chance to ALLOW.
a = NativeIdAllowlist({"42"}, channel="telegram")
assert await a.evaluate(_ctx_pre_link(channel="slack", native_id="42")) is AllowlistDecision.ABSTAIN
async def test_channel_filter_evaluates_matching_channel(self) -> None:
a = NativeIdAllowlist({"42"}, channel="telegram")
assert await a.evaluate(_ctx_pre_link(channel="telegram", native_id="42")) is AllowlistDecision.ALLOW
assert await a.evaluate(_ctx_pre_link(channel="telegram", native_id="99")) is AllowlistDecision.DENY
async def test_async_loader_caches_after_first_call(self) -> None:
# The loader should run once; subsequent ``evaluate`` calls hit
# the cache so a slow / costly source isn't re-queried per
# message.
calls = {"n": 0}
async def loader() -> Collection[str]:
calls["n"] += 1
return {"42"}
a = NativeIdAllowlist(loader)
assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW
assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW
assert calls["n"] == 1
class TestLinkedClaimAllowlist:
"""Claim allowlists abstain pre-link and decide once claims are available."""
def test_declares_requires_linked_claims(self) -> None:
a = LinkedClaimAllowlist("oid", ["abc"])
assert a.requires_linked_claims is True
async def test_pre_link_abstains(self) -> None:
a = LinkedClaimAllowlist("oid", ["abc"])
assert await a.evaluate(_ctx_pre_link()) is AllowlistDecision.ABSTAIN
async def test_post_link_allows_matching_claim(self) -> None:
a = LinkedClaimAllowlist("oid", ["abc"])
assert await a.evaluate(_ctx_post_link({"oid": "abc"})) is AllowlistDecision.ALLOW
async def test_post_link_allows_matching_multi_value_claim(self) -> None:
a = LinkedClaimAllowlist("groups", ["admins"])
ctx = AuthorizationContext(
identity=ChannelIdentity(channel="telegram", native_id="42"),
phase="post_link",
isolation_key="alice",
verified_claims={"groups": ("users", "admins")},
claim_source="linker",
)
assert await a.evaluate(ctx) is AllowlistDecision.ALLOW
async def test_post_link_denies_missing_or_nonmatching_claim(self) -> None:
a = LinkedClaimAllowlist("oid", ["abc"])
assert await a.evaluate(_ctx_post_link({"oid": "def"})) is AllowlistDecision.DENY
assert await a.evaluate(_ctx_post_link({"tid": "abc"})) is AllowlistDecision.DENY
class TestAnyOfAllowlists:
async def test_any_allow_wins(self) -> None:
a = AnyOfAllowlists(NativeIdAllowlist({"42"}), NativeIdAllowlist({"99"}))
# native_id=42 → first ALLOWs, short-circuit.
assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW
async def test_all_deny_yields_deny(self) -> None:
# Both lists deny native_id=7.
a = AnyOfAllowlists(NativeIdAllowlist({"42"}), NativeIdAllowlist({"99"}))
assert await a.evaluate(_ctx_pre_link(native_id="7")) is AllowlistDecision.DENY
async def test_abstain_when_no_decision(self) -> None:
# Channel-scoped lists both ABSTAIN on a "slack" request.
a = AnyOfAllowlists(
NativeIdAllowlist({"42"}, channel="telegram"),
NativeIdAllowlist({"99"}, channel="teams"),
)
assert await a.evaluate(_ctx_pre_link(channel="slack", native_id="42")) is AllowlistDecision.ABSTAIN
async def test_empty_is_abstain(self) -> None:
# No children → ABSTAIN (not DENY) to avoid silent deny-all.
a = AnyOfAllowlists()
assert await a.evaluate(_ctx_pre_link()) is AllowlistDecision.ABSTAIN
def test_propagates_requires_linked_claims(self) -> None:
a = AnyOfAllowlists(NativeIdAllowlist({"42"}), LinkedClaimAllowlist("oid", []))
assert a.requires_linked_claims is True
class TestAllOfAllowlists:
async def test_any_deny_short_circuits(self) -> None:
a = AllOfAllowlists(NativeIdAllowlist({"42"}), NativeIdAllowlist({"99"}))
assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.DENY
async def test_all_allow_yields_allow(self) -> None:
a = AllOfAllowlists(NativeIdAllowlist({"42"}), NativeIdAllowlist({"42", "99"}))
assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW
async def test_abstain_when_no_deny_but_no_unanimous_allow(self) -> None:
a = AllOfAllowlists(
NativeIdAllowlist({"42"}, channel="telegram"),
NativeIdAllowlist({"42"}, channel="teams"),
)
# ABSTAIN from teams (different channel), ALLOW from telegram → ABSTAIN.
assert await a.evaluate(_ctx_pre_link(channel="telegram", native_id="42")) is AllowlistDecision.ABSTAIN
async def test_empty_is_abstain(self) -> None:
a = AllOfAllowlists()
assert await a.evaluate(_ctx_pre_link()) is AllowlistDecision.ABSTAIN
class TestCallableAllowlist:
async def test_wraps_async_fn(self) -> None:
async def fn(ctx: AuthorizationContext) -> AllowlistDecision:
if ctx.identity.native_id == "42":
return AllowlistDecision.ALLOW
return AllowlistDecision.DENY
a = CallableAllowlist(fn)
assert await a.evaluate(_ctx_pre_link(native_id="42")) is AllowlistDecision.ALLOW
assert await a.evaluate(_ctx_pre_link(native_id="99")) is AllowlistDecision.DENY
def test_requires_linked_claims_passthrough(self) -> None:
async def fn(_: AuthorizationContext) -> AllowlistDecision: # pragma: no cover
return AllowlistDecision.ALLOW
a = CallableAllowlist(fn, requires_linked_claims=True)
assert a.requires_linked_claims is True
class TestAuthPolicy:
async def test_factory_helpers_return_working_allowlists(self) -> None:
assert await AuthPolicy.open().evaluate(_ctx_pre_link()) is AllowlistDecision.ALLOW
assert await AuthPolicy.native_ids({"42"}).evaluate(_ctx_pre_link()) is AllowlistDecision.ALLOW
assert await AuthPolicy.linked_claim("oid", {"abc"}).evaluate(_ctx_post_link({"oid": "abc"})) is (
AllowlistDecision.ALLOW
)
async def test_custom_factory(self) -> None:
async def fn(_: AuthorizationContext) -> AllowlistDecision:
return AllowlistDecision.ALLOW
policy = AuthPolicy.custom(fn, requires_linked_claims=True)
assert policy.requires_linked_claims is True
assert await policy.evaluate(_ctx_pre_link()) is AllowlistDecision.ALLOW
# --------------------------------------------------------------------------- #
# Host configuration validator #
# --------------------------------------------------------------------------- #
class TestChannelAuthorizationValidator:
"""The host's startup validator catches three classes of misconfig
so they fail at construction rather than silently denying every
user at runtime."""
def test_require_link_without_linker_raises(self) -> None:
# ``require_link=True`` with no linker would silently reject
# every request — caught at construction.
with pytest.raises(ChannelConfigurationError, match="identity_linker"):
AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub(require_link=True)],
)
def test_require_link_with_linker_passes(self) -> None:
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub(require_link=True)],
identity_linker=_StaticLinker(LinkedIdentity("alice", {"oid": "abc"})),
)
assert host.runtime_mode == "long_running"
def test_linked_claim_allowlist_without_claim_source_raises(self) -> None:
# The channel has no ``require_link=True`` AND doesn't emit
# claims natively → the allowlist would always DENY / ABSTAIN.
with pytest.raises(ChannelConfigurationError, match="verified IdP claims"):
AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub(allowlist=LinkedClaimAllowlist("oid", []))],
)
def test_linked_claim_allowlist_with_native_claim_source_passes(self) -> None:
# When the channel declares ``emits_verified_claims=True``
# (e.g. Activity Protocol with AAD bearer) the validator
# accepts the LinkedClaimAllowlist without needing a linker.
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[
_ChannelStub(
allowlist=LinkedClaimAllowlist("oid", ["abc"]),
emits_verified_claims=True,
)
],
)
assert host.default_allowlist is None
def test_linked_claim_allowlist_with_require_link_and_linker_passes(self) -> None:
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub(require_link=True, allowlist=LinkedClaimAllowlist("oid", ["abc"]))],
identity_linker=_StaticLinker(LinkedIdentity("alice", {"oid": "abc"})),
)
assert host.runtime_mode == "long_running"
def test_native_id_allowlist_unknown_channel_raises(self) -> None:
with pytest.raises(ChannelConfigurationError, match="unknown channel 'mystery'"):
AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub(allowlist=NativeIdAllowlist({"42"}, channel="mystery"))],
)
def test_native_id_allowlist_known_channel_passes(self) -> None:
# A channel-scoped native list pointing at a peer channel is
# the supported way to compose per-channel allowlists.
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[
_ChannelStub(name="telegram", allowlist=NativeIdAllowlist({"42"}, channel="telegram")),
_ChannelStub(name="slack"),
],
)
assert host.runtime_mode == "long_running"
def test_default_allowlist_applies_to_inheriting_channel(self) -> None:
# ``allowlist="inherit"`` (the default) picks up the host-level
# ``default_allowlist``. This is the "lock down a whole bot in
# one place" ergonomic.
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub(name="telegram")],
default_allowlist=NativeIdAllowlist({"42"}),
)
# The default flowed through; channel sees the host's allowlist.
assert host.default_allowlist is not None
def test_explicit_none_carve_out_overrides_default(self) -> None:
# ``allowlist=None`` on a channel explicitly opts out of the
# host default — useful for a public endpoint inside an
# otherwise locked-down host.
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub(name="public", allowlist=None)],
default_allowlist=NativeIdAllowlist({"42"}),
)
# Construction succeeded; the validator did not raise.
assert host.default_allowlist is not None
def test_combinator_with_unknown_nested_channel_raises(self) -> None:
# The validator walks ``AnyOfAllowlists`` / ``AllOfAllowlists``
# so a typo'd channel name nested under a combinator is still
# caught at construction.
with pytest.raises(ChannelConfigurationError, match="unknown channel 'typo'"):
AgentFrameworkHost(
target=_AgentStub(),
channels=[
_ChannelStub(
allowlist=AnyOfAllowlists(
NativeIdAllowlist({"42"}, channel="stub"),
NativeIdAllowlist({"99"}, channel="typo"),
)
)
],
)
# --------------------------------------------------------------------------- #
# host.authorize pipeline #
# --------------------------------------------------------------------------- #
class TestHostAuthorize:
"""Host authorization pipeline across open, native-id, and linked-claim profiles."""
def _host(self) -> AgentFrameworkHost:
return AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()])
async def test_open_profile_returns_allowed_with_auto_isolation_key(self) -> None:
host = self._host()
outcome = await host.authorize(ChannelIdentity(channel="telegram", native_id="42"))
assert isinstance(outcome, Allowed)
assert outcome.isolation_key == "telegram:42"
async def test_native_allowlist_allows_listed_id(self) -> None:
host = self._host()
outcome = await host.authorize(
ChannelIdentity(channel="telegram", native_id="42"),
allowlist=NativeIdAllowlist({"42"}),
)
assert isinstance(outcome, Allowed)
assert outcome.isolation_key == "telegram:42"
async def test_native_allowlist_denies_unlisted_id(self) -> None:
host = self._host()
outcome = await host.authorize(
ChannelIdentity(channel="telegram", native_id="99"),
allowlist=NativeIdAllowlist({"42"}),
)
assert isinstance(outcome, Denied)
assert outcome.reason_code == "allowlist_denied_pre_link"
assert outcome.user_message is not None
# The bland default leaks neither tenant nor list size.
assert "telegram" not in (outcome.user_message or "")
async def test_abstain_with_claim_requirement_yields_link_required_message(self) -> None:
# Without a linker and without channel-emitted claims, a claim-required
# allowlist cannot make progress and the host returns a safe denial.
async def abstain(_: AuthorizationContext) -> AllowlistDecision:
return AllowlistDecision.ABSTAIN
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub(emits_verified_claims=True)],
)
outcome = await host.authorize(
ChannelIdentity(channel="telegram", native_id="42"),
allowlist=CallableAllowlist(abstain, requires_linked_claims=True),
)
assert isinstance(outcome, Denied)
assert outcome.reason_code == "allowlist_requires_link"
async def test_abstain_without_claim_requirement_falls_through_to_allowed(self) -> None:
async def abstain(_: AuthorizationContext) -> AllowlistDecision:
return AllowlistDecision.ABSTAIN
host = self._host()
outcome = await host.authorize(
ChannelIdentity(channel="telegram", native_id="42"),
allowlist=CallableAllowlist(abstain),
)
assert isinstance(outcome, Allowed)
async def test_auto_issue_returns_existing_key_when_known(self) -> None:
# When an identity has already been observed, the auto-issued
# key matches the existing one rather than coining a fresh
# token. This is the linker-free equivalent of identity resolution.
host = self._host()
host._identities["alice"] = {"telegram": ChannelIdentity(channel="telegram", native_id="42")}
outcome = await host.authorize(ChannelIdentity(channel="telegram", native_id="42"))
assert isinstance(outcome, Allowed)
assert outcome.isolation_key == "alice"
async def test_verified_claims_propagate_to_context(self) -> None:
# Channels that natively carry verified claims (e.g. Activity
# Protocol bearer with AAD oid) pass them through to
# ``authorize`` — the allowlist sees them on the
# ``AuthorizationContext``.
seen: list[AuthorizationContext] = []
async def capture(ctx: AuthorizationContext) -> AllowlistDecision:
seen.append(ctx)
return AllowlistDecision.ALLOW
host = self._host()
await host.authorize(
ChannelIdentity(channel="telegram", native_id="42"),
allowlist=CallableAllowlist(capture),
verified_claims={"oid": "abc"},
)
assert len(seen) == 1
assert seen[0].claim_source == "channel"
assert dict(seen[0].verified_claims) == {"oid": "abc"}
async def test_require_link_returns_challenge_when_unlinked(self) -> None:
challenge = LinkChallenge("c1", url="https://login.example/c1")
linker = _StaticLinker(challenge)
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub(require_link=True)],
identity_linker=linker,
)
outcome = await host.authorize(
ChannelIdentity(channel="telegram", native_id="42"),
require_link=True,
)
assert isinstance(outcome, LinkRequired)
assert outcome.challenge is challenge
assert [call.native_id for call in linker.calls] == ["42"]
async def test_require_link_returns_linked_identity_when_resolved(self) -> None:
linked = LinkedIdentity("entra:abc", {"oid": "abc"})
linker = _StaticLinker(linked)
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub(require_link=True)],
identity_linker=linker,
)
outcome = await host.authorize(
ChannelIdentity(channel="telegram", native_id="42"),
require_link=True,
)
assert isinstance(outcome, Allowed)
assert outcome.isolation_key == "entra:abc"
assert dict(outcome.verified_claims) == {"oid": "abc"}
assert outcome.claim_source == "linker"
# authorize() is decision-only; identity registry writes remain on
# the request execution path.
assert host._identities == {}
async def test_linked_claim_allowlist_with_linker_allows_matching_claim(self) -> None:
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub(require_link=True, allowlist=LinkedClaimAllowlist("oid", ["abc"]))],
identity_linker=_StaticLinker(LinkedIdentity("entra:abc", {"oid": "abc"})),
)
outcome = await host.authorize(
ChannelIdentity(channel="telegram", native_id="42"),
require_link=True,
allowlist=LinkedClaimAllowlist("oid", ["abc"]),
)
assert isinstance(outcome, Allowed)
assert outcome.isolation_key == "entra:abc"
async def test_linked_claim_allowlist_with_linker_denies_nonmatching_claim(self) -> None:
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub(require_link=True, allowlist=LinkedClaimAllowlist("oid", ["abc"]))],
identity_linker=_StaticLinker(LinkedIdentity("entra:def", {"oid": "def"})),
)
outcome = await host.authorize(
ChannelIdentity(channel="telegram", native_id="42"),
require_link=True,
allowlist=LinkedClaimAllowlist("oid", ["abc"]),
)
assert isinstance(outcome, Denied)
assert outcome.reason_code == "allowlist_denied_post_link"
async def test_linked_claim_allowlist_with_linker_returns_challenge_when_unlinked(self) -> None:
challenge = LinkChallenge("c1")
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub(require_link=True, allowlist=LinkedClaimAllowlist("oid", ["abc"]))],
identity_linker=_StaticLinker(challenge),
)
outcome = await host.authorize(
ChannelIdentity(channel="telegram", native_id="42"),
require_link=True,
allowlist=LinkedClaimAllowlist("oid", ["abc"]),
)
assert isinstance(outcome, LinkRequired)
assert outcome.challenge is challenge
async def test_linked_claim_allowlist_uses_channel_verified_claims_without_linker(self) -> None:
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub(emits_verified_claims=True, allowlist=LinkedClaimAllowlist("oid", ["abc"]))],
)
outcome = await host.authorize(
ChannelIdentity(channel="activity", native_id="aad-user"),
allowlist=LinkedClaimAllowlist("oid", ["abc"]),
verified_claims={"oid": "abc"},
)
assert isinstance(outcome, Allowed)
assert outcome.isolation_key == "activity:aad-user"
assert outcome.claim_source == "channel"
+77 -629
View File
@@ -4,7 +4,7 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
from collections.abc import AsyncIterator, Sequence
from dataclasses import dataclass, field
from typing import Any
@@ -21,16 +21,9 @@ from agent_framework_hosting import (
ChannelContext,
ChannelContribution,
ChannelIdentity,
ChannelPush,
ChannelRequest,
ChannelSession,
DurableTaskPayloadMode,
DurableTaskRunner,
HostedRunResult,
ResponseTarget,
RetryPolicy,
TaskHandle,
TaskStatus,
)
@@ -75,27 +68,33 @@ class _FakeAgent:
self.created_sessions.append(s)
return s
async def run(self, messages: Any = None, *, stream: bool = False, session: Any = None, **kwargs: Any) -> Any:
def run(self, messages: Any = None, *, stream: bool = False, session: Any = None, **kwargs: Any) -> Any:
self.calls.append({"messages": messages, "stream": stream, "session": session, "kwargs": kwargs})
if stream: # pragma: no cover - not used by these tests
if stream:
updates = [AgentResponseUpdate(contents=[Content.from_text(text=self._reply)], role="assistant")]
async def _gen() -> AsyncIterator[Any]:
yield self._reply
async def _gen() -> AsyncIterator[AgentResponseUpdate]:
for update in updates:
yield update
return _gen()
return _FakeAgentResponse(text=self._reply)
async def _finalize(items: Sequence[AgentResponseUpdate]) -> AgentResponse: # noqa: RUF029
return AgentResponse.from_updates(items)
return ResponseStream[AgentResponseUpdate, AgentResponse](_gen(), finalizer=_finalize)
async def _coro() -> _FakeAgentResponse:
return _FakeAgentResponse(text=self._reply)
return _coro()
class _RecordingChannel:
"""Minimal :class:`Channel` + :class:`ChannelPush` for routing tests."""
"""Minimal :class:`Channel` for host tests."""
def __init__(self, name: str = "fake", path: str = "/fake", supports_push: bool = True) -> None:
def __init__(self, name: str = "fake", path: str = "/fake") -> None:
self.name = name
self.path = path
self.context: ChannelContext | None = None
self.pushes: list[tuple[ChannelIdentity, HostedRunResult[Any]]] = []
self._push_raises: Exception | None = None
self._supports_push = supports_push
# Provide a single trivial route so contribute() exercises the endpoint path.
self._routes: Sequence[BaseRoute] = (Route("/ping", _ping),)
@@ -103,88 +102,6 @@ class _RecordingChannel:
self.context = context
return ChannelContribution(routes=self._routes)
async def push(self, identity: ChannelIdentity, payload: HostedRunResult[Any]) -> None:
if self._push_raises is not None:
raise self._push_raises
self.pushes.append((identity, payload))
class _NoPushChannel:
"""A channel that does NOT implement :class:`ChannelPush`."""
def __init__(self, name: str = "nopush", path: str = "/nopush") -> None:
self.name = name
self.path = path
def contribute(self, context: ChannelContext) -> ChannelContribution:
return ChannelContribution()
class _SyncTaskRunner(DurableTaskRunner):
"""A :class:`DurableTaskRunner` that runs handlers inline.
Tests of the delivery routing want deterministic, synchronous
behaviour. The real :class:`InProcessTaskRunner` schedules via
``asyncio.create_task`` so push side effects only land *after*
the test has yielded control — awkward for assertions that read
a channel's recorded pushes immediately after
:meth:`ChannelContext.deliver_response` returns.
Two knobs control failure handling:
- ``schedule_raises``: when set, every call to :meth:`schedule`
raises this exception. Mimics a host-side outage (the durable
backend is unreachable).
- ``swallow_handler_errors`` (default ``True``): when the
handler raises, the error is recorded in
:attr:`handler_errors` but :meth:`schedule` still returns
successfully — matching the real durable contract that
"scheduled" is a separate signal from "delivered". Set to
``False`` to surface handler exceptions through
:meth:`schedule` for the few tests that want to assert on
handler-raised failures inline.
"""
def __init__(self, *, swallow_handler_errors: bool = True) -> None:
self._handlers: dict[str, Callable[[Mapping[str, Any]], Awaitable[None]]] = {}
self.scheduled: list[tuple[str, Mapping[str, Any]]] = []
self.handler_errors: list[BaseException] = []
self.schedule_raises: BaseException | None = None
self.swallow_handler_errors = swallow_handler_errors
# Default object-mode matches the real ``InProcessTaskRunner`` —
# tests that want to exercise the JSON-mode path override this on
# the instance.
payload_mode = DurableTaskPayloadMode.OBJECT
def register(
self,
name: str,
handler: Callable[[Mapping[str, Any]], Awaitable[None]],
) -> None:
self._handlers[name] = handler
async def schedule(
self,
name: str,
payload: Mapping[str, Any],
*,
retry_policy: RetryPolicy | None = None,
) -> TaskHandle:
if self.schedule_raises is not None:
raise self.schedule_raises
self.scheduled.append((name, payload))
try:
await self._handlers[name](payload)
except Exception as exc:
self.handler_errors.append(exc)
if not self.swallow_handler_errors:
raise
return TaskHandle(task_id=f"sync-{len(self.scheduled)}", name=name)
async def get(self, handle: TaskHandle) -> TaskStatus | None: # pragma: no cover - unused
return "succeeded"
def _assistant_response(text: str) -> AgentResponse:
"""Build a one-message ``AgentResponse`` to use as a ``HostedRunResult.result``."""
@@ -227,7 +144,6 @@ class TestHostWiring:
def test_channel_is_recognized(self) -> None:
ch = _RecordingChannel()
assert isinstance(ch, Channel)
assert isinstance(ch, ChannelPush)
def test_app_mounts_channel_routes_under_path(self) -> None:
agent = _FakeAgent()
@@ -313,10 +229,6 @@ class TestHostInvoke:
"native_id": "user:1",
"attributes": {},
}
assert msg.additional_properties["hosting"]["response_target"] == {
"kind": "originating",
"targets": [],
}
async def test_invoke_caches_session_per_isolation_key(self) -> None:
agent = _FakeAgent()
@@ -398,6 +310,56 @@ class TestHostInvoke:
assert agent.calls[0]["kwargs"]["options"] == {"temperature": 0.4}
class TestHostOwnedHooks:
async def test_context_run_applies_run_hook_before_invocation(self) -> None:
agent = _FakeAgent()
ch = _RecordingChannel()
host = AgentFrameworkHost(target=agent, channels=[ch])
_ = host.app
assert ch.context is not None
captured: dict[str, Any] = {}
async def hook(request: ChannelRequest, **kwargs: Any) -> ChannelRequest:
captured["target"] = kwargs["target"]
captured["protocol_request"] = kwargs["protocol_request"]
return ChannelRequest(
channel=request.channel,
operation=request.operation,
input="rewritten",
session=request.session,
)
req = ChannelRequest(channel=ch.name, operation="op", input="original", session=ChannelSession("alice"))
await ch.context.run(req, run_hook=hook, protocol_request={"raw": True})
assert captured["target"] is agent
assert captured["protocol_request"] == {"raw": True}
assert agent.calls[0]["messages"].text == "rewritten"
async def test_context_run_stream_applies_run_hook_before_opening_stream(self) -> None:
agent = _FakeAgent()
ch = _RecordingChannel()
host = AgentFrameworkHost(target=agent, channels=[ch])
_ = host.app
assert ch.context is not None
def hook(request: ChannelRequest, **_: Any) -> ChannelRequest:
return ChannelRequest(channel=request.channel, operation=request.operation, input="streamed")
stream = await ch.context.run_stream(
ChannelRequest(channel=ch.name, operation="op", input="original"),
run_hook=hook,
stream_update_hook=lambda update: AgentResponseUpdate(
contents=[Content.from_text(text=update.text.upper())],
role="assistant",
),
)
chunks = [update.text async for update in stream]
assert chunks == ["OK"]
assert agent.calls[0]["messages"].text == "streamed"
# --------------------------------------------------------------------------- #
# Workflow target #
# --------------------------------------------------------------------------- #
@@ -436,7 +398,7 @@ class TestHostWorkflowTarget:
assert ch.context is not None
req = ChannelRequest(channel="fake", operation="message.create", input="hi")
stream = ch.context.run_stream(req)
stream = await ch.context.run_stream(req)
updates: list[AgentResponseUpdate] = []
async for update in stream:
@@ -464,7 +426,7 @@ class TestHostWorkflowTarget:
assert ch.context is not None
req = ChannelRequest(channel="fake", operation="message.create", input="x")
stream = ch.context.run_stream(req)
stream = await ch.context.run_stream(req)
chunks: list[str] = []
async for update in stream:
@@ -561,7 +523,7 @@ class TestHostWorkflowCheckpointing:
input="hi",
session=ChannelSession(isolation_key="bob"),
)
stream = ch.context.run_stream(req)
stream = await ch.context.run_stream(req)
async for _ in stream:
pass
await stream.get_final_response()
@@ -702,520 +664,6 @@ class TestHostWorkflowCheckpointingPathTraversal:
assert list(tmp_path.iterdir()) == []
# --------------------------------------------------------------------------- #
# Delivery routing #
# --------------------------------------------------------------------------- #
def _make_host_with_two_channels(
*,
runner: DurableTaskRunner | None = None,
) -> tuple[AgentFrameworkHost, _RecordingChannel, _RecordingChannel, ChannelContext, _SyncTaskRunner]:
agent = _FakeAgent()
a = _RecordingChannel(name="responses", path="/r")
b = _RecordingChannel(name="telegram", path="/t")
sync_runner = runner if isinstance(runner, _SyncTaskRunner) else _SyncTaskRunner()
host = AgentFrameworkHost(
target=agent,
channels=[a, b],
durable_task_runner=runner or sync_runner,
)
_ = host.app
assert a.context is not None
return host, a, b, a.context, sync_runner
def _record_identity_on(host: AgentFrameworkHost, isolation_key: str, channel: str, native_id: str) -> None:
"""Pre-seed the host's identity registry by running a request."""
host._identities.setdefault(isolation_key, {})[channel] = ChannelIdentity(channel=channel, native_id=native_id)
host._active[isolation_key] = channel
class TestDeliverResponse:
"""Delivery routing — the originating channel learns whether to render
on its own wire from the ``bool`` return; everything else
(scheduled tasks, schedule-time failures, skip reasons) lives in
the runner's own log. Tests assert the bool plus observable
state on the sync runner fake (``scheduled``, ``handler_errors``)
and on the destination channels (``pushes``)."""
async def test_originating_returns_true(self) -> None:
_, _, _, ctx, runner = _make_host_with_two_channels()
req = ChannelRequest(channel="responses", operation="op", input="x")
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
assert include_originating is True
assert runner.scheduled == []
async def test_none_suppresses_everything(self) -> None:
_, _, _, ctx, runner = _make_host_with_two_channels()
req = ChannelRequest(
channel="responses",
operation="op",
input="x",
response_target=ResponseTarget.none, # type: ignore[attr-defined]
)
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
assert include_originating is False
assert runner.scheduled == []
async def test_active_pushes_to_other_channel(self) -> None:
host, _a, b, ctx, runner = _make_host_with_two_channels()
# Alice was last seen on telegram.
_record_identity_on(host, "alice", "telegram", "42")
# Now she sends a message via responses; ResponseTarget.active should
# push to telegram, not back to responses.
req = ChannelRequest(
channel="responses",
operation="op",
input="x",
session=ChannelSession(isolation_key="alice"),
response_target=ResponseTarget.active, # type: ignore[attr-defined]
)
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
assert include_originating is False
assert len(runner.scheduled) == 1
assert b.pushes and b.pushes[0][0].native_id == "42"
async def test_active_falls_back_to_originating_when_self(self) -> None:
host, _a, _b, ctx, runner = _make_host_with_two_channels()
_record_identity_on(host, "alice", "responses", "user:1")
req = ChannelRequest(
channel="responses",
operation="op",
input="x",
session=ChannelSession(isolation_key="alice"),
response_target=ResponseTarget.active, # type: ignore[attr-defined]
)
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
assert include_originating is True
assert runner.scheduled == []
async def test_channels_with_unknown_identity_falls_back_to_originating(self) -> None:
_, _, _, ctx, runner = _make_host_with_two_channels()
# No prior identity seeded for telegram on alice.
req = ChannelRequest(
channel="responses",
operation="op",
input="x",
session=ChannelSession(isolation_key="alice"),
response_target=ResponseTarget.channel("telegram"),
)
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
# Skipped at resolution → fallback to originating so the user
# still gets a reply.
assert include_originating is True
assert runner.scheduled == []
async def test_channels_with_explicit_native_id_token(self) -> None:
_, _, b, ctx, runner = _make_host_with_two_channels()
req = ChannelRequest(
channel="responses",
operation="op",
input="x",
response_target=ResponseTarget.channel("telegram:99"),
)
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
assert include_originating is False
assert len(runner.scheduled) == 1
assert b.pushes[0][0].native_id == "99"
async def test_channels_originating_pseudo_includes_origin(self) -> None:
host, _a, _b, ctx, runner = _make_host_with_two_channels()
_record_identity_on(host, "alice", "telegram", "42")
req = ChannelRequest(
channel="responses",
operation="op",
input="x",
session=ChannelSession(isolation_key="alice"),
response_target=ResponseTarget.channels(["originating", "telegram"]),
)
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
assert include_originating is True
assert len(runner.scheduled) == 1
async def test_channels_unknown_channel_name_falls_back(self) -> None:
_, _, _, ctx, runner = _make_host_with_two_channels()
req = ChannelRequest(
channel="responses",
operation="op",
input="x",
response_target=ResponseTarget.channel("nope"),
)
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
assert include_originating is True # fallback
assert runner.scheduled == []
async def test_no_push_capability_falls_back(self) -> None:
agent = _FakeAgent()
a = _RecordingChannel(name="responses", path="/r")
b = _NoPushChannel(name="nopush", path="/n")
host = AgentFrameworkHost(target=agent, channels=[a, b])
_ = host.app
assert a.context is not None
# Pre-seed identity on the no-push channel so we get past the
# identity check and hit the ChannelPush check.
host._identities.setdefault("alice", {})["nopush"] = ChannelIdentity(channel="nopush", native_id="42")
req = ChannelRequest(
channel="responses",
operation="op",
input="x",
session=ChannelSession(isolation_key="alice"),
response_target=ResponseTarget.channel("nopush"),
)
include_originating = await a.context.deliver_response(req, _make_reply("reply"))
assert include_originating is True # fallback
async def test_all_linked_pushes_to_every_other_channel(self) -> None:
host, _a, b, ctx, runner = _make_host_with_two_channels()
# Alice on responses (originating) and telegram.
host._identities.setdefault("alice", {})
host._identities["alice"]["responses"] = ChannelIdentity(channel="responses", native_id="user:1")
host._identities["alice"]["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
req = ChannelRequest(
channel="responses",
operation="op",
input="x",
session=ChannelSession(isolation_key="alice"),
response_target=ResponseTarget.all_linked, # type: ignore[attr-defined]
)
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
assert include_originating is True
assert len(runner.scheduled) == 1
assert b.pushes and b.pushes[0][1].result.text == "reply"
async def test_all_linked_no_other_channels_falls_back(self) -> None:
_host, _a, _b, ctx, runner = _make_host_with_two_channels()
req = ChannelRequest(
channel="responses",
operation="op",
input="x",
session=ChannelSession(isolation_key="alice"),
response_target=ResponseTarget.all_linked, # type: ignore[attr-defined]
)
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
assert include_originating is True
assert runner.scheduled == []
async def test_identities_variant_preserves_attributes(self) -> None:
"""``ResponseTarget.identities([...])`` plumbs full
:class:`ChannelIdentity` objects through resolution, preserving
``attributes`` for destination channels that need conversation/
thread metadata (Teams, Slack, Bot Framework)."""
_, _, b, ctx, runner = _make_host_with_two_channels()
ident = ChannelIdentity(
channel="telegram",
native_id="42",
attributes={"thread_id": "t1", "service_url": "https://x"},
)
req = ChannelRequest(
channel="responses",
operation="op",
input="x",
response_target=ResponseTarget.identity(ident),
)
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
assert include_originating is False
assert len(runner.scheduled) == 1
# The destination identity arrived at push with attributes intact.
pushed_identity = b.pushes[0][0]
assert pushed_identity.native_id == "42"
assert dict(pushed_identity.attributes) == {"thread_id": "t1", "service_url": "https://x"}
async def test_identities_pointing_to_originating_includes_origin(self) -> None:
"""An identity whose channel matches the originating channel
folds into ``include_originating`` rather than double-delivering
via push."""
_, _, _, ctx, runner = _make_host_with_two_channels()
ident = ChannelIdentity(channel="responses", native_id="user:1")
req = ChannelRequest(
channel="responses",
operation="op",
input="x",
response_target=ResponseTarget.identities([ident]),
)
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
assert include_originating is True
assert runner.scheduled == []
async def test_handler_exception_does_not_change_return_value(self) -> None:
"""When ``ChannelPush.push`` raises *inside the runner handler*
the originating channel still sees the same return value —
``DurableTaskRunner.schedule`` accepted the work, and downstream
delivery outcome is owned by the runner (it logs and retries
per the configured ``RetryPolicy``)."""
host, _a, b, ctx, runner = _make_host_with_two_channels()
b._push_raises = RuntimeError("boom") # type: ignore[attr-defined]
host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
req = ChannelRequest(
channel="responses",
operation="op",
input="x",
session=ChannelSession(isolation_key="alice"),
response_target=ResponseTarget.channel("telegram"),
)
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
# Schedule succeeded → the return value is unaffected by a
# downstream handler failure.
assert include_originating is False
assert len(runner.scheduled) == 1
# Handler raised — runner captured the error (the real runner
# would retry it; the sync fake records it).
assert runner.handler_errors and isinstance(runner.handler_errors[0], RuntimeError)
assert str(runner.handler_errors[0]) == "boom"
async def test_schedule_exception_falls_back_to_originating(self) -> None:
"""When :meth:`DurableTaskRunner.schedule` itself raises (the
runner backend is unreachable) the destination is treated as
skipped — same outcome as any other resolution-time drop. The
host's fall-back-to-originating rule then ensures the user
still gets a reply rather than being left without one."""
host, _a, _b, ctx, runner = _make_host_with_two_channels()
runner.schedule_raises = RuntimeError("runner backend unreachable")
host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
req = ChannelRequest(
channel="responses",
operation="op",
input="x",
session=ChannelSession(isolation_key="alice"),
response_target=ResponseTarget.channel("telegram"),
)
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
# Schedule raised → no scheduled tasks, fall back to originating.
assert runner.scheduled == []
assert include_originating is True
async def test_echo_input_pushes_user_message_then_response(self) -> None:
"""``echo_input=True`` triggers two pushes per destination,
bundled into the same scheduled task: the originating user
message first, then the agent reply. Channels downstream of a
workflow that emits to multiple channels need this to keep
their UI state coherent with the user's actual prompt."""
host, _a, b, ctx, runner = _make_host_with_two_channels()
host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
req = ChannelRequest(
channel="responses",
operation="op",
input="hello there",
session=ChannelSession(isolation_key="alice"),
response_target=ResponseTarget.channel("telegram", echo_input=True),
)
include_originating = await ctx.deliver_response(req, _make_reply("reply"))
assert include_originating is False
# One scheduled task per destination; the handler does echo then response inline.
assert len(runner.scheduled) == 1
_, payload = runner.scheduled[0]
assert payload["echo_result"] is not None
# Two pushes landed on the channel: echo first, then response.
assert len(b.pushes) == 2
echo_identity, echo_payload = b.pushes[0]
assert echo_identity.native_id == "42"
assert echo_payload.result.text == "hello there"
assert str(echo_payload.result.messages[0].role) == "user"
resp_identity, resp_payload = b.pushes[1]
assert resp_identity.native_id == "42"
assert resp_payload.result.text == "reply"
assert str(resp_payload.result.messages[0].role) == "assistant"
async def test_echo_input_failure_does_not_block_response(self) -> None:
"""An echo push that raises inside the handler is logged and
swallowed; the response push must still be attempted on the
same destination so the user-visible failure mode is
"response delivered without echo" rather than "no response at
all"."""
agent = _FakeAgent()
a = _RecordingChannel(name="responses", path="/r")
b = _RecordingChannel(name="telegram", path="/t")
runner = _SyncTaskRunner()
host = AgentFrameworkHost(target=agent, channels=[a, b], durable_task_runner=runner)
_ = host.app
assert a.context is not None
host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
# Make the FIRST push (echo) raise, but the SECOND (response) succeed.
calls = {"n": 0}
real_push = b.push
async def flaky_push(identity: ChannelIdentity, payload: HostedRunResult[Any]) -> None:
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("echo down")
await real_push(identity, payload)
b.push = flaky_push # type: ignore[method-assign]
req = ChannelRequest(
channel="responses",
operation="op",
input="hi",
session=ChannelSession(isolation_key="alice"),
response_target=ResponseTarget.channel("telegram", echo_input=True),
)
include_originating = await a.context.deliver_response(req, _make_reply("reply"))
# Schedule succeeded; handler swallowed the echo failure and
# the response push landed on the channel.
assert include_originating is False
assert b.pushes and b.pushes[0][1].result.text == "reply"
# Handler did not raise (echo failure was swallowed inside
# the handler), so the runner saw no error.
assert runner.handler_errors == []
async def test_echo_idempotent_on_retry(self) -> None:
"""When the response push fails on a retried task, the handler
must NOT re-deliver the echo if a prior attempt already
succeeded. The ``echo_done`` cursor on the payload mapping is
the host's idempotency primitive; this test invokes the
handler directly twice with the same payload to exercise the
retry semantics."""
host, _a, b, ctx, runner = _make_host_with_two_channels()
host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
req = ChannelRequest(
channel="responses",
operation="op",
input="hi",
session=ChannelSession(isolation_key="alice"),
response_target=ResponseTarget.channel("telegram", echo_input=True),
)
# First scheduled invocation — echo + response both succeed.
await ctx.deliver_response(req, _make_reply("reply"))
assert len(b.pushes) == 2 # echo + response
# Simulate a retry: invoke the handler again with the same
# payload mapping (the in-process runner reuses the mapping
# across retries). After the first run ``echo_done`` was
# mutated to ``True``; the second run must skip the echo.
_, payload = runner.scheduled[0]
assert payload["echo_done"] is True
await host._handle_push_task(payload)
# Only one more push (the response) — the echo was skipped.
assert len(b.pushes) == 3
assert str(b.pushes[2][1].result.messages[0].role) == "assistant"
# --------------------------------------------------------------------------- #
# Response hook + multi-modal payload + clone-on-fan-out #
# --------------------------------------------------------------------------- #
class TestResponseHookFanOut:
async def test_response_hook_applied_per_destination(self) -> None:
"""Channels with a ``response_hook`` attribute see their hook
applied before push, with a ``ChannelResponseContext`` carrying
the destination identity, the originating request, and an
``is_echo`` flag."""
agent = _FakeAgent()
a = _RecordingChannel(name="responses", path="/r")
b = _RecordingChannel(name="telegram", path="/t")
seen: list[tuple[str, str, bool]] = []
async def telegram_hook(
result: HostedRunResult[AgentResponse],
*,
context: Any,
**_: Any,
) -> HostedRunResult[AgentResponse]:
seen.append((context.channel_name, context.destination_identity.native_id, context.is_echo))
return result.replace(
result=AgentResponse(
messages=[Message(role="assistant", contents=[Content.from_text("[hooked] " + result.result.text)])]
),
)
b.response_hook = telegram_hook # type: ignore[attr-defined]
host = AgentFrameworkHost(target=agent, channels=[a, b], durable_task_runner=_SyncTaskRunner())
_ = host.app
assert a.context is not None
host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
req = ChannelRequest(
channel="responses",
operation="op",
input="hi",
session=ChannelSession(isolation_key="alice"),
response_target=ResponseTarget.channel("telegram"),
)
report = await a.context.deliver_response(req, _make_reply("reply"))
assert report is False
# The pushed payload reflects the hook's transform.
assert b.pushes[0][1].result.text == "[hooked] reply"
assert seen == [("telegram", "42", False)]
async def test_response_hook_mutation_isolated_per_destination(self) -> None:
"""A hook that rebinds ``result`` on its payload must NOT affect
the payload another destination sees. The host clones the
envelope before each hook invocation so a per-destination
:meth:`HostedRunResult.replace` cannot leak across destinations."""
agent = _FakeAgent()
a = _RecordingChannel(name="responses", path="/r")
b = _RecordingChannel(name="telegram", path="/t")
c = _RecordingChannel(name="extra", path="/x")
async def hook_that_rebinds(result: HostedRunResult[AgentResponse], **_: Any) -> HostedRunResult[AgentResponse]:
# Naughty hook: rebind ``result`` to a fresh AgentResponse.
# Host's per-destination clone via ``replace()`` makes this safe
# for sibling destinations.
return result.replace(result=AgentResponse(messages=[]))
b.response_hook = hook_that_rebinds # type: ignore[attr-defined]
host = AgentFrameworkHost(target=agent, channels=[a, b, c], durable_task_runner=_SyncTaskRunner())
_ = host.app
assert a.context is not None
host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
host._identities["alice"]["extra"] = ChannelIdentity(channel="extra", native_id="9")
original = _make_reply("reply")
original_result_snapshot = original.result
req = ChannelRequest(
channel="responses",
operation="op",
input="hi",
session=ChannelSession(isolation_key="alice"),
response_target=ResponseTarget.channels(["telegram", "extra"]),
)
report = await a.context.deliver_response(req, original)
assert report is False
# The rebind on the telegram clone must not have touched the
# original envelope, nor the extra channel's view.
assert original.result is original_result_snapshot
# ``extra`` channel saw the original-shaped payload.
extra_push = next(p for p in c.pushes)
assert extra_push[1].result.text == "reply"
async def test_response_hook_fires_on_echo_with_is_echo_true(self) -> None:
"""When ``echo_input`` is set, the channel's response_hook fires
TWICE per destination — once for the echo (is_echo=True), once
for the response (is_echo=False)."""
agent = _FakeAgent()
a = _RecordingChannel(name="responses", path="/r")
b = _RecordingChannel(name="telegram", path="/t")
phases: list[bool] = []
async def telegram_hook(
result: HostedRunResult[AgentResponse], *, context: Any, **_: Any
) -> HostedRunResult[AgentResponse]:
phases.append(context.is_echo)
return result
b.response_hook = telegram_hook # type: ignore[attr-defined]
host = AgentFrameworkHost(target=agent, channels=[a, b], durable_task_runner=_SyncTaskRunner())
_ = host.app
assert a.context is not None
host._identities.setdefault("alice", {})["telegram"] = ChannelIdentity(channel="telegram", native_id="42")
req = ChannelRequest(
channel="responses",
operation="op",
input="hi",
session=ChannelSession(isolation_key="alice"),
response_target=ResponseTarget.channel("telegram", echo_input=True),
)
await a.context.deliver_response(req, _make_reply("reply"))
assert phases == [True, False]
# --------------------------------------------------------------------------- #
# HostedRunResult — generic typed envelope #
# --------------------------------------------------------------------------- #
@@ -1522,7 +970,7 @@ class TestBindRequestContext:
stream=True,
attributes={"response_id": "resp_stream"},
)
stream = ch.context.run_stream(req)
stream = await ch.context.run_stream(req)
# As soon as run_stream returns, the binding must already be open
# so any provider work that happens during iteration sees it.
@@ -1574,7 +1022,7 @@ class TestBoundResponseStream:
stream=True,
attributes={"response_id": "resp_get_final"},
)
stream = ch.context.run_stream(req)
stream = await ch.context.run_stream(req)
# Skip iteration and go straight to ``get_final_response``;
# the adapter must drain the inner stream itself and close
# the binding in ``finally``.
@@ -1599,7 +1047,7 @@ class TestBoundResponseStream:
stream=True,
attributes={"response_id": "resp_idem"},
)
stream = ch.context.run_stream(req)
stream = await ch.context.run_stream(req)
async for _u in stream:
pass
# Iteration's finally already closed; an explicit ``aclose``
@@ -1627,7 +1075,7 @@ class TestBoundResponseStream:
stream=True,
attributes={"response_id": "resp_abandon"},
)
stream = ch.context.run_stream(req)
stream = await ch.context.run_stream(req)
await stream.aclose() # type: ignore[attr-defined]
# Binding released without iterating.
@@ -1655,7 +1103,7 @@ class TestBoundResponseStream:
stream=True,
attributes={"response_id": "resp_getattr"},
)
stream = ch.context.run_stream(req)
stream = await ch.context.run_stream(req)
# ``with_result_hook`` is a real method on ``ResponseStream``;
# if forwarding broke this would AttributeError.
try:
@@ -1682,7 +1130,7 @@ class TestBoundResponseStream:
stream=True,
attributes={"response_id": "resp_await"},
)
stream = ch.context.run_stream(req)
stream = await ch.context.run_stream(req)
final = await stream # exercises __await__
assert final.text == "chunk-1chunk-2"
names = [n for n, _ in prov.events]
+25 -222
View File
@@ -1,32 +1,19 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for ``state_dir`` wired through :class:`AgentFrameworkHost`."""
"""Tests for narrowed ``state_dir`` support in :class:`AgentFrameworkHost`."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
import pytest
from agent_framework_hosting import (
AgentFrameworkHost,
ChannelContext,
ChannelContribution,
ChannelIdentity,
LinkChallenge,
)
from agent_framework_hosting import AgentFrameworkHost, ChannelContext, ChannelContribution
# Skip the whole module when the optional disk extra isn't installed.
pytest.importorskip("diskcache")
# --------------------------------------------------------------------------- #
# Test helpers #
# --------------------------------------------------------------------------- #
class _AgentStub:
"""Bare-minimum SupportsAgentRun stub for host construction."""
@@ -42,65 +29,22 @@ class _ChannelStub:
return ChannelContribution()
class _NonConfigurableLinker:
async def resolve(self, _identity: ChannelIdentity) -> LinkChallenge:
return LinkChallenge("link")
class _ConfigurableLinker:
def __init__(self) -> None:
self.configured_path: Path | None = None
def configure_link_store_path(self, path: str | Path) -> None:
self.configured_path = Path(path)
async def resolve(self, _identity: ChannelIdentity) -> LinkChallenge:
return LinkChallenge("link")
def _close_host_disk(host: AgentFrameworkHost) -> None:
"""Mirror the lifespan shutdown ordering for tests that simulate restart.
The real shutdown order is ``runner.shutdown()`` → ``sessions_store.close()``;
both release their advisory file locks so a second host can take ownership.
"""
runner = host._durable_task_runner
try:
asyncio.get_event_loop().run_until_complete(runner.shutdown(timeout=1.0))
except RuntimeError:
# No running loop; spin up a throw-away one.
asyncio.run(runner.shutdown(timeout=1.0))
"""Release any session-alias store held by ``host``."""
if host._sessions_store is not None:
host._sessions_store.close()
# --------------------------------------------------------------------------- #
# state_dir=None preserves the in-memory contract #
# --------------------------------------------------------------------------- #
def test_state_dir_none_keeps_plain_dicts(tmp_path: Path) -> None:
"""No store, no sessions persistence, no files written."""
def test_state_dir_none_keeps_plain_alias_dict(tmp_path: Path) -> None:
"""No store, no alias persistence, no files written."""
host = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()])
try:
assert host._sessions_store is None
assert isinstance(host._session_aliases, dict)
assert isinstance(host._active, dict)
assert isinstance(host._identities, dict)
# No accidental disk writes anywhere under tmp_path.
assert list(tmp_path.iterdir()) == []
finally:
# Nothing to close.
pass
assert host._sessions_store is None
assert isinstance(host._session_aliases, dict)
assert list(tmp_path.iterdir()) == []
# --------------------------------------------------------------------------- #
# Single string state_dir creates default subfolders #
# --------------------------------------------------------------------------- #
def test_string_state_dir_creates_subfolders(tmp_path: Path) -> None:
"""Passing a single path expands to ``runner/`` and ``sessions/``."""
def test_string_state_dir_creates_sessions_subfolder_only(tmp_path: Path) -> None:
"""Passing a single path expands to ``sessions/`` plus lazy checkpoint path."""
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
@@ -108,100 +52,42 @@ def test_string_state_dir_creates_subfolders(tmp_path: Path) -> None:
)
try:
assert host._sessions_store is not None
assert (tmp_path / "runner").is_dir()
assert (tmp_path / "sessions").is_dir()
assert not (tmp_path / "runner").exists()
assert not (tmp_path / "links").exists()
# Checkpoint path is derived but not created for agent targets.
assert not (tmp_path / "checkpoints").exists()
finally:
_close_host_disk(host)
# --------------------------------------------------------------------------- #
# Per-component override via HostStatePaths-shaped dict #
# --------------------------------------------------------------------------- #
def test_per_component_paths(tmp_path: Path) -> None:
"""Dict form lets the caller route components to different roots."""
runner_dir = tmp_path / "tasks"
def test_per_component_session_path(tmp_path: Path) -> None:
"""Dict form lets callers route session aliases to a specific root."""
sessions_dir = tmp_path / "state"
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
state_dir={"runner": runner_dir, "sessions": sessions_dir},
state_dir={"sessions": sessions_dir},
)
try:
assert runner_dir.is_dir()
assert sessions_dir.is_dir()
# Default subfolders should NOT exist when the caller provides
# explicit overrides.
assert not (tmp_path / "runner").is_dir() or runner_dir == (tmp_path / "runner")
assert not (tmp_path / "sessions").is_dir() or sessions_dir == (tmp_path / "sessions")
assert host._sessions_store is not None
assert host._checkpoint_location is None
finally:
_close_host_disk(host)
def test_unknown_component_key_raises(tmp_path: Path) -> None:
"""Misspelled keys should fail loudly so the user catches typos."""
@pytest.mark.parametrize("key", ["runner", "links", "active", "identities"])
def test_removed_state_dir_component_keys_raise(tmp_path: Path, key: str) -> None:
"""Obsolete follow-up components should fail loudly instead of becoming no-ops."""
with pytest.raises(ValueError, match="unknown"):
AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
state_dir={"runnerr": tmp_path / "x"}, # type: ignore[dict-item]
state_dir={key: tmp_path / key}, # type: ignore[dict-item]
)
def test_links_state_path_configures_compatible_identity_linker(tmp_path: Path) -> None:
"""``state_dir['links']`` is offered to linkers that accept host-owned persistence."""
linker = _ConfigurableLinker()
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
identity_linker=linker,
state_dir=tmp_path,
)
try:
assert linker.configured_path == tmp_path / "links"
finally:
_close_host_disk(host)
def test_explicit_links_state_path_without_linker_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Explicit ``links`` path with no linker is almost certainly dead config."""
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
state_dir={"links": tmp_path / "links"},
)
try:
assert any(
"state_dir['links']" in rec.message and "no identity_linker" in rec.message for rec in caplog.records
)
finally:
_close_host_disk(host)
def test_links_state_path_with_nonconfigurable_linker_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""A linker that owns its persistence directly gets a clear warning."""
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
identity_linker=_NonConfigurableLinker(),
state_dir={"links": tmp_path / "links"},
)
try:
assert any(
"state_dir['links']" in rec.message and "SupportsLinkStorePath" in rec.message for rec in caplog.records
)
finally:
_close_host_disk(host)
# --------------------------------------------------------------------------- #
# Session bookkeeping survives a host restart #
# --------------------------------------------------------------------------- #
def test_session_aliases_survive_restart(tmp_path: Path) -> None:
"""Aliases written on host #1 must be visible to host #2."""
state_dir = tmp_path / "state"
@@ -219,84 +105,6 @@ def test_session_aliases_survive_restart(tmp_path: Path) -> None:
_close_host_disk(host2)
def test_active_channel_survives_restart(tmp_path: Path) -> None:
"""``_active`` must round-trip through the store."""
state_dir = tmp_path / "state"
host1 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
host1._active["user-1"] = "telegram"
host1._active["user-2"] = "responses"
_close_host_disk(host1)
host2 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
try:
assert host2._active["user-1"] == "telegram"
assert host2._active["user-2"] == "responses"
finally:
_close_host_disk(host2)
def test_identities_nested_mutation_survives_restart(tmp_path: Path) -> None:
"""Setting ``self._identities[ik][channel] = identity`` must persist.
This exercises the proxy-inner-dict ``__setitem__`` write-through path,
not just the outer-key replacement path.
"""
state_dir = tmp_path / "state"
host1 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
ident_tg = ChannelIdentity("telegram", "tg-123", {"username": "alice"})
ident_rsp = ChannelIdentity("responses", "rsp-456")
# Mirrors the host-internal path in ``_register_identity``.
host1._identities.setdefault("user-1", {})["telegram"] = ident_tg
host1._identities.setdefault("user-1", {})["responses"] = ident_rsp
host1._identities.setdefault("user-2", {})["telegram"] = ChannelIdentity("telegram", "tg-789")
_close_host_disk(host1)
host2 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
try:
u1 = host2._identities["user-1"]
assert set(u1.keys()) == {"telegram", "responses"}
assert u1["telegram"].native_id == "tg-123"
assert u1["telegram"].attributes["username"] == "alice"
assert u1["responses"].native_id == "rsp-456"
assert host2._identities["user-2"]["telegram"].native_id == "tg-789"
finally:
_close_host_disk(host2)
# --------------------------------------------------------------------------- #
# Explicit durable_task_runner + state_dir['runner'] warns #
# --------------------------------------------------------------------------- #
def test_explicit_runner_with_runner_state_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Caller-owned runner + state_dir['runner'] → ignore + warn."""
from agent_framework_hosting import InProcessTaskRunner
user_runner = InProcessTaskRunner()
try:
with caplog.at_level("WARNING"):
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
durable_task_runner=user_runner,
allow_in_process_runner=True,
state_dir={"runner": tmp_path / "runner"},
)
assert any("state_dir['runner']" in rec.message for rec in caplog.records)
# Sessions store wasn't requested, so still None.
assert host._sessions_store is None
finally:
# user_runner has no disk state, so nothing else to clean up.
pass
# --------------------------------------------------------------------------- #
# Workflow checkpoint integration #
# --------------------------------------------------------------------------- #
def _build_simple_workflow() -> Any:
"""Build a no-op workflow for checkpoint-wiring tests."""
from tests._workflow_fixtures import build_upper_workflow
@@ -313,7 +121,6 @@ def test_single_path_state_dir_wires_workflow_checkpoints(tmp_path: Path) -> Non
state_dir=tmp_path,
)
try:
# Checkpoint location is derived from the single state_dir.
assert host._checkpoint_location == tmp_path / "checkpoints"
finally:
_close_host_disk(host)
@@ -330,7 +137,6 @@ def test_mapping_state_dir_checkpoints_key_wires_workflow_checkpoints(tmp_path:
)
try:
assert host._checkpoint_location == ckpt_dir
# No diskcache components were requested.
assert host._sessions_store is None
finally:
_close_host_disk(host)
@@ -342,9 +148,7 @@ def test_mapping_state_dir_omits_checkpoints_for_workflow(tmp_path: Path) -> Non
host = AgentFrameworkHost(
target=workflow,
channels=[_ChannelStub()],
# No 'checkpoints' key → no checkpoint persistence even though
# other components are persisted.
state_dir={"runner": tmp_path / "r", "sessions": tmp_path / "s"},
state_dir={"sessions": tmp_path / "s"},
)
try:
assert host._checkpoint_location is None
@@ -381,7 +185,6 @@ def test_state_dir_checkpoints_for_agent_target_silent_for_single_path(tmp_path:
)
try:
assert host._checkpoint_location is None
# ``checkpoints/`` subfolder is not eagerly created (no consumer).
assert not (tmp_path / "checkpoints").exists()
finally:
_close_host_disk(host)
@@ -390,7 +193,7 @@ def test_state_dir_checkpoints_for_agent_target_silent_for_single_path(tmp_path:
def test_state_dir_checkpoints_for_agent_target_warns_when_explicit(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Mapping form with ``checkpoints`` + agent target → warn (dead config)."""
"""Mapping form with ``checkpoints`` + agent target → warn."""
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
host = AgentFrameworkHost(
target=_AgentStub(),
@@ -16,6 +16,7 @@ from __future__ import annotations
import asyncio
import pytest
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import BaseRoute, Route
@@ -168,7 +169,22 @@ def _make_host_with_probe() -> tuple[object, _IsolationProbeChannel]:
class TestIsolationMiddlewareEndToEnd:
def test_both_headers_lifted_into_contextvar(self) -> None:
def test_headers_ignored_outside_foundry_environment(self) -> None:
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get(
"/probe",
headers={
ISOLATION_HEADER_USER: "alice-uid",
ISOLATION_HEADER_CHAT: "general-cid",
},
)
assert r.status_code == 200
assert r.json() == {"user": None, "chat": None, "_present": False}
assert probe.captured == [None]
def test_both_headers_lifted_into_contextvar(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get(
@@ -186,15 +202,17 @@ class TestIsolationMiddlewareEndToEnd:
assert captured.user_key == "alice-uid"
assert captured.chat_key == "general-cid"
def test_only_user_header_lifted(self) -> None:
def test_only_user_header_lifted(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""One-header-only branch: the middleware still binds (chat=None)."""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get("/probe", headers={ISOLATION_HEADER_USER: "alice-uid"})
assert r.status_code == 200
assert r.json() == {"user": "alice-uid", "chat": None}
def test_only_chat_header_lifted(self) -> None:
def test_only_chat_header_lifted(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get("/probe", headers={ISOLATION_HEADER_CHAT: "general-cid"})
@@ -213,9 +231,10 @@ class TestIsolationMiddlewareEndToEnd:
assert r.json() == {"user": None, "chat": None, "_present": False}
assert probe.captured == [None]
def test_empty_header_value_treated_as_absent(self) -> None:
def test_empty_header_value_treated_as_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A header that's present but empty must not bind an empty key —
``IsolationContext`` rejects empty strings on the read side."""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get(
@@ -229,10 +248,11 @@ class TestIsolationMiddlewareEndToEnd:
# Empty user header decodes to None; chat key stays bound.
assert r.json() == {"user": None, "chat": "general-cid"}
def test_contextvar_resets_after_request(self) -> None:
def test_contextvar_resets_after_request(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""The middleware must call ``reset_current_isolation_keys`` in
a ``finally`` so per-request state never leaks across requests
or back into the calling thread's context."""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r1 = client.get("/probe", headers={ISOLATION_HEADER_USER: "alice-uid"})
@@ -245,9 +265,10 @@ class TestIsolationMiddlewareEndToEnd:
r2 = client.get("/probe")
assert r2.json() == {"user": None, "chat": None, "_present": False}
def test_concurrent_requests_get_isolated_contextvars(self) -> None:
def test_concurrent_requests_get_isolated_contextvars(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Different requests run in different async contexts; binding
from request A must NOT leak into a concurrent request B."""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
async def _drive() -> None:
@@ -1,333 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for :class:`InProcessTaskRunner` and runtime-mode auto-detection."""
from __future__ import annotations
import asyncio
from collections.abc import Mapping
from typing import Any
import pytest
from agent_framework_hosting import (
AgentFrameworkHost,
ChannelContext,
ChannelContribution,
DurableTaskPayloadMode,
InProcessTaskRunner,
RetryPolicy,
TaskHandle,
)
from agent_framework_hosting._host import _detect_runtime_mode
# --------------------------------------------------------------------------- #
# Test helpers #
# --------------------------------------------------------------------------- #
class _AgentStub:
"""Bare-minimum SupportsAgentRun stub for host construction."""
async def run(self, *_args: Any, **_kwargs: Any) -> None: # pragma: no cover - unused
return None
class _ChannelStub:
name = "stub"
path = "/stub"
def contribute(self, _context: ChannelContext) -> ChannelContribution:
return ChannelContribution()
# --------------------------------------------------------------------------- #
# Runtime-mode auto-detection #
# --------------------------------------------------------------------------- #
class TestRuntimeModeDetection:
"""``_detect_runtime_mode`` is pure: tests pass a synthetic env so
they never depend on the test runner's environment. Auto-detected
mode + matched marker drive the per-host startup banner so operators
can confirm the host is running in the expected shape."""
def test_no_markers_defaults_to_long_running(self) -> None:
mode, marker = _detect_runtime_mode(env={})
assert mode == "long_running"
assert marker is None
def test_foundry_marker_selects_ephemeral(self) -> None:
mode, marker = _detect_runtime_mode(env={"FOUNDRY_HOSTING_ENVIRONMENT": "production"})
assert mode == "ephemeral"
assert marker == "FOUNDRY_HOSTING_ENVIRONMENT"
def test_azure_functions_marker_selects_ephemeral(self) -> None:
mode, marker = _detect_runtime_mode(env={"AZURE_FUNCTIONS_ENVIRONMENT": "Development"})
assert mode == "ephemeral"
assert marker == "AZURE_FUNCTIONS_ENVIRONMENT"
def test_lambda_marker_selects_ephemeral(self) -> None:
mode, marker = _detect_runtime_mode(env={"AWS_LAMBDA_FUNCTION_NAME": "my-fn"})
assert mode == "ephemeral"
assert marker == "AWS_LAMBDA_FUNCTION_NAME"
def test_empty_marker_value_ignored(self) -> None:
# Empty-string env var should not count as "set" — Foundry's
# template uses unset-or-empty as "not deployed".
mode, marker = _detect_runtime_mode(env={"FOUNDRY_HOSTING_ENVIRONMENT": ""})
assert mode == "long_running"
assert marker is None
class TestHostRuntimeMode:
"""``runtime_mode`` ctor argument overrides auto-detect; ``None``
triggers auto-detect. The detected mode is exposed via the
``runtime_mode`` property for operator inspection (and is logged at
startup via ``_log_startup``)."""
def test_explicit_long_running(self) -> None:
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
runtime_mode="long_running",
)
assert host.runtime_mode == "long_running"
def test_explicit_ephemeral_with_default_runner_raises(self) -> None:
# Default runner is in-process and not durable. Ephemeral
# deployments would silently lose pushes on scale-to-zero, so
# the host refuses the combination at construction unless the
# operator opts in explicitly via ``allow_in_process_runner``.
with pytest.raises(RuntimeError, match="ephemeral"):
AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
runtime_mode="ephemeral",
)
def test_explicit_ephemeral_with_in_process_opt_in_warns(self, caplog: pytest.LogCaptureFixture) -> None:
# The opt-in escape hatch keeps the old warn-and-proceed
# behaviour for local-dev / smoke-test scenarios that genuinely
# want ephemeral runtime semantics without a real durable
# backend.
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
runtime_mode="ephemeral",
allow_in_process_runner=True,
)
assert host.runtime_mode == "ephemeral"
assert any("ephemeral" in r.getMessage() and "InProcessTaskRunner" in r.getMessage() for r in caplog.records)
def test_explicit_ephemeral_with_supplied_runner_does_not_warn(self, caplog: pytest.LogCaptureFixture) -> None:
runner = InProcessTaskRunner()
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
runtime_mode="ephemeral",
durable_task_runner=runner,
)
# No warning — operator opted into a specific runner.
assert host.runtime_mode == "ephemeral"
assert host.durable_task_runner is runner
assert not any("ephemeral" in r.getMessage() for r in caplog.records)
def test_auto_detect_ephemeral_raises_without_opt_in(self, monkeypatch: pytest.MonkeyPatch) -> None:
# Auto-detected ephemeral flows through the same strict gate.
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "production")
with pytest.raises(RuntimeError, match="ephemeral"):
AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()])
def test_auto_detect_ephemeral_with_opt_in_proceeds(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "production")
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
allow_in_process_runner=True,
)
assert host.runtime_mode == "ephemeral"
def test_default_runner_is_in_process_task_runner(self) -> None:
host = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()])
assert isinstance(host.durable_task_runner, InProcessTaskRunner)
# --------------------------------------------------------------------------- #
# InProcessTaskRunner #
# --------------------------------------------------------------------------- #
class TestInProcessTaskRunner:
async def test_schedule_runs_handler_and_records_succeeded(self) -> None:
runner = InProcessTaskRunner()
seen: list[Mapping[str, Any]] = []
async def handler(payload: Mapping[str, Any]) -> None:
seen.append(payload)
runner.register("ping", handler)
handle = await runner.schedule("ping", {"x": 1})
# ``schedule`` returns immediately; the task runs on the loop.
# Drain explicitly via ``shutdown`` to flush in-flight work,
# then assert.
await _drain(runner, handle)
assert seen == [{"x": 1}]
assert await runner.get(handle) == "succeeded"
async def test_unknown_handler_raises_keyerror(self) -> None:
runner = InProcessTaskRunner()
with pytest.raises(KeyError):
await runner.schedule("missing", {})
async def test_register_after_start_raises(self) -> None:
runner = InProcessTaskRunner()
async def noop(_p: Mapping[str, Any]) -> None:
return None
runner.register("x", noop)
handle = await runner.schedule("x", {})
await _drain(runner, handle)
# Re-registering after the runner has started scheduling is
# rejected so in-flight tasks can't have their handler swapped
# out from under them.
with pytest.raises(RuntimeError, match="register"):
runner.register("y", noop)
async def test_handler_retried_then_succeeds(self) -> None:
runner = InProcessTaskRunner()
attempts = {"n": 0}
async def flaky(_p: Mapping[str, Any]) -> None:
attempts["n"] += 1
if attempts["n"] < 3:
raise RuntimeError(f"attempt {attempts['n']}")
runner.register("flaky", flaky)
# Tight retry policy so the test doesn't sleep visibly.
policy = RetryPolicy(max_attempts=5, initial_backoff_seconds=0.001, max_backoff_seconds=0.005)
handle = await runner.schedule("flaky", {}, retry_policy=policy)
await _drain(runner, handle)
assert attempts["n"] == 3
assert await runner.get(handle) == "succeeded"
async def test_handler_failure_records_failed_after_max_attempts(self) -> None:
runner = InProcessTaskRunner()
async def always_fails(_p: Mapping[str, Any]) -> None:
raise RuntimeError("nope")
runner.register("doomed", always_fails)
policy = RetryPolicy(max_attempts=2, initial_backoff_seconds=0.001)
handle = await runner.schedule("doomed", {}, retry_policy=policy)
await _drain(runner, handle)
assert await runner.get(handle) == "failed"
async def test_shutdown_cancels_pending_tasks(self) -> None:
runner = InProcessTaskRunner()
started = asyncio.Event()
cancelled = asyncio.Event()
async def long_running(_p: Mapping[str, Any]) -> None:
started.set()
try:
# Sleep longer than the test wait so shutdown can cancel.
await asyncio.sleep(5)
except asyncio.CancelledError:
cancelled.set()
raise
runner.register("long", long_running)
handle = await runner.schedule("long", {})
await asyncio.wait_for(started.wait(), timeout=1.0)
await runner.shutdown(timeout=1.0)
assert cancelled.is_set()
assert await runner.get(handle) == "cancelled"
async def test_shutdown_grace_drain_does_not_cancel_finishing_tasks(self) -> None:
"""A short-lived task that completes within the grace window
must NOT receive a cancellation. The grace-period drain is the
graceful-shutdown contract — channels with goodbye-message
flushes rely on it."""
runner = InProcessTaskRunner()
cancelled = asyncio.Event()
completed = asyncio.Event()
async def quick(_p: Mapping[str, Any]) -> None:
try:
await asyncio.sleep(0.05)
except asyncio.CancelledError:
cancelled.set()
raise
completed.set()
runner.register("quick", quick)
handle = await runner.schedule("quick", {})
# Shutdown with a generous grace window relative to the task duration.
await runner.shutdown(timeout=1.0)
assert completed.is_set()
assert not cancelled.is_set()
assert await runner.get(handle) == "succeeded"
async def test_get_returns_none_for_unknown_handle(self) -> None:
runner = InProcessTaskRunner()
handle = TaskHandle(task_id="never-scheduled", name="x")
assert await runner.get(handle) is None
async def test_terminal_cache_evicts_oldest(self) -> None:
# Cache size of 2: drain three tasks in sequence, the first
# should age out by the time the third's terminal lands.
runner = InProcessTaskRunner(terminal_cache_size=2)
async def noop(_p: Mapping[str, Any]) -> None:
return None
runner.register("noop", noop)
h1 = await runner.schedule("noop", {})
await _drain(runner, h1)
h2 = await runner.schedule("noop", {})
await _drain(runner, h2)
h3 = await runner.schedule("noop", {})
await _drain(runner, h3)
# Oldest handle's terminal status should be evicted by now.
assert await runner.get(h1) is None
assert await runner.get(h2) == "succeeded"
assert await runner.get(h3) == "succeeded"
async def test_shutdown_is_safe_when_no_tasks_pending(self) -> None:
runner = InProcessTaskRunner()
# No-op shouldn't raise.
await runner.shutdown()
def test_payload_mode_defaults_to_object(self) -> None:
# The in-process runner passes live Python references through
# the payload — the host wires this attribute into its codec
# validator at startup. Durable adapters that persist payloads
# must override this to ``JSON`` so the host refuses to ship
# un-serialisable references.
runner = InProcessTaskRunner()
assert runner.payload_mode == DurableTaskPayloadMode.OBJECT
# --------------------------------------------------------------------------- #
# Helpers #
# --------------------------------------------------------------------------- #
async def _drain(runner: InProcessTaskRunner, handle: TaskHandle, *, timeout: float = 1.0) -> None:
"""Wait for ``handle`` to reach a terminal state.
Polls ``get`` rather than reaching into runner internals so we exercise the
public surface from the test side too.
"""
deadline = asyncio.get_event_loop().time() + timeout
while True:
status = await runner.get(handle)
if status in ("succeeded", "failed", "cancelled"):
return
if asyncio.get_event_loop().time() > deadline:
raise AssertionError(f"task {handle.task_id} did not reach terminal in {timeout}s; status={status}")
await asyncio.sleep(0.01)
@@ -1,278 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for :class:`InProcessTaskRunner` disk persistence (``state_dir``)."""
from __future__ import annotations
import asyncio
from collections.abc import Mapping
from pathlib import Path
from typing import Any
import pytest
from agent_framework_hosting import (
InProcessTaskRunner,
PushPayloadNotPicklable,
RetryPolicy,
)
# Skip the whole module if the optional diskcache dependency isn't installed.
pytest.importorskip("diskcache")
# --------------------------------------------------------------------------- #
# state_dir=None preserves today's purely in-memory contract #
# --------------------------------------------------------------------------- #
async def test_state_dir_none_is_pure_memory(tmp_path: Path) -> None:
"""No directory creation / no lock file when state_dir is omitted."""
runner = InProcessTaskRunner()
calls: list[Mapping[str, Any]] = []
async def handler(payload: Mapping[str, Any]) -> None:
calls.append(payload)
runner.register("echo", handler)
handle = await runner.schedule("echo", {"k": "v"})
# Wait for completion.
for _ in range(50):
if (await runner.get(handle)) == "succeeded":
break
await asyncio.sleep(0.01)
assert calls == [{"k": "v"}]
assert await runner.get(handle) == "succeeded"
# Confirm we didn't accidentally write to disk.
assert not (tmp_path / ".lock").exists()
await runner.shutdown()
# --------------------------------------------------------------------------- #
# Lock contention — two runners on the same dir refuse to coexist #
# --------------------------------------------------------------------------- #
async def test_two_runners_one_state_dir_raise(tmp_path: Path) -> None:
"""Second runner construction must fail loudly, not silently corrupt."""
state_dir = tmp_path / "runner"
first = InProcessTaskRunner(state_dir=state_dir)
try:
with pytest.raises(RuntimeError, match="state lock"):
InProcessTaskRunner(state_dir=state_dir)
finally:
await first.shutdown()
# --------------------------------------------------------------------------- #
# Pickle failure raises eagerly, never silently downgrades #
# --------------------------------------------------------------------------- #
async def test_unpickleable_payload_raises(tmp_path: Path) -> None:
"""Schedule must refuse payloads that can't survive a restart."""
runner = InProcessTaskRunner(state_dir=tmp_path / "runner")
async def handler(_: Mapping[str, Any]) -> None: ...
runner.register("echo", handler)
# Local lambdas / closures are the canonical unpicklable values.
with pytest.raises(PushPayloadNotPicklable):
await runner.schedule("echo", {"callback": lambda: None})
await runner.shutdown()
# --------------------------------------------------------------------------- #
# Resume — pending records replay on next process #
# --------------------------------------------------------------------------- #
async def test_pending_record_replays_on_resume(tmp_path: Path) -> None:
"""Simulate a crash: first runner schedules but never starts running."""
state_dir = tmp_path / "runner"
# Process 1 — schedule a task, then "die" before the asyncio loop runs it.
runner1 = InProcessTaskRunner(state_dir=state_dir)
blocked = asyncio.Event()
async def slow(_: Mapping[str, Any]) -> None:
# Sleep so the task is observably still in flight when we shutdown.
await blocked.wait()
runner1.register("slow", slow)
handle = await runner1.schedule("slow", {"work": 1})
# Force a hard shutdown — leaves the in-flight task in 'pending' on disk.
await runner1.shutdown(timeout=0.1)
# Process 2 — fresh runner against same state_dir, register the handler,
# call resume. We expect the persisted record to be re-scheduled.
runner2 = InProcessTaskRunner(state_dir=state_dir)
seen: list[Mapping[str, Any]] = []
async def slow_resumed(payload: Mapping[str, Any]) -> None:
seen.append(dict(payload))
runner2.register("slow", slow_resumed)
replayed = await runner2.resume()
assert replayed == 1
# Give the resumed task time to run.
for _ in range(50):
if seen:
break
await asyncio.sleep(0.01)
assert seen == [{"work": 1}]
# Status is observable via the original handle.
assert await runner2.get(handle) == "succeeded"
await runner2.shutdown()
# --------------------------------------------------------------------------- #
# echo_done cursor survives restart #
# --------------------------------------------------------------------------- #
async def test_payload_mutation_survives_restart(tmp_path: Path) -> None:
"""Handler-side payload mutations (echo_done) round-trip through disk."""
state_dir = tmp_path / "runner"
runner1 = InProcessTaskRunner(state_dir=state_dir)
# Handler sets echo_done and then blocks forever (simulating mid-flight crash).
handler_progress = asyncio.Event()
async def half_done(payload: Mapping[str, Any]) -> None:
# Mutate the payload to mark first phase complete.
payload["echo_done"] = True # type: ignore[index]
handler_progress.set()
# Sleep indefinitely so the asyncio task is still running at shutdown.
await asyncio.Event().wait()
runner1.register("two_phase", half_done)
handle = await runner1.schedule("two_phase", {"echo_done": False, "k": "v"})
await handler_progress.wait()
await runner1.shutdown(timeout=0.1)
# Process 2 — replay; the handler now sees echo_done=True from disk.
runner2 = InProcessTaskRunner(state_dir=state_dir)
observed: list[bool] = []
async def two_phase_resumed(payload: Mapping[str, Any]) -> None:
observed.append(bool(payload.get("echo_done")))
runner2.register("two_phase", two_phase_resumed)
await runner2.resume()
for _ in range(50):
if observed:
break
await asyncio.sleep(0.01)
assert observed == [True]
# And the resumed task ran to completion.
assert await runner2.get(handle) == "succeeded"
await runner2.shutdown()
# --------------------------------------------------------------------------- #
# Resume gracefully handles missing handler / corrupt entries #
# --------------------------------------------------------------------------- #
async def test_resume_with_missing_handler_marks_failed(tmp_path: Path) -> None:
"""A persisted record whose handler is no longer registered is marked failed."""
state_dir = tmp_path / "runner"
runner1 = InProcessTaskRunner(state_dir=state_dir)
async def will_be_removed(_: Mapping[str, Any]) -> None:
await asyncio.Event().wait()
runner1.register("ghost", will_be_removed)
handle = await runner1.schedule("ghost", {})
await runner1.shutdown(timeout=0.1)
# Process 2 — never registers "ghost".
runner2 = InProcessTaskRunner(state_dir=state_dir)
replayed = await runner2.resume()
assert replayed == 0
# The record is moved to terminal 'failed'.
assert await runner2.get(handle) == "failed"
await runner2.shutdown()
async def test_resume_quarantines_corrupt_entries(tmp_path: Path) -> None:
"""A non-dict on-disk entry must be quarantined, not crash resume."""
import diskcache # noqa: PLC0415 - lazy import to keep module-import cheap
state_dir = tmp_path / "runner"
state_dir.mkdir(parents=True, exist_ok=True)
# Pre-populate the cache with a junk entry.
cache = diskcache.Cache(str(state_dir))
cache.set("bad-task-id", "this is not a dict")
cache.close()
runner = InProcessTaskRunner(state_dir=state_dir)
# resume() must not raise even with a corrupt entry on disk.
replayed = await runner.resume()
assert replayed == 0
await runner.shutdown()
# The corrupt entry should have been removed.
cache2 = diskcache.Cache(str(state_dir))
assert "bad-task-id" not in cache2
cache2.close()
# --------------------------------------------------------------------------- #
# Retry attempt counter persists across resume #
# --------------------------------------------------------------------------- #
async def test_attempt_counter_persists_across_resume(tmp_path: Path) -> None:
"""A handler that crashes mid-attempt resumes with the consumed budget."""
state_dir = tmp_path / "runner"
policy = RetryPolicy(max_attempts=3, initial_backoff_seconds=0.01, backoff_multiplier=1.0)
# Process 1 — schedule, fail once, shutdown before retry settles.
runner1 = InProcessTaskRunner(state_dir=state_dir, default_retry_policy=policy)
attempts_seen_p1 = 0
async def flaky(_: Mapping[str, Any]) -> None:
nonlocal attempts_seen_p1
attempts_seen_p1 += 1
raise RuntimeError("boom-1")
runner1.register("flaky", flaky)
handle = await runner1.schedule("flaky", {})
# Let it attempt twice (waste 2 of 3 budgeted retries), then crash-shutdown.
for _ in range(50):
if attempts_seen_p1 >= 2:
break
await asyncio.sleep(0.01)
await runner1.shutdown(timeout=0.05)
# Process 2 — resume; only 1 attempt left in the budget. Confirm we don't
# re-grant the full retry budget.
runner2 = InProcessTaskRunner(state_dir=state_dir, default_retry_policy=policy)
attempts_seen_p2 = 0
async def flaky_resumed(_: Mapping[str, Any]) -> None:
nonlocal attempts_seen_p2
attempts_seen_p2 += 1
raise RuntimeError("boom-2")
runner2.register("flaky", flaky_resumed)
await runner2.resume()
# Wait for the resumed task to consume its remaining attempts and fail terminally.
for _ in range(100):
if (await runner2.get(handle)) == "failed":
break
await asyncio.sleep(0.01)
assert await runner2.get(handle) == "failed"
# Original consumed 2 attempts; we should have allowed at most max_attempts-2=1
# more in process 2.
assert attempts_seen_p2 <= 1
await runner2.shutdown()
-288
View File
@@ -4,63 +4,13 @@
from __future__ import annotations
from typing import Any
from agent_framework_hosting import (
ChannelContribution,
ChannelIdentity,
ChannelRequest,
ChannelResponseContext,
ChannelSession,
DurableTaskPayloadMode,
HostedRunResult,
ResponseTarget,
ResponseTargetKind,
apply_channel_response_hook,
apply_run_hook,
)
class TestResponseTarget:
def test_originating_default_singleton(self) -> None:
target = ResponseTarget.originating # type: ignore[attr-defined]
assert target.kind is ResponseTargetKind.ORIGINATING
assert target.targets == ()
def test_active_singleton(self) -> None:
target = ResponseTarget.active # type: ignore[attr-defined]
assert target.kind is ResponseTargetKind.ACTIVE
assert target.targets == ()
def test_all_linked_singleton(self) -> None:
target = ResponseTarget.all_linked # type: ignore[attr-defined]
assert target.kind is ResponseTargetKind.ALL_LINKED
def test_none_singleton(self) -> None:
target = ResponseTarget.none # type: ignore[attr-defined]
assert target.kind is ResponseTargetKind.NONE
def test_channel_builder_single(self) -> None:
target = ResponseTarget.channel("teams")
assert target.kind is ResponseTargetKind.CHANNELS
assert target.targets == ("teams",)
def test_channels_builder_list(self) -> None:
target = ResponseTarget.channels(["teams", "telegram", "originating"])
assert target.kind is ResponseTargetKind.CHANNELS
assert target.targets == ("teams", "telegram", "originating")
def test_channels_builder_accepts_tuple(self) -> None:
target = ResponseTarget.channels(("a", "b"))
assert target.targets == ("a", "b")
def test_target_is_hashable(self) -> None:
# Plain class — hashing falls back to identity, which is fine here:
# the two keys below are different instances (singleton vs builder).
d = {ResponseTarget.originating: 1, ResponseTarget.channel("t"): 2} # type: ignore[attr-defined]
assert len(d) == 2
class TestChannelRequest:
def test_required_fields_only(self) -> None:
req = ChannelRequest(channel="responses", operation="message.create", input="hi")
@@ -74,17 +24,6 @@ class TestChannelRequest:
assert req.attributes == {}
assert req.stream is False
assert req.identity is None
# Default response target is the originating singleton.
assert req.response_target.kind is ResponseTargetKind.ORIGINATING
def test_default_response_target_is_originating_singleton(self) -> None:
# Every new request shares the module-level ``originating`` singleton
# by default — instances are intended to be treated as immutable, so
# sharing is safe and avoids per-request allocation.
a = ChannelRequest(channel="a", operation="op", input="x")
b = ChannelRequest(channel="b", operation="op", input="y")
assert a.response_target is ResponseTarget.originating # type: ignore[attr-defined]
assert a.response_target is b.response_target
def test_with_session_and_identity(self) -> None:
req = ChannelRequest(
@@ -93,14 +32,12 @@ class TestChannelRequest:
input="hi",
session=ChannelSession(isolation_key="user:42"),
identity=ChannelIdentity(channel="telegram", native_id="42"),
response_target=ResponseTarget.active, # type: ignore[attr-defined]
)
assert req.session is not None
assert req.session.isolation_key == "user:42"
assert req.identity is not None
assert req.identity.channel == "telegram"
assert req.identity.native_id == "42"
assert req.response_target.kind is ResponseTargetKind.ACTIVE
class TestChannelIdentity:
@@ -111,228 +48,3 @@ class TestChannelIdentity:
def test_attributes_passthrough(self) -> None:
ident = ChannelIdentity(channel="teams", native_id="abc", attributes={"role": "user"})
assert dict(ident.attributes) == {"role": "user"}
class _DummyTarget:
"""Stand-in for the ``SupportsAgentRun | Workflow`` arg `apply_run_hook` forwards.
`apply_run_hook` doesn't introspect the target — it just forwards
it as a kwarg to the user's hook — so a bare class is enough.
"""
class _DummyChannel:
name = "dummy"
path = "/dummy"
def contribute(self, _context: Any) -> ChannelContribution:
return ChannelContribution()
class TestApplyChannelResponseHook:
async def test_originating_hook_receives_standard_context(self) -> None:
request = ChannelRequest(channel="discord", operation="message.create", input="hi")
payload = HostedRunResult("original")
captured: list[ChannelResponseContext] = []
async def hook(
result: HostedRunResult[Any],
*,
context: ChannelResponseContext,
) -> HostedRunResult[Any]:
captured.append(context)
return result.replace(result="hooked")
channel = _DummyChannel()
channel.response_hook = hook # type: ignore[attr-defined]
shaped = await apply_channel_response_hook(channel, payload, request=request, originating=True)
assert shaped.result == "hooked"
assert captured[0].request is request
assert captured[0].channel_name == "dummy"
assert captured[0].destination_identity is None
assert captured[0].originating is True
assert captured[0].is_echo is False
async def test_non_originating_hook_can_clone_before_shaping(self) -> None:
request = ChannelRequest(channel="responses", operation="message.create", input="hi")
identity = ChannelIdentity(channel="dummy", native_id="user-1")
payload = HostedRunResult("original")
seen_payloads: list[HostedRunResult[Any]] = []
seen_contexts: list[ChannelResponseContext] = []
def hook(
result: HostedRunResult[Any],
*,
context: ChannelResponseContext,
) -> HostedRunResult[Any]:
seen_payloads.append(result)
seen_contexts.append(context)
return result.replace(result="hooked")
channel = _DummyChannel()
channel.response_hook = hook # type: ignore[attr-defined]
shaped = await apply_channel_response_hook(
channel,
payload,
request=request,
destination_identity=identity,
originating=False,
is_echo=True,
clone=True,
)
assert seen_payloads[0] is not payload
assert shaped.result == "hooked"
assert seen_contexts[0].destination_identity is identity
assert seen_contexts[0].originating is False
assert seen_contexts[0].is_echo is True
async def test_missing_hook_returns_payload_or_clone(self) -> None:
request = ChannelRequest(channel="responses", operation="message.create", input="hi")
payload = HostedRunResult("original")
channel = _DummyChannel()
same = await apply_channel_response_hook(channel, payload, request=request, originating=True)
cloned = await apply_channel_response_hook(channel, payload, request=request, originating=True, clone=True)
assert same is payload
assert cloned is not payload
assert cloned.result == payload.result
class TestApplyRunHook:
"""`apply_run_hook` is the channel-side helper that invokes a
`ChannelRunHook` with the standard kwargs (`request` positional,
`target` / `protocol_request` keyword). Channels call this rather
than calling the hook directly so the convention is enforced in
one place. Cover both branching paths (sync vs async hook return)
and assert kwargs forwarding so a regression that drops `target`
or `protocol_request` is caught."""
async def test_sync_hook_returning_modified_request(self) -> None:
captured: dict[str, Any] = {}
def hook(request: ChannelRequest, **kwargs: Any) -> ChannelRequest:
# Snapshot the kwargs for the assertion below, then return a
# NEW request so we also verify the helper passes the
# replacement straight through (no merging / mutation).
captured["target"] = kwargs.get("target")
captured["protocol_request"] = kwargs.get("protocol_request")
return ChannelRequest(channel=request.channel, operation="HOOK_TOUCHED", input=request.input)
original = ChannelRequest(channel="responses", operation="op", input="hi")
target = _DummyTarget()
proto = {"raw": "payload"}
result = await apply_run_hook(hook, original, target=target, protocol_request=proto)
assert result is not original
assert result.operation == "HOOK_TOUCHED"
assert captured["target"] is target
assert captured["protocol_request"] is proto
async def test_async_hook_returning_modified_request(self) -> None:
captured: dict[str, Any] = {}
async def hook(request: ChannelRequest, **kwargs: Any) -> ChannelRequest:
captured["target"] = kwargs.get("target")
captured["protocol_request"] = kwargs.get("protocol_request")
# Return an awaitable result to exercise the async branch
# (`isinstance(result, Awaitable) → await it`).
return ChannelRequest(channel=request.channel, operation="ASYNC_HOOK", input=request.input)
original = ChannelRequest(channel="telegram", operation="op", input="hi")
target = _DummyTarget()
proto = {"update_id": 42}
result = await apply_run_hook(hook, original, target=target, protocol_request=proto)
assert result.operation == "ASYNC_HOOK"
assert captured["target"] is target
assert captured["protocol_request"] is proto
async def test_protocol_request_can_be_none(self) -> None:
"""Channels that don't have a raw protocol payload (e.g. CLI / test
harness invocations) pass ``protocol_request=None``; the helper
forwards it as-is so hooks can ``if protocol_request is None`` to
gate channel-specific logic."""
captured: dict[str, Any] = {}
async def hook(request: ChannelRequest, **kwargs: Any) -> ChannelRequest:
captured["protocol_request"] = kwargs.get("protocol_request")
captured["protocol_request_in_kwargs"] = "protocol_request" in kwargs
return request
await apply_run_hook(
hook,
ChannelRequest(channel="x", operation="op", input="hi"),
target=_DummyTarget(),
protocol_request=None,
)
assert captured["protocol_request"] is None
assert captured["protocol_request_in_kwargs"] is True
class TestDurableTaskPayloadMode:
"""``DurableTaskPayloadMode`` distinguishes object-mode (in-process,
live references) from JSON-mode (durable persistence, channel codec
required) runners. The host's startup validator uses the value to
refuse misconfigured deployments."""
def test_enum_values(self) -> None:
assert DurableTaskPayloadMode.OBJECT.value == "object"
assert DurableTaskPayloadMode.JSON.value == "json"
# Both members; no surprise additions until we ship a third
# adapter style.
assert set(DurableTaskPayloadMode) == {DurableTaskPayloadMode.OBJECT, DurableTaskPayloadMode.JSON}
class TestResponseTargetIdentities:
"""``ResponseTarget.identity``/``.identities`` carry full
:class:`ChannelIdentity` objects (incl. attributes) so destination
channels that need conversation/thread metadata (Teams, Slack, Bot
Framework) don't have to encode it through string tokens."""
def test_identity_single(self) -> None:
ident = ChannelIdentity(channel="teams", native_id="user@contoso", attributes={"tenant_id": "abc"})
target = ResponseTarget.identity(ident)
assert target.kind is ResponseTargetKind.IDENTITIES
assert len(target.target_identities) == 1
assert target.target_identities[0].channel == "teams"
assert target.target_identities[0].native_id == "user@contoso"
assert dict(target.target_identities[0].attributes) == {"tenant_id": "abc"}
def test_identities_list_preserves_attributes(self) -> None:
ident_a = ChannelIdentity(channel="teams", native_id="u1", attributes={"thread": "t1"})
ident_b = ChannelIdentity(channel="slack", native_id="u2", attributes={"channel_id": "c2"})
target = ResponseTarget.identities([ident_a, ident_b])
assert target.kind is ResponseTargetKind.IDENTITIES
assert len(target.target_identities) == 2
assert dict(target.target_identities[0].attributes) == {"thread": "t1"}
assert dict(target.target_identities[1].attributes) == {"channel_id": "c2"}
def test_identity_value_equality_matches_on_attributes(self) -> None:
# Two ``ResponseTarget.identity`` values built independently
# compare equal when the underlying ``ChannelIdentity`` content
# matches — important because tests and channel parsers use
# ``==`` on targets.
ident_a = ChannelIdentity(channel="teams", native_id="u1", attributes={"thread": "t1"})
ident_b = ChannelIdentity(channel="teams", native_id="u1", attributes={"thread": "t1"})
assert ResponseTarget.identity(ident_a) == ResponseTarget.identity(ident_b)
# Different attributes → not equal.
ident_c = ChannelIdentity(channel="teams", native_id="u1", attributes={"thread": "t2"})
assert ResponseTarget.identity(ident_a) != ResponseTarget.identity(ident_c)
def test_identity_repr_includes_targets(self) -> None:
ident = ChannelIdentity(channel="teams", native_id="u1")
rep = repr(ResponseTarget.identity(ident))
assert "ResponseTarget.identities" in rep
def test_identity_echo_input_flag(self) -> None:
ident = ChannelIdentity(channel="teams", native_id="u1")
target = ResponseTarget.identity(ident, echo_input=True)
assert target.echo_input is True