mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Simplify Python hosting core (#6492)
Remove linking, multicast, durable delivery, and host push machinery from the v1 hosting core. Keep those scenarios in a proposed follow-up ADR and update channel packages, samples, docs, tests, and workspace metadata around the smaller host/channel contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
e5a6e35843
commit
36ce0950e4
@@ -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"
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user