chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,20 @@
# Copyright (c) Microsoft. All rights reserved.
"""Pytest configuration for declarative tests."""
import sys
import pytest
# Skip all tests in this directory on Python 3.14+ because powerfx doesn't support it yet
if sys.version_info >= (3, 14):
collect_ignore_glob = ["test_*.py"]
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
"""Skip all declarative tests on Python 3.14+ due to powerfx incompatibility."""
if sys.version_info >= (3, 14):
skip_marker = pytest.mark.skip(reason="powerfx does not support Python 3.14+")
for item in items:
if "declarative" in str(item.fspath):
item.add_marker(skip_marker)
@@ -0,0 +1,528 @@
# Copyright (c) Microsoft. All rights reserved.
# pyright: reportUnknownParameterType=false, reportUnknownArgumentType=false
# pyright: reportMissingParameterType=false, reportUnknownMemberType=false
# pyright: reportPrivateUsage=false, reportUnknownVariableType=false
# pyright: reportGeneralTypeIssues=false
"""Regression tests pinning the approval-flow binding contract.
The resumed invocation MUST come from the framework-delivered
``original_request`` payload (the data the reviewer approved) for both
``InvokeFunctionTool`` and ``InvokeMcpTool``. These tests verify that:
* Invocation parameters come from ``original_request``, not from any prior
side-channel state.
* Concurrent pending approvals on the same executor do not swap.
* Pre-existing state at old approval keys is ignored entirely.
* Resume works on a freshly constructed executor (checkpoint-restore
simulation), without any prior ``ctx.state`` write.
* For MCP, ``connection_name`` is sourced from the approval payload and
``headers`` are re-evaluated from the action definition on resume.
"""
import sys
from dataclasses import dataclass
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = pytest.mark.skipif(
not _powerfx_available or sys.version_info >= (3, 14),
reason="PowerFx engine not available (requires dotnet runtime)",
)
from agent_framework import Content # noqa: E402
from agent_framework_declarative._workflows import ( # noqa: E402
DECLARATIVE_STATE_KEY,
ActionComplete,
InvokeFunctionToolExecutor,
MCPToolApprovalRequest,
MCPToolHandler,
MCPToolInvocation,
MCPToolResult,
ToolApprovalRequest,
ToolApprovalResponse,
)
from agent_framework_declarative._workflows._declarative_base import DeclarativeWorkflowState # noqa: E402
from agent_framework_declarative._workflows._executors_mcp import ( # noqa: E402
InvokeMcpToolActionExecutor,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_state() -> MagicMock:
"""In-memory mock of the underlying State."""
state = MagicMock()
state._data = {}
def _get(key: str, default: Any = None) -> Any:
return state._data.get(key, default)
def _set(key: str, value: Any) -> None:
state._data[key] = value
def _has(key: str) -> bool:
return key in state._data
def _delete(key: str) -> None:
state._data.pop(key, None)
state.get = MagicMock(side_effect=_get)
state.set = MagicMock(side_effect=_set)
state.has = MagicMock(side_effect=_has)
state.delete = MagicMock(side_effect=_delete)
return state
@pytest.fixture
def mock_context(mock_state: MagicMock) -> MagicMock:
ctx = MagicMock()
ctx.state = mock_state
ctx.send_message = AsyncMock()
ctx.yield_output = AsyncMock()
ctx.request_info = AsyncMock()
return ctx
def _seed_state(mock_state: MagicMock) -> None:
mock_state._data[DECLARATIVE_STATE_KEY] = {
"Inputs": {},
"Outputs": {},
"Local": {},
"Custom": {},
"System": {
"ConversationId": "00000000-0000-0000-0000-000000000000",
"LastMessage": {"Text": "", "Id": ""},
"LastMessageText": "",
"LastMessageId": "",
},
"Agent": {},
"Conversation": {"messages": [], "history": []},
}
class _RecordingMcpHandler(MCPToolHandler):
def __init__(self, result: MCPToolResult | None = None) -> None:
self.result = result or MCPToolResult(outputs=[Content.from_text("ok")])
self.invocations: list[MCPToolInvocation] = []
@property
def call_count(self) -> int:
return len(self.invocations)
@property
def last(self) -> MCPToolInvocation | None:
return self.invocations[-1] if self.invocations else None
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
self.invocations.append(invocation)
return self.result
# ---------------------------------------------------------------------------
# InvokeFunctionTool: approval-binding regression
# ---------------------------------------------------------------------------
class TestFunctionToolApprovalBinding:
def _action(self, *, fn_name: str = "my_tool") -> dict[str, Any]:
return {
"kind": "InvokeFunctionTool",
"id": "fn_action",
"functionName": fn_name,
"requireApproval": True,
"output": {"result": "Local.result"},
}
@pytest.mark.asyncio
async def test_request_id_matches_framework_pending_key(self, mock_state, mock_context) -> None:
"""The id on the emitted ToolApprovalRequest must match the framework's pending-request key."""
from agent_framework_declarative._workflows._declarative_base import ActionTrigger
_seed_state(mock_state)
def my_tool(x: int) -> int:
return x
executor = InvokeFunctionToolExecutor(self._action(), tools={"my_tool": my_tool})
await executor.handle_action(ActionTrigger(), mock_context)
mock_context.request_info.assert_called_once()
emitted_request = mock_context.request_info.call_args[0][0]
framework_request_id = mock_context.request_info.call_args.kwargs["request_id"]
assert isinstance(emitted_request, ToolApprovalRequest)
assert emitted_request.request_id == framework_request_id
@pytest.mark.asyncio
async def test_resume_uses_request_payload_arguments(self, mock_state, mock_context) -> None:
_seed_state(mock_state)
call_log: list[int] = []
def my_tool(x: int) -> int:
call_log.append(x)
return x
executor = InvokeFunctionToolExecutor(self._action(), tools={"my_tool": my_tool})
request = ToolApprovalRequest(request_id="r-1", function_name="my_tool", arguments={"x": 1})
await executor.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context)
assert call_log == [1]
@pytest.mark.asyncio
async def test_concurrent_pending_approvals_do_not_swap(self, mock_state, mock_context) -> None:
"""Two pending approvals, responses delivered out of order — each invocation uses its own payload."""
_seed_state(mock_state)
call_log: list[int] = []
def my_tool(x: int) -> int:
call_log.append(x)
return x
executor = InvokeFunctionToolExecutor(self._action(), tools={"my_tool": my_tool})
request_a = ToolApprovalRequest(request_id="r-A", function_name="my_tool", arguments={"x": 1})
request_b = ToolApprovalRequest(request_id="r-B", function_name="my_tool", arguments={"x": 999})
# Deliver response for B first, then for A. Each invocation must use its own payload.
await executor.handle_approval_response(request_b, ToolApprovalResponse(approved=True), mock_context)
await executor.handle_approval_response(request_a, ToolApprovalResponse(approved=True), mock_context)
assert call_log == [999, 1]
@pytest.mark.asyncio
async def test_resume_ignores_stale_state_at_old_approval_key(self, mock_state, mock_context) -> None:
"""Pre-existing state at the OLD approval key is ignored — payload wins."""
_seed_state(mock_state)
call_log: list[int] = []
def my_tool(x: int) -> int:
call_log.append(x)
return x
executor = InvokeFunctionToolExecutor(self._action(), tools={"my_tool": my_tool})
# Poison the old key shape (no longer read by the executor).
mock_state._data["_tool_approval_state_fn_action"] = {"function_name": "other", "arguments": {"x": 999}}
request = ToolApprovalRequest(request_id="r-3", function_name="my_tool", arguments={"x": 7})
await executor.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context)
assert call_log == [7]
# The poison was never read or deleted by the executor.
assert "_tool_approval_state_fn_action" in mock_state._data
@pytest.mark.asyncio
async def test_fresh_executor_resume_works(self, mock_state, mock_context) -> None:
"""Simulates checkpoint restore: a brand-new executor instance handles the approval response."""
_seed_state(mock_state)
call_log: list[int] = []
def my_tool(x: int) -> int:
call_log.append(x)
return x
# Pretend the executor that emitted the request is gone; a fresh one handles the response.
fresh = InvokeFunctionToolExecutor(self._action(), tools={"my_tool": my_tool})
request = ToolApprovalRequest(request_id="r-4", function_name="my_tool", arguments={"x": 42})
await fresh.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context)
assert call_log == [42]
mock_context.send_message.assert_called_once()
sent = mock_context.send_message.call_args[0][0]
assert isinstance(sent, ActionComplete)
@pytest.mark.asyncio
async def test_rejection_uses_request_payload_function_name(self, mock_state, mock_context) -> None:
_seed_state(mock_state)
def my_tool(x: int) -> int:
raise AssertionError("should not be called when rejected")
executor = InvokeFunctionToolExecutor(self._action(), tools={"my_tool": my_tool})
request = ToolApprovalRequest(request_id="r-5", function_name="my_tool", arguments={"x": 3})
await executor.handle_approval_response(
request, ToolApprovalResponse(approved=False, reason="not authorized"), mock_context
)
# The rejection message references the function name from the request payload.
local = mock_state._data[DECLARATIVE_STATE_KEY]["Local"]
assert local["result"]["rejected"] is True
assert local["result"]["reason"] == "not authorized"
# ---------------------------------------------------------------------------
# InvokeMcpTool: approval-binding regression
# ---------------------------------------------------------------------------
class TestMcpToolApprovalBinding:
def _action(self, *, headers: dict[str, Any] | None = None) -> dict[str, Any]:
action: dict[str, Any] = {
"kind": "InvokeMcpTool",
"id": "mcp_action",
"serverUrl": "https://mcp.example/api",
"toolName": "search",
"requireApproval": True,
"output": {"result": "Local.Result"},
}
if headers is not None:
action["headers"] = headers
return action
@pytest.mark.asyncio
async def test_request_id_matches_framework_pending_key(self, mock_state, mock_context) -> None:
"""The id on the emitted MCPToolApprovalRequest must match the framework's pending-request key."""
from agent_framework_declarative._workflows._declarative_base import ActionTrigger
_seed_state(mock_state)
executor = InvokeMcpToolActionExecutor(self._action(), mcp_tool_handler=_RecordingMcpHandler())
await executor.handle_action(ActionTrigger(), mock_context)
mock_context.request_info.assert_called_once()
emitted_request = mock_context.request_info.call_args[0][0]
framework_request_id = mock_context.request_info.call_args.kwargs["request_id"]
assert isinstance(emitted_request, MCPToolApprovalRequest)
assert emitted_request.request_id == framework_request_id
@pytest.mark.asyncio
async def test_resume_uses_request_payload_fields(self, mock_state, mock_context) -> None:
_seed_state(mock_state)
handler = _RecordingMcpHandler()
executor = InvokeMcpToolActionExecutor(self._action(), mcp_tool_handler=handler)
request = MCPToolApprovalRequest(
request_id="r-1",
tool_name="search",
server_url="https://mcp.example/api",
server_label="prod",
arguments={"q": "x"},
connection_name="conn-A",
)
await executor.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context)
assert handler.call_count == 1
inv = handler.last
assert inv is not None
assert inv.tool_name == "search"
assert inv.server_url == "https://mcp.example/api"
assert inv.server_label == "prod"
assert inv.arguments == {"q": "x"}
assert inv.connection_name == "conn-A"
@pytest.mark.asyncio
async def test_concurrent_pending_mcp_approvals_do_not_swap(self, mock_state, mock_context) -> None:
_seed_state(mock_state)
handler = _RecordingMcpHandler()
executor = InvokeMcpToolActionExecutor(self._action(), mcp_tool_handler=handler)
request_a = MCPToolApprovalRequest(
request_id="r-A",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={"q": "alpha"},
connection_name="conn-A",
)
request_b = MCPToolApprovalRequest(
request_id="r-B",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={"q": "beta"},
connection_name="conn-B",
)
await executor.handle_approval_response(request_b, ToolApprovalResponse(approved=True), mock_context)
await executor.handle_approval_response(request_a, ToolApprovalResponse(approved=True), mock_context)
assert handler.call_count == 2
assert handler.invocations[0].arguments == {"q": "beta"}
assert handler.invocations[0].connection_name == "conn-B"
assert handler.invocations[1].arguments == {"q": "alpha"}
assert handler.invocations[1].connection_name == "conn-A"
@pytest.mark.asyncio
async def test_headers_reevaluated_from_action_def_on_resume(self, mock_state, mock_context) -> None:
"""Headers come from the action definition (re-evaluated) so secrets are not in the payload."""
_seed_state(mock_state)
handler = _RecordingMcpHandler()
executor = InvokeMcpToolActionExecutor(
self._action(headers={"Authorization": "Bearer tk"}),
mcp_tool_handler=handler,
)
request = MCPToolApprovalRequest(
request_id="r-1",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={"q": "x"},
connection_name=None,
)
await executor.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context)
assert handler.last is not None
assert handler.last.headers == {"Authorization": "Bearer tk"}
@pytest.mark.asyncio
async def test_mcp_resume_ignores_stale_state_at_old_approval_key(self, mock_state, mock_context) -> None:
_seed_state(mock_state)
handler = _RecordingMcpHandler()
executor = InvokeMcpToolActionExecutor(self._action(), mcp_tool_handler=handler)
mock_state._data["_mcp_tool_approval_state_mcp_action"] = {"poison": True}
request = MCPToolApprovalRequest(
request_id="r-1",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={"q": "real"},
connection_name=None,
)
await executor.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context)
assert handler.call_count == 1
assert handler.last is not None
assert handler.last.arguments == {"q": "real"}
# The poison was never read or deleted by the executor.
assert "_mcp_tool_approval_state_mcp_action" in mock_state._data
@pytest.mark.asyncio
async def test_fresh_mcp_executor_resume_works(self, mock_state, mock_context) -> None:
"""Checkpoint-restore simulation: fresh executor handles the response."""
_seed_state(mock_state)
handler = _RecordingMcpHandler()
fresh = InvokeMcpToolActionExecutor(self._action(), mcp_tool_handler=handler)
request = MCPToolApprovalRequest(
request_id="r-1",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={"q": "fresh"},
connection_name=None,
)
await fresh.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context)
assert handler.call_count == 1
assert handler.last is not None
assert handler.last.arguments == {"q": "fresh"}
@pytest.mark.asyncio
async def test_request_payload_carries_connection_name(self, mock_state, mock_context) -> None:
"""When emitting the approval request, connection_name flows into MCPToolApprovalRequest."""
from agent_framework_declarative._workflows._declarative_base import ActionTrigger
_seed_state(mock_state)
action = self._action()
action["connection"] = {"name": "conn-from-action"}
executor = InvokeMcpToolActionExecutor(action, mcp_tool_handler=_RecordingMcpHandler())
await executor.handle_action(ActionTrigger(), mock_context)
mock_context.request_info.assert_called_once()
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, MCPToolApprovalRequest)
assert request.connection_name == "conn-from-action"
@pytest.mark.asyncio
async def test_request_payload_pins_conversation_id(self, mock_state, mock_context) -> None:
"""Evaluated ``conversationId`` is pinned in ``metadata`` at request-emit time."""
from agent_framework_declarative._workflows._declarative_base import ActionTrigger
_seed_state(mock_state)
state = DeclarativeWorkflowState(mock_state)
state.set("Local.targetConversation", "conv-original")
action = self._action()
action["conversationId"] = "=Local.targetConversation"
executor = InvokeMcpToolActionExecutor(action, mcp_tool_handler=_RecordingMcpHandler())
await executor.handle_action(ActionTrigger(), mock_context)
mock_context.request_info.assert_called_once()
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, MCPToolApprovalRequest)
assert request.metadata.get("conversation_id") == "conv-original"
@pytest.mark.asyncio
async def test_resume_routes_output_to_pinned_conversation_not_mutated_state(
self, mock_state, mock_context
) -> None:
"""Output appends to the conversation pinned on ``original_request``, not the
current state evaluation."""
_seed_state(mock_state)
state = DeclarativeWorkflowState(mock_state)
state.set("System.conversations.conv-original.messages", [])
state.set("System.conversations.conv-mutated.messages", [])
state.set("Local.targetConversation", "conv-mutated")
handler = _RecordingMcpHandler(MCPToolResult(outputs=[Content.from_text("approved-output")]))
action = self._action()
action["conversationId"] = "=Local.targetConversation"
executor = InvokeMcpToolActionExecutor(action, mcp_tool_handler=handler)
original_request = MCPToolApprovalRequest(
request_id="r-1",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={"q": "x"},
connection_name=None,
metadata={"conversation_id": "conv-original"},
)
await executor.handle_approval_response(original_request, ToolApprovalResponse(approved=True), mock_context)
assert len(state.get("System.conversations.conv-original.messages") or []) == 1
assert state.get("System.conversations.conv-mutated.messages") == []
@pytest.mark.asyncio
async def test_resume_handles_legacy_request_without_new_fields(self, mock_state, mock_context) -> None:
"""Resume tolerates payloads lacking ``connection_name`` / ``metadata`` (legacy pickle shape)."""
@dataclass
class _LegacyMCPApprovalRequest:
request_id: str
tool_name: str
server_url: str
server_label: str | None
arguments: dict[str, Any]
header_names: list[str]
_seed_state(mock_state)
handler = _RecordingMcpHandler()
executor = InvokeMcpToolActionExecutor(self._action(), mcp_tool_handler=handler)
legacy_request = _LegacyMCPApprovalRequest(
request_id="r-1",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={"q": "x"},
header_names=[],
)
await executor.handle_approval_response(
legacy_request, # type: ignore[arg-type]
ToolApprovalResponse(approved=True),
mock_context,
)
assert handler.call_count == 1
assert handler.last is not None
assert handler.last.connection_name is None
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,364 @@
# Copyright (c) Microsoft. All rights reserved.
# pyright: reportUnknownParameterType=false, reportUnknownArgumentType=false
# pyright: reportMissingParameterType=false, reportUnknownMemberType=false
# pyright: reportPrivateUsage=false, reportUnknownVariableType=false
# pyright: reportGeneralTypeIssues=false
"""Path-segment validation tests for DeclarativeWorkflowState.
Path segments handed to ``get``/``set``/``append`` and ``{Variable.Path}``
placeholders in ``interpolate_string`` are subject to three distinct rules
that this module pins:
- **Empty segments** (e.g. ``""``, ``"Local."``, ``"Local..foo"``) are rejected
by all of ``get``/``set``/``append`` and ``interpolate_string``. ``get`` and
``interpolate_string`` return their default / leave the placeholder literal;
``set`` and ``append`` raise ``ValueError``.
- **Object-attribute segments** — segments that ``get`` would resolve via
``getattr`` because the parent is a non-dict object — must match the safe
identifier shape ``[A-Za-z][A-Za-z0-9_]*``. Other shapes are rejected with a
warning log and the default is returned.
- **Dict-keyed segments** — segments that resolve via dict lookup because the
parent is a ``dict`` — may use arbitrary non-empty string keys (e.g. UUIDs
or hyphenated identifiers like ``System.conversations.<uuid>.messages``).
"""
import logging
from dataclasses import dataclass
from typing import Any
from unittest.mock import MagicMock
import pytest
from agent_framework_declarative._workflows import DeclarativeWorkflowState
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
_requires_powerfx = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")
@pytest.fixture
def mock_state() -> MagicMock:
"""In-memory mock for the underlying State."""
ms = MagicMock()
ms._data = {}
def get(key: str, default: Any = None) -> Any:
return ms._data.get(key, default)
def set_(key: str, value: Any) -> None:
ms._data[key] = value
def has(key: str) -> bool:
return key in ms._data
def delete(key: str) -> None:
ms._data.pop(key, None)
ms.get = MagicMock(side_effect=get)
ms.set = MagicMock(side_effect=set_)
ms.has = MagicMock(side_effect=has)
ms.delete = MagicMock(side_effect=delete)
return ms
@pytest.fixture
def state(mock_state: MagicMock) -> DeclarativeWorkflowState:
s = DeclarativeWorkflowState(mock_state)
s.initialize()
return s
@dataclass
class _PlainObj:
"""Non-dict object so ``get`` falls through to attribute access."""
text: str = "hi"
# ---------------------------------------------------------------------------
# get(): invalid paths return default
# ---------------------------------------------------------------------------
class TestGetRejectsInvalidPaths:
def test_rejects_dunder_segment_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.obj", _PlainObj())
assert state.get("Local.obj.__class__") is None
assert state.get("Local.obj.__class__", default="DEF") == "DEF"
def test_rejects_full_env_exfil_chain(self, state: DeclarativeWorkflowState, monkeypatch) -> None:
sentinel = "agent-framework-path-safety-sentinel"
monkeypatch.setenv("AF_PATH_SAFETY_SENTINEL", sentinel)
state.set("Local.obj", _PlainObj())
result = state.get("Local.obj.__class__.__init__.__globals__.os.environ")
assert result is None
assert sentinel not in str(result)
def test_rejects_leading_underscore_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.obj", _PlainObj())
assert state.get("Local.obj._private") is None
def test_rejects_invalid_chars_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.obj", _PlainObj())
assert state.get("Local.obj.text bar") is None
assert state.get("Local.obj.text-bar") is None
def test_rejects_empty_path_and_empty_segments(self, state: DeclarativeWorkflowState) -> None:
assert state.get("") is None
assert state.get(".") is None
assert state.get("Local.") is None
assert state.get(".Local") is None
def test_warning_logged_on_rejected_attribute_segment(
self,
state: DeclarativeWorkflowState,
caplog: pytest.LogCaptureFixture,
) -> None:
state.set("Local.obj", _PlainObj())
with caplog.at_level(logging.WARNING, logger="agent_framework_declarative._workflows._declarative_base"):
state.get("Local.obj.__class__")
assert any("rejecting attribute segment" in r.message for r in caplog.records)
def test_dict_keyed_dunder_is_not_attribute_access(self, state: DeclarativeWorkflowState) -> None:
"""A literal dunder dict key is harmless because dict lookup never reaches getattr."""
state.set("Local.bag", {"__class__": "harmless-string"})
assert state.get("Local.bag.__class__") == "harmless-string"
# ---------------------------------------------------------------------------
# get(): legitimate paths continue to work
# ---------------------------------------------------------------------------
class TestGetAllowsValidPaths:
def test_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.user_input", "ok")
assert state.get("Local.user_input") == "ok"
def test_mixed_case_identifiers(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.UserInput", "u1")
state.set("Local.userInput", "u2")
assert state.get("Local.UserInput") == "u1"
assert state.get("Local.userInput") == "u2"
def test_object_attribute_traversal_still_works(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.msg", _PlainObj(text="hello"))
assert state.get("Local.msg.text") == "hello"
def test_nested_dict_traversal_still_works(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.params", {"team": {"name": "alpha"}})
assert state.get("Local.params.team.name") == "alpha"
def test_uuid_and_hyphenated_dict_keys_are_allowed(self, state: DeclarativeWorkflowState) -> None:
"""Conversation-id style paths use arbitrary dict keys (UUIDs / hyphens)."""
conv_id = "eb815014-06f1-4db6-b7c1-304ea135424f"
state.set(f"System.conversations.{conv_id}.messages", ["m1", "m2"])
assert state.get(f"System.conversations.{conv_id}.messages") == ["m1", "m2"]
# ---------------------------------------------------------------------------
# set() / append(): dict-keyed operations accept arbitrary string keys
# ---------------------------------------------------------------------------
class TestSetAndAppend:
def test_set_allows_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.user_input", "ok")
assert state.get("Local.user_input") == "ok"
def test_set_allows_uuid_and_hyphenated_dict_keys(self, state: DeclarativeWorkflowState) -> None:
conv_id = "conv-test-1"
state.set(f"System.conversations.{conv_id}.messages", [])
assert state.get(f"System.conversations.{conv_id}.messages") == []
def test_append_allows_uuid_and_hyphenated_dict_keys(self, state: DeclarativeWorkflowState) -> None:
conv_id = "conv-42"
state.append(f"System.conversations.{conv_id}.messages", {"role": "user", "text": "hi"})
msgs = state.get(f"System.conversations.{conv_id}.messages")
assert msgs == [{"role": "user", "text": "hi"}]
def test_workflow_inputs_still_read_only(self, state: DeclarativeWorkflowState) -> None:
with pytest.raises(ValueError, match="read-only"):
state.set("Workflow.Inputs.x", 1)
# ---------------------------------------------------------------------------
# set() / append(): malformed paths (empty segments) raise ValueError
# ---------------------------------------------------------------------------
class TestSetRejectsInvalidPaths:
@pytest.mark.parametrize("bad_path", ["", "Local.", "Local..foo", ".Local"])
def test_set_rejects_empty_segment(self, state: DeclarativeWorkflowState, bad_path: str) -> None:
with pytest.raises(ValueError, match="empty segments are not allowed"):
state.set(bad_path, "x")
@pytest.mark.parametrize("bad_path", ["", "Local.", "Local..foo", ".Local"])
def test_append_rejects_empty_segment(self, state: DeclarativeWorkflowState, bad_path: str) -> None:
with pytest.raises(ValueError, match="empty segments are not allowed"):
state.append(bad_path, "x")
def test_set_rejection_makes_no_partial_write(self, state: DeclarativeWorkflowState) -> None:
"""Rejected set() must not create an unreachable entry in the state."""
state.set("Local.user_input", "pre")
with pytest.raises(ValueError):
state.set("Local.", "value")
local = state.get_state_data().get("Local", {})
assert "" not in local
assert local == {"user_input": "pre"}
assert state.get("Local.") is None
assert state.get("Local.user_input") == "pre"
def test_append_rejection_makes_no_partial_write(self, state: DeclarativeWorkflowState) -> None:
"""Rejected append() must not create an unreachable entry in the state."""
state.set("Local.items", ["a"])
with pytest.raises(ValueError):
state.append("Local.", "value")
local = state.get_state_data().get("Local", {})
assert "" not in local
assert local == {"items": ["a"]}
# ---------------------------------------------------------------------------
# interpolate_string(): permissive matcher; get() enforces safety
# ---------------------------------------------------------------------------
class TestInterpolateString:
def test_ignores_dunder_payload(self, state: DeclarativeWorkflowState, monkeypatch) -> None:
sentinel = "agent-framework-interp-sentinel"
monkeypatch.setenv("AF_INTERP_SENTINEL", sentinel)
state.set("Local.obj", _PlainObj())
out = state.interpolate_string("X={Local.obj.__class__.__init__.__globals__.os.environ}")
assert sentinel not in out
assert out == "X="
def test_unknown_path_reduces_to_empty(self, state: DeclarativeWorkflowState) -> None:
assert state.interpolate_string("v={Local._private}") == "v="
@pytest.mark.parametrize(
"literal",
["{foo-bar}", "{Ctrl+C}", "{not:a:path}", "{Local.}", "{}"],
)
def test_non_state_braced_tokens_left_literal(self, state: DeclarativeWorkflowState, literal: str) -> None:
assert state.interpolate_string(f"v={literal}") == f"v={literal}"
def test_allows_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.user_input", "hello")
assert state.interpolate_string("v={Local.user_input}") == "v=hello"
def test_resolves_nested_dict_path(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.params", {"team": "alpha"})
assert state.interpolate_string("team={Local.params.team}") == "team=alpha"
@pytest.mark.parametrize(
("key", "value"),
[
("_id", "abc123"),
("1", "one"),
("2025", "year-bucket"),
],
)
def test_resolves_dict_keyed_segments(self, state: DeclarativeWorkflowState, key: str, value: str) -> None:
state.set("Local.bag", {key: value})
assert state.interpolate_string(f"v={{Local.bag.{key}}}") == f"v={value}"
def test_resolves_uuid_conversation_key(self, state: DeclarativeWorkflowState) -> None:
conv_id = "eb815014-06f1-4db6-b7c1-304ea135424f"
state.set(f"System.conversations.{conv_id}.messages", ["hello"])
out = state.interpolate_string(f"m={{System.conversations.{conv_id}.messages}}")
assert out == "m=['hello']"
def test_end_to_end_send_activity_payload_neutralized(
self,
state: DeclarativeWorkflowState,
monkeypatch,
) -> None:
sentinel = "agent-framework-e2e-sentinel"
monkeypatch.setenv("AF_E2E_SENTINEL", sentinel)
state.set("Local.toolResult", _PlainObj())
payload = "{Local.toolResult.__class__.__init__.__globals__.os.environ}"
evaluated = state.eval_if_expression(payload)
rendered = state.interpolate_string(evaluated) if isinstance(evaluated, str) else str(evaluated)
assert sentinel not in rendered
assert rendered == ""
# ---------------------------------------------------------------------------
# Regressions: PowerFx and internal temp-variable handling still work
# ---------------------------------------------------------------------------
@_requires_powerfx
class TestPowerFxStillWorks:
def test_simple_powerfx_expression_evaluates(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.x", 6)
state.set("Local.y", 7)
assert state.eval("=Local.x * Local.y") == 42
def test_internal_temp_message_text_still_works(self, state: DeclarativeWorkflowState) -> None:
"""Long MessageText() results round-trip and the temp key is removed after eval."""
long_text = "A" * 600
state.set(
"Local.Messages",
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
)
result = state.eval("=Upper(MessageText(Local.Messages))")
assert result == "A" * 600
local = state.get_state_data().get("Local", {})
remaining = sorted(k for k in local if k.startswith("_TempMessageText"))
assert not remaining, f"Temporary keys remain in Local: {remaining}"
def test_message_text_eval_preserves_user_temp_value(self, state: DeclarativeWorkflowState) -> None:
"""User state at the temp key path survives a long MessageText eval."""
long_text = "A" * 600
state.set("Local._TempMessageText0", "user-important-value")
state.set(
"Local.Messages",
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
)
result = state.eval("=Upper(MessageText(Local.Messages))")
assert result == "A" * 600
assert state.get("Local._TempMessageText0") == "user-important-value"
def test_message_text_eval_cleans_up_on_powerfx_failure(
self,
state: DeclarativeWorkflowState,
monkeypatch,
) -> None:
"""Temp key is removed even when PowerFx evaluation raises."""
from agent_framework_declarative._workflows import _declarative_base as base
class _FailingEngine:
def eval(self, *args: Any, **kwargs: Any) -> Any:
raise RuntimeError("boom")
monkeypatch.setattr(base, "Engine", _FailingEngine)
long_text = "A" * 600
state.set(
"Local.Messages",
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
)
with pytest.raises(RuntimeError, match="boom"):
state.eval("=Upper(MessageText(Local.Messages))")
local = state.get_state_data().get("Local", {})
remaining = sorted(k for k in local if k.startswith("_TempMessageText"))
assert not remaining, f"Temporary keys remain in Local after PowerFx failure: {remaining}"
@@ -0,0 +1,329 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for ``DefaultHttpRequestHandler``.
These tests exercise the real handler against ``httpx.MockTransport`` (no real
network) to cover the parts of the handler not exercisable through the executor
stub: query-param URL composition, content-type forwarding, per-request
timeout overrides, multi-value response header preservation, and client
ownership semantics.
"""
from __future__ import annotations
import sys
import httpx
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
# These tests don't actually need PowerFx, but the rest of the suite gates on
# Python versions and we keep behaviour consistent.
pytestmark = pytest.mark.skipif(
sys.version_info >= (3, 14),
reason="Skipped on Python 3.14+ to keep parity with rest of declarative suite",
)
from agent_framework_declarative._workflows._http_handler import ( # noqa: E402
DefaultHttpRequestHandler,
HttpRequestInfo,
)
def _make_handler(transport: httpx.MockTransport) -> DefaultHttpRequestHandler:
"""Return a handler with a MockTransport-backed caller-owned client."""
client = httpx.AsyncClient(transport=transport)
return DefaultHttpRequestHandler(client=client)
class TestRequestComposition:
@pytest.mark.asyncio
async def test_query_parameters_merged_into_url(self) -> None:
captured: dict[str, httpx.Request] = {}
def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(200, text="ok")
handler = _make_handler(httpx.MockTransport(respond))
try:
await handler.send(
HttpRequestInfo(
method="GET",
url="https://api.example.test/items",
query_parameters={"q": "alpha", "limit": "5"},
)
)
finally:
await handler.aclose()
req = captured["req"]
# httpx exposes the merged URL with QS appended
assert req.url.params.get("q") == "alpha"
assert req.url.params.get("limit") == "5"
@pytest.mark.asyncio
async def test_body_content_type_forwarded(self) -> None:
captured: dict[str, httpx.Request] = {}
def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(204)
handler = _make_handler(httpx.MockTransport(respond))
try:
await handler.send(
HttpRequestInfo(
method="POST",
url="https://api.example.test/items",
body='{"k":"v"}',
body_content_type="application/json",
)
)
finally:
await handler.aclose()
req = captured["req"]
assert req.headers.get("content-type") == "application/json"
assert req.content == b'{"k":"v"}'
@pytest.mark.asyncio
async def test_existing_content_type_header_not_overwritten(self) -> None:
captured: dict[str, httpx.Request] = {}
def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(200, text="ok")
handler = _make_handler(httpx.MockTransport(respond))
try:
await handler.send(
HttpRequestInfo(
method="POST",
url="https://api.example.test/items",
headers={"Content-Type": "application/xml"}, # caller wins
body="<x/>",
body_content_type="application/json",
)
)
finally:
await handler.aclose()
req = captured["req"]
assert req.headers.get("content-type") == "application/xml"
@pytest.mark.asyncio
async def test_body_without_content_type_defaults_to_text_plain(self) -> None:
"""Match .NET DefaultHttpRequestHandler: body without explicit content type → ``text/plain``."""
captured: dict[str, httpx.Request] = {}
def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(204)
handler = _make_handler(httpx.MockTransport(respond))
try:
await handler.send(
HttpRequestInfo(
method="POST",
url="https://api.example.test/items",
body="hello",
# No body_content_type and no Content-Type header.
)
)
finally:
await handler.aclose()
req = captured["req"]
assert req.headers.get("content-type") == "text/plain"
assert req.content == b"hello"
class TestTimeout:
@pytest.mark.asyncio
async def test_per_request_timeout_surfaces_as_timeout_exception(self) -> None:
def respond(request: httpx.Request) -> httpx.Response:
raise httpx.TimeoutException("simulated timeout", request=request)
handler = _make_handler(httpx.MockTransport(respond))
try:
with pytest.raises(httpx.TimeoutException):
await handler.send(
HttpRequestInfo(
method="GET",
url="https://api.example.test/slow",
timeout_ms=50,
)
)
finally:
await handler.aclose()
class TestResponseHeaders:
@pytest.mark.asyncio
async def test_multi_value_headers_preserved(self) -> None:
def respond(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
text="ok",
headers=[
("Content-Type", "application/json"),
("Set-Cookie", "a=1"),
("Set-Cookie", "b=2"),
],
)
handler = _make_handler(httpx.MockTransport(respond))
try:
result = await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
finally:
await handler.aclose()
assert result.is_success_status_code
# The handler keeps multi-value headers as list[str].
assert result.headers.get("set-cookie") == ["a=1", "b=2"]
assert result.headers.get("content-type") == ["application/json"]
class TestClientOwnership:
@pytest.mark.asyncio
async def test_owned_client_is_closed_on_aclose(self) -> None:
handler = DefaultHttpRequestHandler()
# Inject a MockTransport-backed client into the owned slot and verify
# aclose() releases it. Avoids real network access.
owned = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
handler._owned_client = owned
assert not owned.is_closed
await handler.aclose()
assert owned.is_closed
@pytest.mark.asyncio
async def test_caller_owned_client_is_not_closed(self) -> None:
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
handler = DefaultHttpRequestHandler(client=client)
await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
await handler.aclose()
assert not client.is_closed
await client.aclose() # cleanup
@pytest.mark.asyncio
async def test_concurrent_first_send_creates_single_owned_client(self) -> None:
"""Concurrent first-send calls must not race-leak duplicate clients.
Without the lock, two concurrent calls on a fresh handler would each
observe ``_owned_client is None`` and create their own
``httpx.AsyncClient``, orphaning one. Verify that lazy initialization
is serialized: all concurrent sends end up using the same client and
``aclose()`` cleanly closes it.
"""
import asyncio
# Patch httpx.AsyncClient to count constructions, but only when called
# from inside _resolve_client (no transport=) so we don't break the
# MockTransport-backed clients used elsewhere.
original_ctor = httpx.AsyncClient
construction_count = 0
def counting_ctor(*args, **kwargs): # type: ignore[no-untyped-def]
nonlocal construction_count
if not args and not kwargs:
construction_count += 1
return original_ctor(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
return original_ctor(*args, **kwargs)
import agent_framework_declarative._workflows._http_handler as hh
hh.httpx.AsyncClient = counting_ctor # type: ignore[assignment] # ty: ignore[invalid-assignment]
try:
handler = DefaultHttpRequestHandler()
try:
await asyncio.gather(*[
handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x")) for _ in range(8)
])
finally:
await handler.aclose()
finally:
hh.httpx.AsyncClient = original_ctor # type: ignore[assignment]
assert construction_count == 1, (
f"Expected exactly 1 owned client to be lazily created but got {construction_count}"
)
class TestClientProvider:
@pytest.mark.asyncio
async def test_client_provider_overrides_default(self) -> None:
captured: dict[str, str] = {}
def primary(request: httpx.Request) -> httpx.Response:
captured["transport"] = "primary"
return httpx.Response(200, text="primary")
def provided(request: httpx.Request) -> httpx.Response:
captured["transport"] = "provided"
return httpx.Response(200, text="provided")
primary_client = httpx.AsyncClient(transport=httpx.MockTransport(primary))
provided_client = httpx.AsyncClient(transport=httpx.MockTransport(provided))
async def provider(info: HttpRequestInfo) -> httpx.AsyncClient:
return provided_client
handler = DefaultHttpRequestHandler(client=primary_client, client_provider=provider)
try:
result = await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
assert result.body == "provided"
assert captured["transport"] == "provided"
finally:
await handler.aclose()
await primary_client.aclose()
await provided_client.aclose()
@pytest.mark.asyncio
async def test_client_provider_returning_none_falls_back(self) -> None:
captured: dict[str, str] = {}
def primary(request: httpx.Request) -> httpx.Response:
captured["transport"] = "primary"
return httpx.Response(200, text="primary")
async def provider(info: HttpRequestInfo) -> httpx.AsyncClient | None:
return None
primary_client = httpx.AsyncClient(transport=httpx.MockTransport(primary))
handler = DefaultHttpRequestHandler(client=primary_client, client_provider=provider)
try:
result = await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
assert result.body == "primary"
finally:
await handler.aclose()
await primary_client.aclose()
class TestValidation:
@pytest.mark.asyncio
async def test_empty_url_raises(self) -> None:
handler = DefaultHttpRequestHandler()
with pytest.raises(ValueError):
await handler.send(HttpRequestInfo(method="GET", url=""))
@pytest.mark.asyncio
async def test_empty_method_raises(self) -> None:
handler = DefaultHttpRequestHandler()
with pytest.raises(ValueError):
await handler.send(HttpRequestInfo(method="", url="https://x.test/"))
class TestAsyncContextManager:
@pytest.mark.asyncio
async def test_context_manager_closes_owned_client(self) -> None:
async with DefaultHttpRequestHandler() as handler:
owned = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
handler._owned_client = owned
assert owned.is_closed
@@ -0,0 +1,789 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for ``DefaultMCPToolHandler``.
These tests exercise the real handler against a fake ``MCPStreamableHTTPTool``
(no real MCP server, no real network) to cover the parts of the handler not
exercisable through the executor stub: cache hit/miss/eviction, concurrent
connect via in-flight futures, header isolation across cache keys,
string-result normalisation, ``load_prompts=False`` verification, and
owned-vs-caller httpx close semantics.
"""
from __future__ import annotations
import asyncio
import json
import sys
from typing import Any
from unittest.mock import patch
import httpx
import pytest
from agent_framework import Content
from agent_framework.exceptions import ToolExecutionException
from agent_framework_declarative._workflows._mcp_handler import (
DefaultMCPToolHandler,
MCPToolInvocation,
)
pytestmark = pytest.mark.skipif(
sys.version_info >= (3, 14),
reason="Skipped on Python 3.14+ to keep parity with rest of declarative suite",
)
class FakeListToolsResult: # noqa: B903 - mimics ``mcp.types.ListToolsResult`` shape, not a value type
"""Stand-in for ``mcp.types.ListToolsResult`` returned by ``session.list_tools()``."""
def __init__(self, tools: list[Any], next_cursor: str | None = None) -> None:
self.tools = tools
self.nextCursor = next_cursor
class FakeMcpTool:
"""Stand-in for an MCP ``Tool`` (subset used by ``_invoke_list_tools``)."""
def __init__(
self,
name: str,
description: str | None = None,
inputSchema: dict[str, Any] | None = None,
outputSchema: dict[str, Any] | None = None,
) -> None:
self.name = name
self.description = description
self.inputSchema = inputSchema if inputSchema is not None else {"type": "object", "properties": {}}
self.outputSchema = outputSchema
class FakeMcpSession:
"""Stand-in for ``mcp.ClientSession``.
``list_tools_pages`` lets a test enqueue multiple paginated responses;
when None (default), an empty single-page result is returned. ``list_tools_error``
raises a synthetic error on the next call when set.
"""
def __init__(self) -> None:
self.list_tools_pages: list[FakeListToolsResult] | None = None
self.list_tools_calls: list[Any] = []
self.list_tools_error: BaseException | None = None
async def list_tools(self, params: Any = None) -> FakeListToolsResult:
self.list_tools_calls.append(params)
if self.list_tools_error is not None:
raise self.list_tools_error
if self.list_tools_pages is None:
return FakeListToolsResult(tools=[])
index = len(self.list_tools_calls) - 1
if index >= len(self.list_tools_pages):
return FakeListToolsResult(tools=[])
return self.list_tools_pages[index]
class FakeTool:
"""Stand-in for ``MCPStreamableHTTPTool``.
Records constructor kwargs, tracks connect/close lifecycle, and dispatches
``call_tool`` to a per-instance handler.
"""
instances: list[FakeTool] = []
def __init__(self, **kwargs: Any) -> None:
self.kwargs = kwargs
self.connect_count = 0
self.close_count = 0
self.connect_delay: float = 0.0
self.connect_error: BaseException | None = None
self.call_handler: Any = lambda **_a: [Content.from_text("ok")]
self._httpx_client: httpx.AsyncClient | None = None
self.session: FakeMcpSession | None = None
# Mimic MCPStreamableHTTPTool: when no caller client AND header_provider
# is set, lazily allocate an owned httpx client during connect.
FakeTool.instances.append(self)
async def connect(self) -> None:
if self.connect_delay:
await asyncio.sleep(self.connect_delay)
if self.connect_error is not None:
raise self.connect_error
self.connect_count += 1
# Mimic lazy httpx allocation when no client provided AND header_provider set.
if self.kwargs.get("http_client") is None and self.kwargs.get("header_provider") is not None:
self._httpx_client = httpx.AsyncClient()
# Mimic MCPStreamableHTTPTool: a live session becomes available after connect.
if self.session is None:
self.session = FakeMcpSession()
async def close(self) -> None:
self.close_count += 1
async def call_tool(self, tool_name: str, **arguments: Any) -> Any:
return self.call_handler(tool_name=tool_name, **arguments)
@pytest.fixture(autouse=True)
def _clear_fake_instances() -> None:
FakeTool.instances.clear()
def _patch_tool() -> Any:
"""Patch the lazy import inside ``_create_entry`` to substitute FakeTool."""
import agent_framework
return patch.object(agent_framework, "MCPStreamableHTTPTool", FakeTool)
def _invocation(
*, server_url: str = "https://mcp.example/api", tool_name: str = "search", **overrides: Any
) -> MCPToolInvocation:
return MCPToolInvocation(
server_url=server_url,
tool_name=tool_name,
**overrides,
)
# ---------- Construction ---------------------------------------------------
class TestConstruction:
def test_invalid_cache_size_raises(self) -> None:
with pytest.raises(ValueError):
DefaultMCPToolHandler(cache_max_size=0)
with pytest.raises(ValueError):
DefaultMCPToolHandler(cache_max_size=-3)
# ---------- Tool kwargs ----------------------------------------------------
class TestToolKwargs:
@pytest.mark.asyncio
async def test_load_prompts_false_passed_to_tool(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
assert len(FakeTool.instances) == 1
assert FakeTool.instances[0].kwargs["load_prompts"] is False
@pytest.mark.asyncio
async def test_server_label_used_as_tool_name(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(server_label="MyMcp"))
assert FakeTool.instances[0].kwargs["name"] == "MyMcp"
@pytest.mark.asyncio
async def test_default_tool_name_when_no_label(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(server_label=None))
assert FakeTool.instances[0].kwargs["name"] == "McpClient"
@pytest.mark.asyncio
async def test_no_header_provider_when_no_headers(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(headers={}))
assert FakeTool.instances[0].kwargs["header_provider"] is None
@pytest.mark.asyncio
async def test_header_provider_returns_captured_headers(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(headers={"Authorization": "Bearer T"}))
provider = FakeTool.instances[0].kwargs["header_provider"]
assert provider({}) == {"Authorization": "Bearer T"}
# Even if runtime kwargs change, captured headers stay the same.
assert provider({"foo": "bar"}) == {"Authorization": "Bearer T"}
# ---------- Cache behaviour ------------------------------------------------
class TestCache:
@pytest.mark.asyncio
async def test_same_url_and_headers_hit_cache(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(headers={"X": "1"}))
await handler.invoke_tool(_invocation(headers={"X": "1"}))
# One tool created, connect called once.
assert len(FakeTool.instances) == 1
assert FakeTool.instances[0].connect_count == 1
@pytest.mark.asyncio
async def test_different_headers_create_separate_entries(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(headers={"Authorization": "tk-A"}))
await handler.invoke_tool(_invocation(headers={"Authorization": "tk-B"}))
assert len(FakeTool.instances) == 2
@pytest.mark.asyncio
async def test_different_urls_create_separate_entries(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(server_url="https://mcp.a/api"))
await handler.invoke_tool(_invocation(server_url="https://mcp.b/api"))
assert len(FakeTool.instances) == 2
@pytest.mark.asyncio
async def test_lru_eviction_closes_old_entry(self) -> None:
handler = DefaultMCPToolHandler(cache_max_size=2)
with _patch_tool():
await handler.invoke_tool(_invocation(server_url="https://a/"))
await handler.invoke_tool(_invocation(server_url="https://b/"))
# Inserting a third evicts the LRU entry (the first one).
await handler.invoke_tool(_invocation(server_url="https://c/"))
assert len(FakeTool.instances) == 3
# First instance (https://a/) was evicted → close() called.
assert FakeTool.instances[0].kwargs["url"] == "https://a/"
assert FakeTool.instances[0].close_count == 1
# Other two remain in cache → not closed.
assert FakeTool.instances[1].close_count == 0
assert FakeTool.instances[2].close_count == 0
@pytest.mark.asyncio
async def test_repeated_use_keeps_lru_alive(self) -> None:
handler = DefaultMCPToolHandler(cache_max_size=2)
with _patch_tool():
await handler.invoke_tool(_invocation(server_url="https://a/"))
await handler.invoke_tool(_invocation(server_url="https://b/"))
# Touch a → b becomes LRU.
await handler.invoke_tool(_invocation(server_url="https://a/"))
# Insert c → b is evicted.
await handler.invoke_tool(_invocation(server_url="https://c/"))
# b was evicted.
b = FakeTool.instances[1]
assert b.kwargs["url"] == "https://b/"
assert b.close_count == 1
# a survived.
a = FakeTool.instances[0]
assert a.kwargs["url"] == "https://a/"
assert a.close_count == 0
@pytest.mark.asyncio
async def test_concurrent_connect_shares_one_entry(self) -> None:
"""Multiple concurrent invocations with the same key must share one tool."""
handler = DefaultMCPToolHandler()
# Slow down connect so concurrency window is observable.
original_connect = FakeTool.connect
async def slow_connect(self: FakeTool) -> None:
self.connect_delay = 0.05
await original_connect(self)
with _patch_tool(), patch.object(FakeTool, "connect", slow_connect):
results = await asyncio.gather(
handler.invoke_tool(_invocation(headers={"X": "1"})),
handler.invoke_tool(_invocation(headers={"X": "1"})),
handler.invoke_tool(_invocation(headers={"X": "1"})),
handler.invoke_tool(_invocation(headers={"X": "1"})),
)
assert all(not r.is_error for r in results)
# Only one tool was created and connected, despite 4 concurrent calls.
assert len(FakeTool.instances) == 1
assert FakeTool.instances[0].connect_count == 1
@pytest.mark.asyncio
async def test_different_connection_names_create_separate_entries(self) -> None:
"""Same URL/headers but different ``connection_name`` must dispatch separately."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(connection_name="conn-A"))
await handler.invoke_tool(_invocation(connection_name="conn-B"))
assert len(FakeTool.instances) == 2
@pytest.mark.asyncio
async def test_different_server_labels_create_separate_entries(self) -> None:
"""Same URL/headers but different ``server_label`` must dispatch separately."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(server_label="LabelA"))
await handler.invoke_tool(_invocation(server_label="LabelB"))
assert len(FakeTool.instances) == 2
@pytest.mark.asyncio
async def test_full_identity_match_hits_cache(self) -> None:
"""All four identity components match → single cached entry."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(server_label="Lbl", connection_name="C", headers={"X": "1"}))
await handler.invoke_tool(_invocation(server_label="Lbl", connection_name="C", headers={"X": "1"}))
assert len(FakeTool.instances) == 1
assert FakeTool.instances[0].connect_count == 1
@pytest.mark.asyncio
async def test_header_name_case_collapses_to_one_cache_entry(self) -> None:
"""Header name spelling differences (case-only) must share a cache entry."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(headers={"Authorization": "tk"}))
await handler.invoke_tool(_invocation(headers={"authorization": "tk"}))
await handler.invoke_tool(_invocation(headers={"AUTHORIZATION": "tk"}))
assert len(FakeTool.instances) == 1
assert FakeTool.instances[0].connect_count == 1
@pytest.mark.asyncio
async def test_header_value_case_does_not_collapse(self) -> None:
"""Header *values* remain case-sensitive (different tokens → different sessions)."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(headers={"Authorization": "Bearer-A"}))
await handler.invoke_tool(_invocation(headers={"Authorization": "bearer-a"}))
assert len(FakeTool.instances) == 2
# ---------- Aclose semantics ----------------------------------------------
class TestAclose:
@pytest.mark.asyncio
async def test_aclose_closes_owned_clients(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(headers={"X": "1"}))
tool = FakeTool.instances[0]
owned = tool._httpx_client
assert owned is not None
await handler.aclose()
assert tool.close_count == 1
assert owned.is_closed
@pytest.mark.asyncio
async def test_aclose_does_not_close_caller_supplied_client(self) -> None:
caller_client = httpx.AsyncClient()
async def provider(_inv: MCPToolInvocation) -> httpx.AsyncClient:
return caller_client
handler = DefaultMCPToolHandler(client_provider=provider)
try:
with _patch_tool():
await handler.invoke_tool(_invocation(headers={"X": "1"}))
await handler.aclose()
assert FakeTool.instances[0].close_count == 1
# Caller client must still be usable.
assert not caller_client.is_closed
finally:
await caller_client.aclose()
@pytest.mark.asyncio
async def test_async_context_manager(self) -> None:
with _patch_tool():
async with DefaultMCPToolHandler() as handler:
await handler.invoke_tool(_invocation())
tool = FakeTool.instances[0]
assert tool.close_count == 1
@pytest.mark.asyncio
async def test_aclose_is_idempotent(self) -> None:
"""A second ``aclose`` is a no-op (no exception, no double-close)."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(headers={"X": "1"}))
await handler.aclose()
await handler.aclose()
assert FakeTool.instances[0].close_count == 1
@pytest.mark.asyncio
async def test_invoke_after_close_returns_error_result(self) -> None:
"""Post-close ``invoke_tool`` surfaces a tool error rather than crashing."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.aclose()
result = await handler.invoke_tool(_invocation())
assert result.is_error is True
assert "closed" in (result.error_message or "").lower()
@pytest.mark.asyncio
async def test_aclose_drains_inflight_creation(self) -> None:
"""An in-flight ``_create_entry`` must not leak when ``aclose`` races with it.
Reproduces the race described in PR #5630 review-comment 3:
task A claims an inflight future and starts a slow connect; task B
runs ``aclose``; task A must self-clean (close its tool + httpx
client) and surface a closed-handler error rather than orphaning
the entry.
"""
handler = DefaultMCPToolHandler()
connect_started = asyncio.Event()
release_connect = asyncio.Event()
original_connect = FakeTool.connect
async def gated_connect(self: FakeTool) -> None:
connect_started.set()
await release_connect.wait()
await original_connect(self)
with _patch_tool(), patch.object(FakeTool, "connect", gated_connect):
invoke_task = asyncio.create_task(handler.invoke_tool(_invocation(headers={"X": "1"})))
# Wait until task A is mid-connect.
await connect_started.wait()
# Race: kick off aclose. It must wait for the in-flight task.
close_task = asyncio.create_task(handler.aclose())
# Yield once to ensure aclose has set _closed and is awaiting.
await asyncio.sleep(0)
# Allow the connect to complete; phase 3 sees _closed and self-cleans.
release_connect.set()
result = await invoke_task
await close_task
# Entry was created and then closed by the in-flight task itself.
assert len(FakeTool.instances) == 1
assert FakeTool.instances[0].close_count == 1
# The originating invocation surfaces a closed-handler error.
assert result.is_error is True
assert "closed" in (result.error_message or "").lower()
# ---------- Result normalisation ------------------------------------------
class TestResultNormalisation:
@pytest.mark.asyncio
async def test_string_result_wrapped_in_text_content(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
inv = _invocation()
result = await handler.invoke_tool(inv)
# The fake's default already returns a list; replace handler for this test.
FakeTool.instances[0].call_handler = lambda **_a: "raw string body"
result = await handler.invoke_tool(inv)
assert result.is_error is False
assert len(result.outputs) == 1
assert result.outputs[0].text == "raw string body" # type: ignore[reportAttributeAccessIssue]
@pytest.mark.asyncio
async def test_list_result_passed_through(self) -> None:
handler = DefaultMCPToolHandler()
custom = [Content.from_text("a"), Content.from_text("b")]
with _patch_tool():
inv = _invocation()
await handler.invoke_tool(inv)
FakeTool.instances[0].call_handler = lambda **_a: custom
result = await handler.invoke_tool(inv)
assert result.is_error is False
assert len(result.outputs) == 2
# ---------- Error mapping --------------------------------------------------
class TestErrorMapping:
@pytest.mark.asyncio
async def test_tool_execution_exception_returns_error_result(self) -> None:
handler = DefaultMCPToolHandler()
def boom(**_a: Any) -> Any:
raise ToolExecutionException("server says no")
with _patch_tool():
inv = _invocation()
await handler.invoke_tool(inv)
FakeTool.instances[0].call_handler = boom
result = await handler.invoke_tool(inv)
assert result.is_error is True
assert result.error_message == "server says no"
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
assert text is not None
assert text.startswith("Error:")
@pytest.mark.asyncio
async def test_httpx_error_returns_error_result(self) -> None:
handler = DefaultMCPToolHandler()
def boom(**_a: Any) -> Any:
raise httpx.ConnectError("dns failure")
with _patch_tool():
inv = _invocation()
await handler.invoke_tool(inv)
FakeTool.instances[0].call_handler = boom
result = await handler.invoke_tool(inv)
assert result.is_error is True
assert "dns failure" in (result.error_message or "")
@pytest.mark.asyncio
async def test_unexpected_exception_propagates(self) -> None:
"""RuntimeError (not in the narrow catch list) must propagate."""
handler = DefaultMCPToolHandler()
def boom(**_a: Any) -> Any:
raise RuntimeError("programmer error")
with _patch_tool():
inv = _invocation()
await handler.invoke_tool(inv)
FakeTool.instances[0].call_handler = boom
with pytest.raises(RuntimeError, match="programmer error"):
await handler.invoke_tool(inv)
@pytest.mark.asyncio
async def test_connect_failure_returns_error_result(self) -> None:
handler = DefaultMCPToolHandler()
with (
_patch_tool(),
patch.object(
FakeTool,
"connect",
lambda self: (_ for _ in ()).throw(httpx.ConnectError("server down")),
),
):
result = await handler.invoke_tool(_invocation())
assert result.is_error is True
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
assert text is not None
assert text.startswith("Error:")
# Failed connect must clear in-flight + cache entries.
assert handler._inflight == {}
assert len(handler._cache) == 0
@pytest.mark.asyncio
async def test_cancelled_error_propagates(self) -> None:
"""asyncio.CancelledError is BaseException, must NOT be swallowed."""
handler = DefaultMCPToolHandler()
def boom(**_a: Any) -> Any:
raise asyncio.CancelledError
with _patch_tool():
inv = _invocation()
await handler.invoke_tool(inv)
FakeTool.instances[0].call_handler = boom
with pytest.raises(asyncio.CancelledError):
await handler.invoke_tool(inv)
# ---------- Cache key isolation -------------------------------------------
class TestCacheKey:
def test_key_order_independent(self) -> None:
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"A": "1", "B": "2"})
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"B": "2", "A": "1"})
assert k1 == k2
def test_key_distinguishes_values(self) -> None:
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"A": "1"})
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"A": "2"})
assert k1 != k2
def test_empty_headers_use_fixed_hash(self) -> None:
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, None)
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {})
assert k1 == k2
def test_key_distinguishes_connection_name(self) -> None:
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, "conn-A", None)
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, "conn-B", None)
assert k1 != k2
def test_key_distinguishes_server_label(self) -> None:
k1 = DefaultMCPToolHandler._cache_key("https://x/", "Lbl-A", None, None)
k2 = DefaultMCPToolHandler._cache_key("https://x/", "Lbl-B", None, None)
assert k1 != k2
def test_key_collapses_header_name_case(self) -> None:
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"Authorization": "tk"})
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"authorization": "tk"})
assert k1 == k2
def test_key_keeps_header_value_case(self) -> None:
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "Bearer-A"})
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "bearer-a"})
assert k1 != k2
# ---------- tools/list reserved name --------------------------------------
class TestListTools:
"""Exercise the reserved :attr:`DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME` interception path."""
@pytest.mark.asyncio
async def test_list_tools_returns_json_catalog(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
# Prime the cache so the FakeTool session exists.
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr] # ty: ignore[invalid-assignment]
FakeListToolsResult(
tools=[
FakeMcpTool(
name="search",
description="Search docs",
inputSchema={"type": "object", "properties": {"q": {"type": "string"}}},
outputSchema={"type": "object"},
),
FakeMcpTool(name="echo", description=None, outputSchema=None),
],
),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert result.is_error is False
assert len(result.outputs) == 1
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
assert text is not None
payload = json.loads(text)
assert payload == {
"tools": [
{
"name": "search",
"description": "Search docs",
"inputSchema": {"type": "object", "properties": {"q": {"type": "string"}}},
"outputSchema": {"type": "object"},
},
{
"name": "echo",
"description": None,
"inputSchema": {"type": "object", "properties": {}},
"outputSchema": None,
},
],
}
@pytest.mark.asyncio
async def test_list_tools_property_order_is_stable(self) -> None:
"""JSON property order is stable: name, description, inputSchema, outputSchema."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr] # ty: ignore[invalid-assignment]
FakeListToolsResult(tools=[FakeMcpTool(name="t1", description="d")]),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
assert text is not None
name_idx = text.find('"name"')
desc_idx = text.find('"description"')
input_idx = text.find('"inputSchema"')
output_idx = text.find('"outputSchema"')
assert 0 <= name_idx < desc_idx < input_idx < output_idx
@pytest.mark.asyncio
async def test_list_tools_indented_output(self) -> None:
"""Output is JSON with a 2-space indent so the conversation log is human-readable."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr] # ty: ignore[invalid-assignment]
FakeListToolsResult(tools=[FakeMcpTool(name="t1")]),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
assert text is not None
# Indented output contains newlines and a 2-space indented key.
assert "\n " in text
@pytest.mark.asyncio
async def test_list_tools_rejects_arguments(self) -> None:
"""Reserved name does NOT accept tool arguments. Fails fast before connect."""
handler = DefaultMCPToolHandler()
with _patch_tool():
result = await handler.invoke_tool(
_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME, arguments={"q": "test"}),
)
assert result.is_error is True
assert "does not accept tool arguments" in (result.error_message or "")
# Args validation runs before connect, so no tool was instantiated.
assert FakeTool.instances == []
@pytest.mark.asyncio
async def test_list_tools_empty_args_dict_is_accepted(self) -> None:
"""An empty arguments dict is equivalent to no arguments."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
result = await handler.invoke_tool(
_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME, arguments={}),
)
assert result.is_error is False
@pytest.mark.asyncio
async def test_list_tools_paginates(self) -> None:
"""Pagination loop calls list_tools repeatedly until nextCursor is empty."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr] # ty: ignore[invalid-assignment]
FakeListToolsResult(tools=[FakeMcpTool(name="a")], next_cursor="cursor1"),
FakeListToolsResult(tools=[FakeMcpTool(name="b")], next_cursor="cursor2"),
FakeListToolsResult(tools=[FakeMcpTool(name="c")], next_cursor=None),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
assert text is not None
payload = json.loads(text)
assert [t["name"] for t in payload["tools"]] == ["a", "b", "c"]
session = FakeTool.instances[0].session
assert session is not None
assert len(session.list_tools_calls) == 3
# First call has no cursor; second/third use the cursor from the prior page.
assert session.list_tools_calls[0] is None
assert getattr(session.list_tools_calls[1], "cursor", None) == "cursor1"
assert getattr(session.list_tools_calls[2], "cursor", None) == "cursor2"
@pytest.mark.asyncio
async def test_list_tools_shares_cache_with_call_tool(self) -> None:
"""tools/list reuses the same cached MCP session as a regular call_tool."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(tool_name="search"))
await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert len(FakeTool.instances) == 1
assert FakeTool.instances[0].connect_count == 1
@pytest.mark.asyncio
async def test_list_tools_propagates_session_errors_as_error_result(self) -> None:
"""Errors raised by session.list_tools become MCPToolResult(is_error=True), not crashes."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_error = httpx.ReadTimeout("read timed out") # type: ignore[union-attr] # ty: ignore[invalid-assignment]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert result.is_error is True
assert "ReadTimeout" in (result.error_message or "")
@pytest.mark.asyncio
async def test_list_tools_returns_error_when_session_is_none(self) -> None:
"""If somehow the cached tool has no session, return a clear error rather than crashing."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session = None
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert result.is_error is True
assert "not connected" in (result.error_message or "")
@pytest.mark.asyncio
async def test_list_tools_does_not_call_call_tool(self) -> None:
"""The reserved name is intercepted; the inner call_tool path is bypassed."""
handler = DefaultMCPToolHandler()
call_tool_invoked = False
def fail(**_a: Any) -> Any:
nonlocal call_tool_invoked
call_tool_invoked = True
raise AssertionError("call_tool should not run for tools/list")
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].call_handler = fail
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr] # ty: ignore[invalid-assignment]
FakeListToolsResult(tools=[]),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert call_tool_invoked is False
assert result.is_error is False
def test_class_attribute_value(self) -> None:
# Constant must equal the MCP protocol method name so a single
# string travels unchanged through host code, YAML, and the wire.
assert DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME == "tools/list"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,375 @@
# Copyright (c) Microsoft. All rights reserved.
"""Integration tests for declarative workflows.
These tests verify:
- End-to-end workflow execution
- Checkpointing at action boundaries
- WorkflowFactory creating graph-based workflows
- Pause/resume capabilities
"""
import sys
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = pytest.mark.skipif(
not _powerfx_available or sys.version_info >= (3, 14),
reason="PowerFx engine not available (requires dotnet runtime)",
)
from agent_framework_declarative._workflows import ( # noqa: E402
ActionTrigger,
DeclarativeWorkflowBuilder,
)
from agent_framework_declarative._workflows._factory import WorkflowFactory # noqa: E402
class TestGraphBasedWorkflowExecution:
"""Integration tests for graph-based workflow execution."""
@pytest.mark.asyncio
async def test_simple_sequential_workflow(self):
"""Test a simple sequential workflow with SendActivity actions."""
yaml_def = {
"name": "simple_workflow",
"actions": [
{"kind": "SendActivity", "id": "greet", "activity": {"text": "Hello!"}},
{"kind": "SetValue", "id": "set_count", "path": "Local.count", "value": 1},
{"kind": "SendActivity", "id": "done", "activity": {"text": "Done!"}},
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
workflow = builder.build()
# Run the workflow
events = await workflow.run(ActionTrigger())
# Verify outputs were produced
outputs = events.get_outputs()
assert "Hello!" in outputs
assert "Done!" in outputs
@pytest.mark.asyncio
async def test_workflow_with_conditional(self):
"""Test workflow with If conditional branching."""
yaml_def = {
"name": "conditional_workflow",
"actions": [
{"kind": "SetValue", "id": "set_flag", "path": "Local.flag", "value": True},
{
"kind": "If",
"id": "check_flag",
"condition": "=Local.flag",
"then": [
{"kind": "SendActivity", "id": "say_yes", "activity": {"text": "Flag is true!"}},
],
"else": [
{"kind": "SendActivity", "id": "say_no", "activity": {"text": "Flag is false!"}},
],
},
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
workflow = builder.build()
# Run the workflow
events = await workflow.run(ActionTrigger())
outputs = events.get_outputs()
# Should take the "then" branch since flag is True
assert "Flag is true!" in outputs
assert "Flag is false!" not in outputs
@pytest.mark.asyncio
async def test_workflow_with_foreach_loop(self):
"""Test workflow with Foreach loop."""
yaml_def = {
"name": "loop_workflow",
"actions": [
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["a", "b", "c"]},
{
"kind": "Foreach",
"id": "process_items",
"source": "=Local.items",
"itemName": "item",
"actions": [
{"kind": "SendActivity", "id": "show_item", "activity": {"text": "=Local.item"}},
],
},
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
workflow = builder.build()
# Run the workflow
events = await workflow.run(ActionTrigger())
outputs = events.get_outputs()
# Should output each item
assert "a" in outputs
assert "b" in outputs
assert "c" in outputs
@pytest.mark.asyncio
async def test_foreach_multi_action_body_runs_sequentially(self):
"""Body actions must complete per item before advancing."""
yaml_def = {
"name": "loop_sequential_body",
"actions": [
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A", "B"]},
{
"kind": "Foreach",
"id": "loop",
"source": "=Local.items",
"itemName": "item",
"actions": [
{"kind": "SendActivity", "id": "step_1", "activity": {"text": '="1-" & Local.item'}},
{"kind": "SendActivity", "id": "step_2", "activity": {"text": '="2-" & Local.item'}},
{"kind": "SendActivity", "id": "step_3", "activity": {"text": '="3-" & Local.item'}},
],
},
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
workflow = builder.build()
events = await workflow.run(ActionTrigger())
outputs = events.get_outputs()
assert outputs == ["1-A", "2-A", "3-A", "1-B", "2-B", "3-B"]
@pytest.mark.asyncio
async def test_workflow_with_condition_group(self):
"""Test workflow with ConditionGroup."""
yaml_def = {
"name": "condition_group_workflow",
"actions": [
{"kind": "SetValue", "id": "set_level", "path": "Local.level", "value": 2},
{
"kind": "ConditionGroup",
"id": "check_level",
"conditions": [
{
"condition": "=Local.level = 1",
"actions": [
{"kind": "SendActivity", "id": "level_1", "activity": {"text": "Level 1"}},
],
},
{
"condition": "=Local.level = 2",
"actions": [
{"kind": "SendActivity", "id": "level_2", "activity": {"text": "Level 2"}},
],
},
],
"elseActions": [
{"kind": "SendActivity", "id": "default", "activity": {"text": "Other level"}},
],
},
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
workflow = builder.build()
# Run the workflow
events = await workflow.run(ActionTrigger())
outputs = events.get_outputs()
# Should take the level 2 branch
assert "Level 2" in outputs
assert "Level 1" not in outputs
assert "Other level" not in outputs
class TestWorkflowFactory:
"""Tests for WorkflowFactory."""
def test_factory_creates_workflow(self):
"""Test creating workflow."""
factory = WorkflowFactory()
yaml_content = """
name: test_workflow
actions:
- kind: SendActivity
id: greet
activity:
text: "Hello from graph mode!"
- kind: SetValue
id: set_val
path: Local.result
value: 42
"""
workflow = factory.create_workflow_from_yaml(yaml_content)
assert workflow is not None
assert hasattr(workflow, "_declarative_agents")
@pytest.mark.asyncio
async def test_workflow_execution(self):
"""Test executing a workflow."""
factory = WorkflowFactory()
yaml_content = """
name: graph_execution_test
actions:
- kind: SendActivity
id: start
activity:
text: "Starting workflow"
- kind: SetValue
id: set_message
path: Local.message
value: "Hello World"
- kind: SendActivity
id: end
activity:
text: "Workflow complete"
"""
workflow = factory.create_workflow_from_yaml(yaml_content)
# Execute the workflow
events = await workflow.run(ActionTrigger())
outputs = events.get_outputs()
assert "Starting workflow" in outputs
assert "Workflow complete" in outputs
class TestGraphWorkflowCheckpointing:
"""Tests for checkpointing capabilities of graph-based workflows."""
def test_workflow_has_multiple_executors(self):
"""Test that graph-based workflow creates multiple executor nodes."""
yaml_def = {
"name": "multi_executor_workflow",
"actions": [
{"kind": "SetValue", "id": "step1", "path": "Local.a", "value": 1},
{"kind": "SetValue", "id": "step2", "path": "Local.b", "value": 2},
{"kind": "SetValue", "id": "step3", "path": "Local.c", "value": 3},
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
_workflow = builder.build() # noqa: F841
# Verify multiple executors were created (+ _workflow_entry node)
assert "step1" in builder._executors
assert "step2" in builder._executors
assert "step3" in builder._executors
assert len(builder._executors) == 4
def test_workflow_executor_connectivity(self):
"""Test that executors are properly connected in sequence."""
yaml_def = {
"name": "connected_workflow",
"actions": [
{"kind": "SendActivity", "id": "a", "activity": {"text": "A"}},
{"kind": "SendActivity", "id": "b", "activity": {"text": "B"}},
{"kind": "SendActivity", "id": "c", "activity": {"text": "C"}},
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
workflow = builder.build()
# Verify all executors exist (+ _workflow_entry node)
assert len(builder._executors) == 4
# Verify the workflow can be inspected
assert workflow is not None
class TestGraphWorkflowVisualization:
"""Tests for workflow visualization capabilities."""
def test_workflow_can_be_built(self):
"""Test that complex workflows can be built successfully."""
yaml_def = {
"name": "complex_workflow",
"actions": [
{"kind": "SendActivity", "id": "intro", "activity": {"text": "Starting"}},
{
"kind": "If",
"id": "branch",
"condition": "=true",
"then": [
{"kind": "SendActivity", "id": "then_msg", "activity": {"text": "Then branch"}},
],
"else": [
{"kind": "SendActivity", "id": "else_msg", "activity": {"text": "Else branch"}},
],
},
{"kind": "SendActivity", "id": "outro", "activity": {"text": "Done"}},
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
workflow = builder.build()
# Verify the workflow was built
assert workflow is not None
# Verify expected executors exist
# intro, branch_condition, then_msg, else_msg, branch_join, outro
assert "intro" in builder._executors
assert "then_msg" in builder._executors
assert "else_msg" in builder._executors
assert "outro" in builder._executors
class TestGraphWorkflowStateManagement:
"""Tests for state management across graph executor nodes."""
@pytest.mark.asyncio
async def test_state_persists_across_executors(self):
"""Test that state set in one executor is available in the next."""
yaml_def = {
"name": "state_test",
"actions": [
{"kind": "SetValue", "id": "set", "path": "Local.value", "value": "test_data"},
{"kind": "SendActivity", "id": "send", "activity": {"text": "=Local.value"}},
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
workflow = builder.build()
events = await workflow.run(ActionTrigger())
outputs = events.get_outputs()
# The SendActivity should have access to the value set by SetValue
assert "test_data" in outputs
@pytest.mark.asyncio
async def test_multiple_variables(self):
"""Test setting and using multiple variables."""
yaml_def = {
"name": "multi_var_test",
"actions": [
{"kind": "SetValue", "id": "set_a", "path": "Local.a", "value": "Hello"},
{"kind": "SetValue", "id": "set_b", "path": "Local.b", "value": "World"},
{"kind": "SendActivity", "id": "send", "activity": {"text": "=Local.a"}},
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
workflow = builder.build()
events = await workflow.run(ActionTrigger())
outputs = events.get_outputs()
assert "Hello" in outputs
@@ -0,0 +1,645 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for HttpRequestActionExecutor.
These tests use a stub HttpRequestHandler that returns canned HttpRequestResults.
No real network or httpx transports are exercised. See
test_default_http_request_handler.py for tests that exercise the real
DefaultHttpRequestHandler against httpx.MockTransport.
"""
from __future__ import annotations
import asyncio
import sys
from typing import Any
import httpx
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = pytest.mark.skipif(
not _powerfx_available or sys.version_info >= (3, 14),
reason="PowerFx engine not available (requires dotnet runtime)",
)
from agent_framework_declarative._workflows import ( # noqa: E402
DECLARATIVE_STATE_KEY,
DeclarativeActionError,
DeclarativeWorkflowError,
HttpRequestHandler,
HttpRequestInfo,
HttpRequestResult,
WorkflowFactory,
)
class StubHandler:
"""Test stub that records the last call and returns a canned result."""
def __init__(
self,
result: HttpRequestResult | None = None,
*,
raise_exc: BaseException | None = None,
) -> None:
self.result = result
self.raise_exc = raise_exc
self.last_info: HttpRequestInfo | None = None
self.call_count = 0
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
self.call_count += 1
self.last_info = info
if self.raise_exc is not None:
raise self.raise_exc
assert self.result is not None
return self.result
def _ok(body: str = "", headers: dict[str, list[str]] | None = None) -> HttpRequestResult:
return HttpRequestResult(
status_code=200,
is_success_status_code=True,
body=body,
headers=headers or {},
)
def _err(status: int = 500, body: str = "", headers: dict[str, list[str]] | None = None) -> HttpRequestResult:
return HttpRequestResult(
status_code=status,
is_success_status_code=False,
body=body,
headers=headers or {},
)
async def _run(yaml_def: dict[str, Any], handler: HttpRequestHandler) -> Any:
"""Build & run a workflow, returning final WorkflowState."""
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(yaml_def)
return await workflow.run({})
def _state(workflow: Any, events: Any) -> dict[str, Any]:
"""Read declarative state out of the workflow after run completes."""
return workflow._runner.state.get(DECLARATIVE_STATE_KEY) or {}
# Helper used by parametrised path tests
_TEST_URL = "https://api.example.test/items"
def _action(
*,
method: str | None = None,
url: str = _TEST_URL,
headers: dict[str, Any] | None = None,
query_parameters: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
response: Any = None,
response_headers: Any = None,
conversation_id: str | None = None,
request_timeout_ms: int | None = None,
connection: dict[str, Any] | None = None,
) -> dict[str, Any]:
action: dict[str, Any] = {
"kind": "HttpRequestAction",
"id": "http_action",
"url": url,
}
if method is not None:
action["method"] = method
if headers is not None:
action["headers"] = headers
if query_parameters is not None:
action["queryParameters"] = query_parameters
if body is not None:
action["body"] = body
if response is not None:
action["response"] = response
if response_headers is not None:
action["responseHeaders"] = response_headers
if conversation_id is not None:
action["conversationId"] = conversation_id
if request_timeout_ms is not None:
action["requestTimeoutInMilliseconds"] = request_timeout_ms
if connection is not None:
action["connection"] = connection
return action
def _yaml(action: dict[str, Any]) -> dict[str, Any]:
return {"name": "http_test", "actions": [action]}
# ---------- Success path: response parsing ----------------------------------
class TestSuccessPath:
@pytest.mark.asyncio
async def test_get_parses_json_object(self) -> None:
handler = StubHandler(_ok('{"key":"value","number":42}'))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(method="GET", response="Local.Result")))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == {"key": "value", "number": 42}
assert handler.last_info is not None
assert handler.last_info.method == "GET"
assert handler.last_info.url == _TEST_URL
@pytest.mark.asyncio
async def test_get_parses_plain_string(self) -> None:
handler = StubHandler(_ok("not-json content"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result")))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == "not-json content"
@pytest.mark.asyncio
async def test_get_empty_body_yields_none(self) -> None:
handler = StubHandler(_ok(""))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result")))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] is None
@pytest.mark.asyncio
async def test_response_object_form_path(self) -> None:
handler = StubHandler(_ok('{"x":1}'))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response={"path": "Local.Result"})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == {"x": 1}
@pytest.mark.asyncio
async def test_no_response_path_does_not_assign(self) -> None:
handler = StubHandler(_ok('{"x":1}'))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
# Should complete without error and without writing anything
await workflow.run({})
# ---------- Method / headers / query params --------------------------------
class TestRequestComposition:
@pytest.mark.asyncio
async def test_default_method_is_get(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.method == "GET"
@pytest.mark.asyncio
async def test_method_uppercased(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(method="post")))
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.method == "POST"
@pytest.mark.asyncio
async def test_headers_are_forwarded_and_empty_skipped(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
headers={
"Accept": "application/json",
"X-Empty": "",
"Authorization": "Bearer token",
}
)
)
)
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.headers == {
"Accept": "application/json",
"Authorization": "Bearer token",
}
@pytest.mark.asyncio
async def test_query_parameters_stringified(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
query_parameters={
"name": "alpha",
"limit": 10,
"active": True,
"ratio": 0.5,
"missing": None, # dropped
}
)
)
)
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.query_parameters == {
"name": "alpha",
"limit": "10",
"active": "true",
"ratio": "0.5",
}
# ---------- Body composition ------------------------------------------------
class TestBody:
@pytest.mark.asyncio
async def test_post_json_body_sets_content_type_and_serialises(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
method="POST",
body={"kind": "json", "content": {"k": "v", "n": 1}},
)
)
)
await workflow.run({})
info = handler.last_info
assert info is not None
assert info.body_content_type == "application/json"
assert info.body is not None
# JSON serialized, key order may vary
import json
assert json.loads(info.body) == {"k": "v", "n": 1}
@pytest.mark.asyncio
async def test_post_raw_body_uses_declared_content_type(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
method="POST",
body={
"kind": "raw",
"content": "raw body text",
"contentType": "text/plain",
},
)
)
)
await workflow.run({})
info = handler.last_info
assert info is not None
assert info.body == "raw body text"
assert info.body_content_type == "text/plain"
@pytest.mark.asyncio
async def test_post_raw_body_without_content_type_defaults_to_text_plain(self) -> None:
"""Match .NET RawRequestContent: no contentType => default text/plain.
Otherwise the request is sent without a Content-Type header which most
servers will treat as application/octet-stream and fail to parse.
"""
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
method="POST",
body={"kind": "raw", "content": "plain body"},
)
)
)
await workflow.run({})
info = handler.last_info
assert info is not None
assert info.body == "plain body"
assert info.body_content_type == "text/plain"
@pytest.mark.asyncio
async def test_long_form_body_kinds_accepted(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
method="POST",
body={"kind": "JsonRequestContent", "content": {"k": 1}},
)
)
)
await workflow.run({})
info = handler.last_info
assert info is not None
assert info.body_content_type == "application/json"
@pytest.mark.asyncio
async def test_unknown_body_kind_raises(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(body={"kind": "weirdform", "content": "x"})))
with pytest.raises(Exception) as excinfo:
await workflow.run({})
# Should surface as ValueError (potentially wrapped by runner)
msg = str(excinfo.value)
assert "weirdform" in msg or "unsupported value" in msg
@pytest.mark.asyncio
async def test_no_body_omitted(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
await workflow.run({})
info = handler.last_info
assert info is not None
assert info.body is None
assert info.body_content_type is None
# ---------- Non-2xx and error handling -------------------------------------
class TestErrorHandling:
@pytest.mark.asyncio
async def test_non_2xx_raises_declarative_action_error(self) -> None:
handler = StubHandler(_err(status=500, body="server exploded"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "500" in msg
assert "server exploded" in msg
@pytest.mark.asyncio
async def test_non_2xx_long_body_truncated(self) -> None:
big_body = "A" * 1000
handler = StubHandler(_err(status=500, body=big_body))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "[truncated]" in msg
assert len(msg) < 512
# Should NOT contain the full 1000-char body
assert big_body not in msg
@pytest.mark.asyncio
async def test_non_2xx_empty_body_omits_body_section(self) -> None:
handler = StubHandler(_err(status=404, body=""))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "404" in msg
assert "Body:" not in msg
@pytest.mark.asyncio
async def test_non_2xx_control_chars_collapsed(self) -> None:
handler = StubHandler(_err(status=500, body="line1\r\nline2\tlong"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "\r" not in msg
assert "\n" not in msg
assert "\t" not in msg
assert "line1 line2 long" in msg
@pytest.mark.asyncio
async def test_timeout_exception_becomes_declarative_action_error(self) -> None:
handler = StubHandler(raise_exc=httpx.TimeoutException("timeout"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
assert "timed out" in str(excinfo.value)
@pytest.mark.asyncio
async def test_stdlib_timeout_error_becomes_declarative_action_error(self) -> None:
handler = StubHandler(raise_exc=TimeoutError("clock"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
assert "timed out" in str(excinfo.value)
@pytest.mark.asyncio
async def test_transport_error_becomes_declarative_action_error(self) -> None:
handler = StubHandler(raise_exc=httpx.ConnectError("dns failure"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "failed" in msg
assert _TEST_URL in msg
@pytest.mark.asyncio
async def test_cancelled_error_propagates_unchanged(self) -> None:
"""CancelledError from the handler must propagate so cancellation works."""
handler = StubHandler(raise_exc=asyncio.CancelledError())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
# CancelledError is allowed to surface as either CancelledError or as
# the runner's wrapped form, but it MUST NOT be DeclarativeActionError.
with pytest.raises(BaseException) as excinfo:
await workflow.run({})
assert not isinstance(excinfo.value, DeclarativeActionError)
@pytest.mark.asyncio
async def test_generic_exception_from_custom_handler_wrapped(self) -> None:
"""A custom handler raising a non-httpx Exception must be wrapped.
Authors can plug in custom HttpRequestHandler implementations that use
any transport (requests-like clients, gRPC bridges, mock test doubles,
etc.). The executor must wrap arbitrary Exception subclasses uniformly
so that workflow error handling stays consistent across transports.
"""
handler = StubHandler(raise_exc=RuntimeError("custom transport blew up"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "failed" in msg
assert "RuntimeError" in msg
assert _TEST_URL in msg
# ---------- Response headers ------------------------------------------------
class TestResponseHeaders:
@pytest.mark.asyncio
async def test_response_headers_folded_with_commas(self) -> None:
handler = StubHandler(
_ok(
"ok",
headers={
"Content-Type": ["application/json"],
"Set-Cookie": ["a=1", "b=2"],
},
)
)
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
h = decl["Local"]["H"]
assert h["Content-Type"] == "application/json"
assert h["Set-Cookie"] == "a=1,b=2"
@pytest.mark.asyncio
async def test_response_headers_empty_assigned_none(self) -> None:
handler = StubHandler(_ok("ok", headers={}))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["H"] is None
@pytest.mark.asyncio
async def test_non_2xx_still_publishes_headers(self) -> None:
handler = StubHandler(_err(status=500, body="boom", headers={"X-Trace": ["abc"]}))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
with pytest.raises(DeclarativeActionError):
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["H"] == {"X-Trace": "abc"}
# ---------- ConversationId append -------------------------------------------
class TestConversationAppend:
@pytest.mark.asyncio
async def test_conversation_id_appends_message(self) -> None:
handler = StubHandler(_ok('{"answer":"hello"}'))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
response="Local.Result",
conversation_id="conv-test-1",
)
)
)
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
conv = decl["System"]["conversations"].get("conv-test-1")
assert conv is not None
assert len(conv["messages"]) == 1
@pytest.mark.asyncio
async def test_empty_conversation_id_does_not_append(self) -> None:
handler = StubHandler(_ok('{"answer":"hello"}'))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result", conversation_id="")))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
# Auto-init creates an entry for the System.ConversationId conversation,
# but it should NOT have HTTP-appended messages from us.
for _cid, conv in decl["System"]["conversations"].items():
assert conv["messages"] == []
@pytest.mark.asyncio
async def test_empty_body_skips_conversation_append(self) -> None:
handler = StubHandler(_ok(""))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(conversation_id="conv-test-1")))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
# No conversation entry should have been created either.
assert "conv-test-1" not in decl["System"]["conversations"]
# ---------- Connection name -------------------------------------------------
class TestConnection:
@pytest.mark.asyncio
async def test_connection_name_forwarded(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(connection={"name": "my-connection"})))
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.connection_name == "my-connection"
# ---------- Build-time validation -------------------------------------------
class TestBuildTimeValidation:
def test_missing_url_fails_validation(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
bad = {
"name": "no_url",
"actions": [{"kind": "HttpRequestAction", "id": "x"}],
}
with pytest.raises(DeclarativeWorkflowError):
factory.create_workflow_from_definition(bad)
def test_missing_handler_fails_at_build(self) -> None:
factory = WorkflowFactory() # no handler
with pytest.raises(DeclarativeWorkflowError) as excinfo:
factory.create_workflow_from_definition(_yaml(_action()))
assert "http_request_handler" in str(excinfo.value)
# ---------- Timeout forwarding ----------------------------------------------
class TestTimeout:
@pytest.mark.asyncio
async def test_timeout_ms_forwarded(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(request_timeout_ms=2500)))
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.timeout_ms == 2500
@pytest.mark.asyncio
async def test_timeout_ms_zero_treated_as_unset(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(request_timeout_ms=0)))
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.timeout_ms is None
@@ -0,0 +1,111 @@
# Copyright (c) Microsoft. All rights reserved.
"""End-to-end YAML integration test for ``HttpRequestAction``.
Loads the ``tests/workflows/http_request.yaml`` fixture (parity with the .NET
integration fixture) through ``WorkflowFactory.create_workflow_from_yaml_path``
with a stub :class:`HttpRequestHandler` and asserts state is populated.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = [
pytest.mark.skipif(
not _powerfx_available,
reason="powerfx not available — declarative workflows require it.",
),
pytest.mark.skipif(
sys.version_info >= (3, 14),
reason="Skipped on Python 3.14+ to keep parity with declarative suite.",
),
]
from agent_framework_declarative import WorkflowFactory # noqa: E402
from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY # noqa: E402
from agent_framework_declarative._workflows._http_handler import ( # noqa: E402
HttpRequestInfo,
HttpRequestResult,
)
FIXTURE_PATH = Path(__file__).parent / "workflows" / "http_request.yaml"
class _StubHandler:
"""Test double that records requests and returns a canned response."""
def __init__(self, result: HttpRequestResult) -> None:
self._result = result
self.received: list[HttpRequestInfo] = []
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
self.received.append(info)
return self._result
@pytest.mark.asyncio
async def test_http_request_yaml_roundtrip() -> None:
handler = _StubHandler(
HttpRequestResult(
status_code=200,
is_success_status_code=True,
body='{"name": "runtime", "visibility": "public", "stars": 12345}',
headers={
"content-type": ["application/json"],
"x-ratelimit-remaining": ["59"],
},
)
)
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_yaml_path(FIXTURE_PATH)
await workflow.run({})
decl: dict[str, Any] = workflow._runner.state.get(DECLARATIVE_STATE_KEY) or {}
local: dict[str, Any] = decl.get("Local") or {}
assert local.get("RepoOwner") == "dotnet"
repo_info = local.get("RepoInfo")
assert isinstance(repo_info, dict), f"Expected dict body, got {type(repo_info)!r}"
assert repo_info["name"] == "runtime"
assert repo_info["visibility"] == "public"
assert repo_info["stars"] == 12345
repo_headers = local.get("RepoHeaders")
assert isinstance(repo_headers, dict)
# Single-value header surfaces as plain string.
assert repo_headers.get("content-type") == "application/json"
assert repo_headers.get("x-ratelimit-remaining") == "59"
# Stub got the right call.
assert len(handler.received) == 1
sent = handler.received[0]
assert sent.method == "GET"
assert sent.url == "https://api.github.com/repos/dotnet/runtime"
assert sent.headers["Accept"] == "application/vnd.github+json"
assert sent.headers["User-Agent"] == "agent-framework-integration-test"
@pytest.mark.asyncio
async def test_http_request_yaml_missing_handler_fails_at_build_time() -> None:
"""Without an http_request_handler, building the workflow must raise."""
from agent_framework_declarative._workflows._errors import DeclarativeWorkflowError
factory = WorkflowFactory() # no handler configured
with pytest.raises(DeclarativeWorkflowError) as excinfo:
factory.create_workflow_from_yaml_path(FIXTURE_PATH)
msg = str(excinfo.value)
assert "HttpRequestAction" in msg
assert "http_request_handler" in msg
@@ -0,0 +1,631 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for ``InvokeMcpToolActionExecutor``.
Use a stub :class:`MCPToolHandler` that returns canned :class:`MCPToolResult`s.
No real MCP server or network is exercised. See
``test_default_mcp_tool_handler.py`` for tests that exercise the real
``DefaultMCPToolHandler`` against a mocked ``MCPStreamableHTTPTool``.
"""
import sys
from typing import Any
import httpx
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = pytest.mark.skipif(
not _powerfx_available or sys.version_info >= (3, 14),
reason="PowerFx engine not available (requires dotnet runtime)",
)
from agent_framework import Content, Message # noqa: E402
from agent_framework.exceptions import ToolExecutionException # noqa: E402
from agent_framework_declarative._workflows import ( # noqa: E402
DECLARATIVE_STATE_KEY,
DeclarativeWorkflowError,
MCPToolHandler,
MCPToolInvocation,
MCPToolResult,
WorkflowFactory,
)
class StubMcpHandler:
"""Test stub recording the last call and returning a canned result."""
def __init__(
self,
result: MCPToolResult | None = None,
*,
raise_exc: BaseException | None = None,
) -> None:
self.result = result
self.raise_exc = raise_exc
self.last_invocation: MCPToolInvocation | None = None
self.invocations: list[MCPToolInvocation] = []
self.call_count = 0
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
self.call_count += 1
self.last_invocation = invocation
self.invocations.append(invocation)
if self.raise_exc is not None:
raise self.raise_exc
assert self.result is not None
return self.result
def _ok(outputs: list[Content] | None = None) -> MCPToolResult:
return MCPToolResult(outputs=outputs or [Content.from_text("hello")])
def _err(message: str = "boom") -> MCPToolResult:
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
def _action(
*,
server_url: str = "https://mcp.example/api",
tool_name: str = "search",
server_label: str | None = None,
arguments: dict[str, Any] | None = None,
headers: dict[str, Any] | None = None,
require_approval: Any = None,
connection: dict[str, Any] | None = None,
conversation_id: str | None = None,
output: dict[str, Any] | None = None,
) -> dict[str, Any]:
action: dict[str, Any] = {
"kind": "InvokeMcpTool",
"id": "mcp_action",
"serverUrl": server_url,
"toolName": tool_name,
}
if server_label is not None:
action["serverLabel"] = server_label
if arguments is not None:
action["arguments"] = arguments
if headers is not None:
action["headers"] = headers
if require_approval is not None:
action["requireApproval"] = require_approval
if connection is not None:
action["connection"] = connection
if conversation_id is not None:
action["conversationId"] = conversation_id
if output is not None:
action["output"] = output
return action
def _yaml(action: dict[str, Any]) -> dict[str, Any]:
return {"name": "mcp_test", "actions": [action]}
# ---------- Builder enforcement --------------------------------------------
class TestBuilderEnforcement:
def test_missing_handler_raises_at_build_time(self) -> None:
factory = WorkflowFactory()
with pytest.raises(DeclarativeWorkflowError) as excinfo:
factory.create_workflow_from_definition(_yaml(_action()))
assert "InvokeMcpTool" in str(excinfo.value)
assert "mcp_tool_handler" in str(excinfo.value)
def test_missing_server_url_fails_validation(self) -> None:
handler = StubMcpHandler(_ok())
factory = WorkflowFactory(mcp_tool_handler=handler)
action = _action()
del action["serverUrl"]
with pytest.raises(Exception) as excinfo:
factory.create_workflow_from_definition(_yaml(action))
assert "serverUrl" in str(excinfo.value)
def test_missing_tool_name_fails_validation(self) -> None:
handler = StubMcpHandler(_ok())
factory = WorkflowFactory(mcp_tool_handler=handler)
action = _action()
del action["toolName"]
with pytest.raises(Exception) as excinfo:
factory.create_workflow_from_definition(_yaml(action))
assert "toolName" in str(excinfo.value)
# ---------- Field forwarding ----------------------------------------------
class TestFieldForwarding:
@pytest.mark.asyncio
async def test_basic_invocation_forwards_required_fields(self) -> None:
handler = StubMcpHandler(_ok())
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
await workflow.run({})
assert handler.call_count == 1
inv = handler.last_invocation
assert inv is not None
assert inv.server_url == "https://mcp.example/api"
assert inv.tool_name == "search"
assert inv.server_label is None
assert inv.headers == {}
assert inv.arguments == {}
assert inv.connection_name is None
@pytest.mark.asyncio
async def test_arguments_evaluated_and_preserves_none(self) -> None:
handler = StubMcpHandler(_ok())
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
arguments={
"query": "weather today",
"limit": 5,
"fresh": True,
"missing": None,
}
)
)
)
await workflow.run({})
inv = handler.last_invocation
assert inv is not None
# ``None`` is preserved (parity with .NET) — caller decides.
assert inv.arguments == {
"query": "weather today",
"limit": 5,
"fresh": True,
"missing": None,
}
@pytest.mark.asyncio
async def test_headers_drop_empty_values(self) -> None:
handler = StubMcpHandler(_ok())
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
headers={
"Authorization": "Bearer token-123",
"X-Trace": "trace-id",
"X-Empty": "",
}
)
)
)
await workflow.run({})
inv = handler.last_invocation
assert inv is not None
assert inv.headers == {
"Authorization": "Bearer token-123",
"X-Trace": "trace-id",
}
@pytest.mark.asyncio
async def test_server_label_and_connection_name_forwarded(self) -> None:
handler = StubMcpHandler(_ok())
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
server_label="docs-mcp",
connection={"name": "azure-conn"},
)
)
)
await workflow.run({})
inv = handler.last_invocation
assert inv is not None
assert inv.server_label == "docs-mcp"
assert inv.connection_name == "azure-conn"
# ---------- Output handling ------------------------------------------------
class TestOutput:
@pytest.mark.asyncio
async def test_output_result_parses_json_text(self) -> None:
handler = StubMcpHandler(_ok([Content.from_text('{"k":"v","n":1}')]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == [{"k": "v", "n": 1}]
@pytest.mark.asyncio
async def test_output_result_falls_back_to_raw_text(self) -> None:
handler = StubMcpHandler(_ok([Content.from_text("plain text not json")]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == ["plain text not json"]
@pytest.mark.asyncio
async def test_output_messages_writes_single_tool_role_message(self) -> None:
handler = StubMcpHandler(_ok([Content.from_text("hi"), Content.from_text("there")]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"messages": "Local.Messages"})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
msg = decl["Local"]["Messages"]
# Single Tool-role message containing both contents (parity with .NET).
assert isinstance(msg, Message)
assert str(msg.role).lower() == "tool"
assert len(msg.contents) == 2
@pytest.mark.asyncio
async def test_uri_content_serialised_as_uri_string(self) -> None:
uri_content = Content.from_uri("https://example.com/file.txt", media_type="text/plain")
handler = StubMcpHandler(_ok([uri_content]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == ["https://example.com/file.txt"]
@pytest.mark.asyncio
async def test_output_path_object_form(self) -> None:
handler = StubMcpHandler(_ok([Content.from_text("ok")]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": {"path": "Local.Result"}})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == ["ok"]
# ---------- Conversation append --------------------------------------------
class TestConversation:
@pytest.mark.asyncio
async def test_conversation_id_appends_assistant_message(self) -> None:
handler = StubMcpHandler(_ok([Content.from_text("answer")]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
conversation_id="conv-42",
output={"result": "Local.Result"},
)
)
)
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
conv = decl["System"]["conversations"]["conv-42"]
msgs = conv["messages"] if isinstance(conv, dict) else conv.messages
assert len(msgs) == 1
appended = msgs[0]
assert str(appended.role).lower() == "assistant"
# Same contents as the tool output.
assert len(appended.contents) == 1
@pytest.mark.asyncio
async def test_empty_conversation_id_does_not_append(self) -> None:
handler = StubMcpHandler(_ok([Content.from_text("answer")]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
conversation_id="",
output={"result": "Local.Result"},
)
)
)
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
# Empty conversation id must not produce a `""` entry under System.conversations.
conversations = decl.get("System", {}).get("conversations", {})
assert "" not in conversations
# ---------- Approval flow --------------------------------------------------
@pytest.fixture
def mock_state(): # type: ignore[no-untyped-def]
from unittest.mock import MagicMock
state = MagicMock()
state._data = {}
def _get(key: str, default: Any = None) -> Any:
if key not in state._data:
if default is not None:
return default
raise KeyError(key)
return state._data[key]
def _set(key: str, value: Any) -> None:
state._data[key] = value
def _delete(key: str) -> None:
if key in state._data:
del state._data[key]
else:
raise KeyError(key)
state.get = MagicMock(side_effect=_get)
state.set = MagicMock(side_effect=_set)
state.delete = MagicMock(side_effect=_delete)
return state
@pytest.fixture
def mock_context(mock_state): # type: ignore[no-untyped-def]
from unittest.mock import AsyncMock, MagicMock
ctx = MagicMock()
ctx.state = mock_state
ctx.send_message = AsyncMock()
ctx.yield_output = AsyncMock()
ctx.request_info = AsyncMock()
return ctx
def _seed_state(mock_state) -> None: # type: ignore[no-untyped-def]
"""Pre-seed the declarative state container as the executors expect."""
from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY
mock_state._data[DECLARATIVE_STATE_KEY] = {
"Local": {},
"Custom": {},
"Workflow": {},
"System": {
"ConversationId": "00000000-0000-0000-0000-000000000000",
"LastMessage": {"Id": "", "Text": ""},
"LastMessageText": "",
"LastMessageId": "",
},
"Agent": {},
"Conversation": {"messages": [], "history": []},
"Inputs": {},
}
class TestApprovalFlow:
@pytest.mark.asyncio
async def test_approval_required_emits_request_and_yields(self, mock_state, mock_context) -> None: # type: ignore[no-untyped-def]
from agent_framework_declarative._workflows._declarative_base import ActionTrigger
from agent_framework_declarative._workflows._executors_mcp import (
InvokeMcpToolActionExecutor,
MCPToolApprovalRequest,
)
_seed_state(mock_state)
handler = StubMcpHandler(_ok())
executor = InvokeMcpToolActionExecutor(
_action(
require_approval=True,
arguments={"q": "x"},
headers={"Authorization": "Bearer SECRET"},
output={"result": "Local.Result"},
),
mcp_tool_handler=handler,
)
await executor.handle_action(ActionTrigger(), mock_context)
# Approval request emitted.
mock_context.request_info.assert_called_once()
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, MCPToolApprovalRequest)
assert request.tool_name == "search"
assert request.arguments == {"q": "x"}
assert request.header_names == ["Authorization"]
# NEVER expose the actual auth token in any field of the approval payload.
for value in request.__dict__.values():
assert "SECRET" not in str(value)
# Workflow should yield (no ActionComplete sent yet).
mock_context.send_message.assert_not_called()
# Handler not invoked yet.
assert handler.call_count == 0
@pytest.mark.asyncio
async def test_approval_response_approved_invokes_handler(self, mock_state, mock_context) -> None: # type: ignore[no-untyped-def]
from agent_framework_declarative._workflows import ActionComplete, ToolApprovalResponse
from agent_framework_declarative._workflows._executors_mcp import (
InvokeMcpToolActionExecutor,
MCPToolApprovalRequest,
)
_seed_state(mock_state)
handler = StubMcpHandler(_ok([Content.from_text('{"ok":true}')]))
executor = InvokeMcpToolActionExecutor(
_action(
require_approval=True,
headers={"Authorization": "Bearer tk"},
output={"result": "Local.Result"},
),
mcp_tool_handler=handler,
)
await executor.handle_approval_response(
MCPToolApprovalRequest(
request_id="req-1",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={"q": "x"},
),
ToolApprovalResponse(approved=True),
mock_context,
)
assert handler.call_count == 1
inv = handler.last_invocation
assert inv is not None
# Invocation fields source from the approval request payload.
assert inv.tool_name == "search"
assert inv.server_url == "https://mcp.example/api"
assert inv.arguments == {"q": "x"}
# Headers are re-evaluated from the action definition on resume.
assert inv.headers == {"Authorization": "Bearer tk"}
# ActionComplete was sent.
mock_context.send_message.assert_called_once()
sent = mock_context.send_message.call_args[0][0]
assert isinstance(sent, ActionComplete)
@pytest.mark.asyncio
async def test_approval_response_rejected_assigns_error(self, mock_state, mock_context) -> None: # type: ignore[no-untyped-def]
from agent_framework_declarative._workflows import ToolApprovalResponse
from agent_framework_declarative._workflows._executors_mcp import (
InvokeMcpToolActionExecutor,
MCPToolApprovalRequest,
)
_seed_state(mock_state)
handler = StubMcpHandler(_ok())
executor = InvokeMcpToolActionExecutor(
_action(
require_approval=True,
output={"result": "Local.Result"},
),
mcp_tool_handler=handler,
)
await executor.handle_approval_response(
MCPToolApprovalRequest(
request_id="req-2",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={},
),
ToolApprovalResponse(approved=False, reason="not authorized"),
mock_context,
)
assert handler.call_count == 0
# Error string assigned at output.result.
from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY
result = mock_state._data[DECLARATIVE_STATE_KEY]["Local"]["Result"]
assert result == "Error: MCP tool invocation was not approved by user."
# ---------- Error handling -------------------------------------------------
class TestErrorHandling:
@pytest.mark.asyncio
async def test_handler_returns_error_result_assigns_error_string(self) -> None:
handler = StubMcpHandler(_err("server down"))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == "Error: server down"
@pytest.mark.asyncio
async def test_tool_execution_exception_becomes_error_result(self) -> None:
handler = StubMcpHandler(raise_exc=ToolExecutionException("invalid arguments"))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == "Error: invalid arguments"
@pytest.mark.asyncio
async def test_httpx_error_becomes_error_result(self) -> None:
handler = StubMcpHandler(raise_exc=httpx.ConnectError("dns fail"))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
result = decl["Local"]["Result"]
assert isinstance(result, str)
assert result.startswith("Error:")
assert "ConnectError" in result
@pytest.mark.asyncio
async def test_unexpected_exception_propagates(self) -> None:
"""Programmer bugs (TypeError etc.) must NOT be swallowed."""
handler = StubMcpHandler(raise_exc=TypeError("bad type"))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(Exception) as excinfo:
await workflow.run({})
# Either the TypeError reaches us or it gets wrapped by the runner —
# either way the message must surface.
assert "bad type" in str(excinfo.value)
# ---------- autoSend -------------------------------------------------------
class TestAutoSend:
@pytest.mark.asyncio
async def test_auto_send_default_true_yields_output(self) -> None:
handler = StubMcpHandler(_ok([Content.from_text("hello")]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
events = await workflow.run({})
outputs = events.get_outputs()
assert len(outputs) == 1
@pytest.mark.asyncio
async def test_auto_send_false_suppresses_yield(self) -> None:
handler = StubMcpHandler(_ok([Content.from_text("hello")]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"autoSend": False})))
events = await workflow.run({})
outputs = events.get_outputs()
assert outputs == []
# ---------- Protocol structure --------------------------------------------
class TestProtocol:
def test_stub_handler_satisfies_protocol(self) -> None:
handler = StubMcpHandler(_ok())
assert isinstance(handler, MCPToolHandler)
# ---------- _format_outputs_for_send --------------------------------------
class TestFormatOutputsForSend:
"""Direct tests for the auto-send rendering helper.
Regression for PR #5630 review-comment 4: a single scalar JSON value
must render bare (e.g. ``"42"``) rather than wrapped (``"[42]"``).
"""
@pytest.mark.parametrize(
("parsed", "expected"),
[
([], ""),
(["hello"], "hello"),
(["a", "b"], "a\nb"),
([42], "42"),
([3.14], "3.14"),
([True], "true"),
([False], "false"),
([None], "null"),
([{"k": "v"}], '{"k": "v"}'),
([[1, 2]], "[1, 2]"),
(["hello", 42], '["hello", 42]'),
([{"a": 1}, {"b": 2}], '[{"a": 1}, {"b": 2}]'),
],
)
def test_format_outputs_for_send(self, parsed: list[Any], expected: str) -> None:
from agent_framework_declarative._workflows._executors_mcp import _format_outputs_for_send
assert _format_outputs_for_send(parsed) == expected
@@ -0,0 +1,682 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for custom PowerFx-like functions."""
from typing import cast
from agent_framework_declarative._workflows._powerfx_functions import (
CUSTOM_FUNCTIONS,
assistant_message,
concat_text,
count_rows,
find,
first,
is_blank,
last,
lower,
message_text,
search_table,
system_message,
upper,
user_message,
)
class TestMessageText:
"""Tests for MessageText function."""
def test_message_text_from_string(self):
"""Test extracting text from a plain string."""
assert message_text("Hello") == "Hello"
def test_message_text_from_single_dict(self):
"""Test extracting text from a single message dict."""
msg = {"role": "assistant", "content": "Hello world"}
assert message_text(msg) == "Hello world"
def test_message_text_from_list(self):
"""Test extracting text from a list of messages."""
msgs = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello"},
]
assert message_text(msgs) == "Hi Hello"
def test_message_text_from_none(self):
"""Test that None returns empty string."""
assert message_text(None) == ""
def test_message_text_empty_list(self):
"""Test that empty list returns empty string."""
assert message_text([]) == ""
class TestUserMessage:
"""Tests for UserMessage function."""
def test_user_message_creates_dict(self):
"""Test that UserMessage creates correct dict."""
msg = user_message("Hello")
assert msg == {"role": "user", "content": "Hello"}
def test_user_message_with_none(self):
"""Test UserMessage with None."""
msg = user_message(cast("str", None))
assert msg == {"role": "user", "content": ""}
class TestAssistantMessage:
"""Tests for AssistantMessage function."""
def test_assistant_message_creates_dict(self):
"""Test that AssistantMessage creates correct dict."""
msg = assistant_message("Hello")
assert msg == {"role": "assistant", "content": "Hello"}
class TestSystemMessage:
"""Tests for SystemMessage function."""
def test_system_message_creates_dict(self):
"""Test that SystemMessage creates correct dict."""
msg = system_message("You are helpful")
assert msg == {"role": "system", "content": "You are helpful"}
class TestIsBlank:
"""Tests for IsBlank function."""
def test_is_blank_none(self):
"""Test that None is blank."""
assert is_blank(None) is True
def test_is_blank_empty_string(self):
"""Test that empty string is blank."""
assert is_blank("") is True
def test_is_blank_whitespace(self):
"""Test that whitespace-only string is blank."""
assert is_blank(" ") is True
def test_is_blank_empty_list(self):
"""Test that empty list is blank."""
assert is_blank([]) is True
def test_is_blank_non_empty(self):
"""Test that non-empty values are not blank."""
assert is_blank("hello") is False
assert is_blank([1, 2, 3]) is False
assert is_blank(0) is False
class TestCountRows:
"""Tests for CountRows function."""
def test_count_rows_list(self):
"""Test counting list items."""
assert count_rows([1, 2, 3]) == 3
def test_count_rows_empty(self):
"""Test counting empty list."""
assert count_rows([]) == 0
def test_count_rows_none(self):
"""Test counting None."""
assert count_rows(None) == 0
class TestFirstLast:
"""Tests for First and Last functions."""
def test_first_returns_first_item(self):
"""Test that First returns first item."""
assert first([1, 2, 3]) == 1
def test_last_returns_last_item(self):
"""Test that Last returns last item."""
assert last([1, 2, 3]) == 3
def test_first_empty_returns_none(self):
"""Test that First returns None for empty list."""
assert first([]) is None
def test_last_empty_returns_none(self):
"""Test that Last returns None for empty list."""
assert last([]) is None
class TestFind:
"""Tests for Find function."""
def test_find_substring(self):
"""Test finding a substring."""
result = find("world", "Hello world")
assert result == 7 # 1-based index
def test_find_not_found(self):
"""Test when substring not found - returns Blank (None) per PowerFx semantics."""
result = find("xyz", "Hello world")
assert result is None
def test_find_at_start(self):
"""Test finding at start of string."""
result = find("Hello", "Hello world")
assert result == 1
class TestUpperLower:
"""Tests for Upper and Lower functions."""
def test_upper(self):
"""Test uppercase conversion."""
assert upper("hello") == "HELLO"
def test_lower(self):
"""Test lowercase conversion."""
assert lower("HELLO") == "hello"
def test_upper_none(self):
"""Test upper with None."""
assert upper(None) == ""
class TestConcatText:
"""Tests for Concat function."""
def test_concat_simple_list(self):
"""Test concatenating simple list."""
assert concat_text(["a", "b", "c"], separator=", ") == "a, b, c"
def test_concat_with_field(self):
"""Test concatenating with field extraction."""
items = [{"name": "Alice"}, {"name": "Bob"}]
assert concat_text(items, field="name", separator=", ") == "Alice, Bob"
class TestSearchTable:
"""Tests for Search function."""
def test_search_finds_matching(self):
"""Test search finds matching items."""
items = [
{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25},
{"name": "Charlie", "age": 35},
]
result = search_table(items, "Bob", "name")
assert len(result) == 1
assert result[0]["name"] == "Bob"
def test_search_case_insensitive(self):
"""Test search is case insensitive."""
items = [{"name": "Alice"}]
result = search_table(items, "alice", "name")
assert len(result) == 1
def test_search_partial_match(self):
"""Test search finds partial matches."""
items = [{"name": "Alice Smith"}, {"name": "Bob Jones"}]
result = search_table(items, "Smith", "name")
assert len(result) == 1
class TestCustomFunctionsRegistry:
"""Tests for the CUSTOM_FUNCTIONS registry."""
def test_all_functions_registered(self):
"""Test that all functions are in the registry."""
expected = [
"MessageText",
"UserMessage",
"AssistantMessage",
"SystemMessage",
"IsBlank",
"CountRows",
"First",
"Last",
"Find",
"Upper",
"Lower",
"Concat",
"Search",
"If",
"Or",
"And",
"Not",
"AgentMessage",
"ForAll",
]
for name in expected:
assert name in CUSTOM_FUNCTIONS
class TestMessageTextEdgeCases:
"""Additional tests for message_text edge cases."""
def test_message_text_dict_with_text_attr_content(self):
"""Test message with content that has text attribute."""
class ContentWithText: # noqa: B903
def __init__(self, text: str):
self.text = text
msg = {"role": "assistant", "content": ContentWithText("Hello from text attr")}
assert message_text(msg) == "Hello from text attr"
def test_message_text_dict_content_non_string(self):
"""Test message with non-string content."""
msg = {"role": "assistant", "content": 42}
assert message_text(msg) == "42"
def test_message_text_list_with_string_items(self):
"""Test message_text with list of strings."""
result = message_text(["Hello", "World"])
assert result == "Hello World"
def test_message_text_list_with_content_objects(self):
"""Test message_text with list items having content attribute."""
class MessageObj: # noqa: B903
def __init__(self, content: str):
self.content = content
msgs = [MessageObj("Hello"), MessageObj("World")]
result = message_text(msgs)
assert result == "Hello World"
def test_message_text_list_with_content_text_attr(self):
"""Test message_text with content having text attribute."""
class ContentWithText: # noqa: B903
def __init__(self, text: str):
self.text = text
class MessageObj:
def __init__(self, content):
self.content = content
msgs = [MessageObj(ContentWithText("Part1")), MessageObj(ContentWithText("Part2"))]
result = message_text(msgs)
assert result == "Part1 Part2"
def test_message_text_list_with_non_string_content(self):
"""Test message_text with non-string content in dicts."""
msgs = [{"content": 123}, {"content": 456}]
result = message_text(msgs)
assert result == "123 456"
def test_message_text_object_with_text_attr(self):
"""Test message_text with object having text attribute."""
class ObjWithText:
text = "Direct text"
result = message_text(ObjWithText())
assert result == "Direct text"
def test_message_text_object_with_content_attr(self):
"""Test message_text with object having content attribute."""
class ObjWithContent:
content = "Direct content"
result = message_text(ObjWithContent())
assert result == "Direct content"
def test_message_text_object_with_non_string_content(self):
"""Test message_text with object having non-string content."""
class ObjWithContent:
content = None
result = message_text(ObjWithContent())
assert result == ""
def test_message_text_list_with_empty_content_object(self):
"""Test message with content object that evaluates to empty."""
class MessageObj:
content = None
result = message_text([MessageObj()])
assert result == ""
class TestAgentMessage:
"""Tests for agent_message function."""
def test_agent_message_creates_dict(self):
"""Test that AgentMessage creates correct dict."""
from agent_framework_declarative._workflows._powerfx_functions import agent_message
msg = agent_message("Hello")
assert msg == {"role": "assistant", "content": "Hello"}
def test_agent_message_with_none(self):
"""Test AgentMessage with None."""
from agent_framework_declarative._workflows._powerfx_functions import agent_message
msg = agent_message(cast("str", None))
assert msg == {"role": "assistant", "content": ""}
class TestIfFunc:
"""Tests for if_func conditional function."""
def test_if_true_condition(self):
"""Test If with true condition."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(True, "yes", "no") == "yes"
def test_if_false_condition(self):
"""Test If with false condition."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(False, "yes", "no") == "no"
def test_if_truthy_value(self):
"""Test If with truthy value."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(1, "yes", "no") == "yes"
assert if_func("non-empty", "yes", "no") == "yes"
def test_if_falsy_value(self):
"""Test If with falsy value."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(0, "yes", "no") == "no"
assert if_func("", "yes", "no") == "no"
assert if_func(None, "yes", "no") == "no"
def test_if_no_false_value(self):
"""Test If with no false value defaults to None."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(False, "yes") is None
class TestOrFunc:
"""Tests for or_func function."""
def test_or_all_false(self):
"""Test Or with all false values."""
from agent_framework_declarative._workflows._powerfx_functions import or_func
assert or_func(False, False, False) is False
def test_or_one_true(self):
"""Test Or with one true value."""
from agent_framework_declarative._workflows._powerfx_functions import or_func
assert or_func(False, True, False) is True
def test_or_all_true(self):
"""Test Or with all true values."""
from agent_framework_declarative._workflows._powerfx_functions import or_func
assert or_func(True, True, True) is True
def test_or_empty(self):
"""Test Or with no arguments."""
from agent_framework_declarative._workflows._powerfx_functions import or_func
assert or_func() is False
class TestAndFunc:
"""Tests for and_func function."""
def test_and_all_true(self):
"""Test And with all true values."""
from agent_framework_declarative._workflows._powerfx_functions import and_func
assert and_func(True, True, True) is True
def test_and_one_false(self):
"""Test And with one false value."""
from agent_framework_declarative._workflows._powerfx_functions import and_func
assert and_func(True, False, True) is False
def test_and_all_false(self):
"""Test And with all false values."""
from agent_framework_declarative._workflows._powerfx_functions import and_func
assert and_func(False, False, False) is False
def test_and_empty(self):
"""Test And with no arguments."""
from agent_framework_declarative._workflows._powerfx_functions import and_func
assert and_func() is True
class TestNotFunc:
"""Tests for not_func function."""
def test_not_true(self):
"""Test Not with true."""
from agent_framework_declarative._workflows._powerfx_functions import not_func
assert not_func(True) is False
def test_not_false(self):
"""Test Not with false."""
from agent_framework_declarative._workflows._powerfx_functions import not_func
assert not_func(False) is True
def test_not_truthy(self):
"""Test Not with truthy values."""
from agent_framework_declarative._workflows._powerfx_functions import not_func
assert not_func(1) is False
assert not_func("text") is False
def test_not_falsy(self):
"""Test Not with falsy values."""
from agent_framework_declarative._workflows._powerfx_functions import not_func
assert not_func(0) is True
assert not_func("") is True
assert not_func(None) is True
class TestIsBlankEdgeCases:
"""Additional tests for is_blank edge cases."""
def test_is_blank_empty_dict(self):
"""Test that empty dict is blank."""
assert is_blank({}) is True
def test_is_blank_non_empty_dict(self):
"""Test that non-empty dict is not blank."""
assert is_blank({"key": "value"}) is False
class TestCountRowsEdgeCases:
"""Additional tests for count_rows edge cases."""
def test_count_rows_dict(self):
"""Test counting dict items."""
assert count_rows({"a": 1, "b": 2, "c": 3}) == 3
def test_count_rows_tuple(self):
"""Test counting tuple items."""
assert count_rows((1, 2, 3, 4)) == 4
def test_count_rows_non_iterable(self):
"""Test counting non-iterable returns 0."""
assert count_rows(42) == 0
assert count_rows("string") == 0
class TestFirstLastEdgeCases:
"""Additional tests for first/last edge cases."""
def test_first_none(self):
"""Test first with None."""
assert first(None) is None
def test_last_none(self):
"""Test last with None."""
assert last(None) is None
def test_first_tuple(self):
"""Test first with tuple."""
assert first((1, 2, 3)) == 1
def test_last_tuple(self):
"""Test last with tuple."""
assert last((1, 2, 3)) == 3
class TestFindEdgeCases:
"""Additional tests for find edge cases."""
def test_find_none_substring(self):
"""Test find with None substring."""
assert find(None, "text") is None
def test_find_none_text(self):
"""Test find with None text."""
assert find("sub", None) is None
def test_find_both_none(self):
"""Test find with both None."""
assert find(None, None) is None
class TestLowerEdgeCases:
"""Additional tests for lower edge cases."""
def test_lower_none(self):
"""Test lower with None."""
assert lower(None) == ""
class TestConcatStrings:
"""Tests for concat_strings function."""
def test_concat_strings_basic(self):
"""Test basic string concatenation."""
from agent_framework_declarative._workflows._powerfx_functions import concat_strings
assert concat_strings("Hello", " ", "World") == "Hello World"
def test_concat_strings_with_none(self):
"""Test concat with None values."""
from agent_framework_declarative._workflows._powerfx_functions import concat_strings
assert concat_strings("Hello", None, "World") == "HelloWorld"
def test_concat_strings_empty(self):
"""Test concat with no arguments."""
from agent_framework_declarative._workflows._powerfx_functions import concat_strings
assert concat_strings() == ""
class TestConcatTextEdgeCases:
"""Additional tests for concat_text edge cases."""
def test_concat_text_none(self):
"""Test concat_text with None."""
assert concat_text(None) == ""
def test_concat_text_non_list(self):
"""Test concat_text with non-list."""
assert concat_text("single value") == "single value"
def test_concat_text_with_field_attr(self):
"""Test concat_text with field as object attribute."""
class Item: # noqa: B903
def __init__(self, name: str):
self.name = name
items = [Item("Alice"), Item("Bob")]
assert concat_text(items, field="name", separator=", ") == "Alice, Bob"
def test_concat_text_with_none_values(self):
"""Test concat_text with None values in list."""
items = [{"name": "Alice"}, {"name": None}, {"name": "Bob"}]
result = concat_text(items, field="name", separator=", ")
assert result == "Alice, , Bob"
class TestForAll:
"""Tests for for_all function."""
def test_for_all_with_list_of_dicts(self):
"""Test ForAll with list of dictionaries."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
items = [{"name": "Alice"}, {"name": "Bob"}]
result = for_all(items, "expression")
assert result == items
def test_for_all_with_non_dict_items(self):
"""Test ForAll with non-dict items."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
items = [1, 2, 3]
result = for_all(items, "expression")
assert result == [1, 2, 3]
def test_for_all_with_none(self):
"""Test ForAll with None."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
assert for_all(None, "expression") == []
def test_for_all_with_non_list(self):
"""Test ForAll with non-list."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
assert for_all("not a list", "expression") == []
def test_for_all_empty_list(self):
"""Test ForAll with empty list."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
assert for_all([], "expression") == []
class TestSearchTableEdgeCases:
"""Additional tests for search_table edge cases."""
def test_search_table_none(self):
"""Test search_table with None."""
assert search_table(None, "value", "column") == []
def test_search_table_non_list(self):
"""Test search_table with non-list."""
assert search_table("not a list", "value", "column") == []
def test_search_table_with_object_attr(self):
"""Test search_table with object attributes."""
class Item: # noqa: B903
def __init__(self, name: str):
self.name = name
items = [Item("Alice"), Item("Bob"), Item("Charlie")]
result = search_table(items, "Bob", "name")
assert len(result) == 1
assert result[0].name == "Bob"
def test_search_table_no_matching_column(self):
"""Test search_table when items don't have the column."""
items = [{"other": "value"}]
result = search_table(items, "value", "name")
assert result == []
def test_search_table_empty_value(self):
"""Test search_table with empty search value."""
items = [{"name": "Alice"}, {"name": "Bob"}]
result = search_table(items, "", "name")
# Empty string matches everything
assert len(result) == 2
@@ -0,0 +1,59 @@
# Copyright (c) Microsoft. All rights reserved.
"""Regression tests for ``_make_powerfx_safe``.
PowerFx (via pythonnet) only accepts plain primitives, dicts, and lists.
``Enum`` instances - especially ``str``- and ``int``-subclass enums like
MAF's ``MessageRole`` - silently pass ``isinstance(v, str)`` /
``isinstance(v, int)`` checks but blow up later inside pythonnet with
``'<EnumName>' value cannot be converted to System.<X>``. These tests
pin down the Enum coercion branch so we don't regress that interop fix.
"""
from enum import Enum, IntEnum
from agent_framework_declarative._workflows._declarative_base import _make_powerfx_safe
class _StrRole(str, Enum):
USER = "user"
SYSTEM = "system"
class _IntCode(IntEnum):
ONE = 1
TWO = 2
class _PlainEnum(Enum):
X = "x"
Y = 42
def test_str_subclass_enum_reduces_to_str():
assert _make_powerfx_safe(_StrRole.USER) == "user"
assert type(_make_powerfx_safe(_StrRole.USER)) is str
def test_int_subclass_enum_reduces_to_int():
assert _make_powerfx_safe(_IntCode.ONE) == 1
assert type(_make_powerfx_safe(_IntCode.ONE)) is int
def test_plain_enum_reduces_to_underlying_value():
assert _make_powerfx_safe(_PlainEnum.X) == "x"
assert _make_powerfx_safe(_PlainEnum.Y) == 42
def test_enum_inside_dict_is_coerced():
safe = _make_powerfx_safe({"role": _StrRole.USER, "code": _IntCode.TWO})
assert safe == {"role": "user", "code": 2}
assert type(safe["role"]) is str
assert type(safe["code"]) is int
def test_enum_inside_list_is_coerced():
safe = _make_powerfx_safe([_StrRole.USER, _IntCode.ONE])
assert safe == ["user", 1]
assert type(safe[0]) is str
assert type(safe[1]) is int
@@ -0,0 +1,611 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests to ensure PowerFx evaluation supports all expressions used in declarative YAML workflows.
This test suite validates that all PowerFx expressions found in the sample YAML workflows
under samples/03-workflows/declarative/ work correctly with our implementation.
Coverage includes:
- Built-in PowerFx functions: Concat, If, IsBlank, Not, Or, Upper, Find
- Custom functions: UserMessage, MessageText
- System variables: System.ConversationId, System.LastMessage.Text
- Local/turn variables with nested access
- Comparison operators: <, >, <=, >=, <>, =
- Logical operators: And, Or, Not, !
- Arithmetic operators: +, -, *, /
- String interpolation: {Variable.Path}
"""
import locale
from unittest.mock import MagicMock
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")
from agent_framework_declarative._workflows._declarative_base import ( # noqa: E402
DeclarativeWorkflowState,
)
class TestPowerFxBuiltinFunctions:
"""Test PowerFx built-in functions used in YAML workflows."""
@pytest.fixture
def mock_state(self):
"""Create a mock state with sync get/set methods."""
state = MagicMock()
state._data = {}
def mock_get(key, default=None):
return state._data.get(key, default)
def mock_set(key, value):
state._data[key] = value
def mock_has(key):
return key in state._data
state.get = MagicMock(side_effect=mock_get)
state.set = MagicMock(side_effect=mock_set)
state.has = MagicMock(side_effect=mock_has)
return state
async def test_concat_simple(self, mock_state):
"""Test Concat function with simple strings."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Concat("Nice to meet you, ", Local.userName, "!")
state.set("Local.userName", "Alice")
result = state.eval('=Concat("Nice to meet you, ", Local.userName, "!")')
assert result == "Nice to meet you, Alice!"
async def test_concat_multiple_args(self, mock_state):
"""Test Concat with multiple arguments."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Concat(Local.greeting, ", ", Local.name, "!")
state.set("Local.greeting", "Hello")
state.set("Local.name", "World")
result = state.eval('=Concat(Local.greeting, ", ", Local.name, "!")')
assert result == "Hello, World!"
async def test_concat_with_local_namespace(self, mock_state):
"""Test Concat using Local.* namespace (maps to Local.*)."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Concat("Starting math coaching session for: ", Local.Problem)
state.set("Local.Problem", "2 + 2")
result = state.eval('=Concat("Starting math coaching session for: ", Local.Problem)')
assert result == "Starting math coaching session for: 2 + 2"
async def test_if_with_isblank(self, mock_state):
"""Test If function with IsBlank."""
state = DeclarativeWorkflowState(mock_state)
state.initialize({"name": ""})
# From YAML: =If(IsBlank(inputs.name), "World", inputs.name)
# When input is blank
result = state.eval('=If(IsBlank(Workflow.Inputs.name), "World", Workflow.Inputs.name)')
assert result == "World"
# When input is provided
state.initialize({"name": "Alice"})
result = state.eval('=If(IsBlank(Workflow.Inputs.name), "World", Workflow.Inputs.name)')
assert result == "Alice"
async def test_not_function(self, mock_state):
"""Test Not function."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Not(Local.EscalationParameters.IsComplete)
state.set("Local.EscalationParameters", {"IsComplete": False})
result = state.eval("=Not(Local.EscalationParameters.IsComplete)")
assert result is True
state.set("Local.EscalationParameters", {"IsComplete": True})
result = state.eval("=Not(Local.EscalationParameters.IsComplete)")
assert result is False
async def test_or_function(self, mock_state):
"""Test Or function."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Or(Local.feeling = "great", Local.feeling = "good")
state.set("Local.feeling", "great")
result = state.eval('=Or(Local.feeling = "great", Local.feeling = "good")')
assert result is True
state.set("Local.feeling", "good")
result = state.eval('=Or(Local.feeling = "great", Local.feeling = "good")')
assert result is True
state.set("Local.feeling", "bad")
result = state.eval('=Or(Local.feeling = "great", Local.feeling = "good")')
assert result is False
async def test_upper_function(self, mock_state):
"""Test Upper function."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Upper(System.LastMessage.Text)
state.set("System.LastMessage", {"Text": "hello world"})
result = state.eval("=Upper(System.LastMessage.Text)")
assert result == "HELLO WORLD"
async def test_find_function(self, mock_state):
"""Test Find function."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =!IsBlank(Find("CONGRATULATIONS", Upper(Local.TeacherResponse)))
state.set("Local.TeacherResponse", "CONGRATULATIONS! You solved it!")
result = state.eval('=Not(IsBlank(Find("CONGRATULATIONS", Upper(Local.TeacherResponse))))')
assert result is True
state.set("Local.TeacherResponse", "Try again")
result = state.eval('=Not(IsBlank(Find("CONGRATULATIONS", Upper(Local.TeacherResponse))))')
assert result is False
class TestPowerFxSystemVariables:
"""Test System.* variable access."""
@pytest.fixture
def mock_state(self):
"""Create a mock shared state."""
mock_state = MagicMock()
mock_state._data = {}
def mock_get(key, default=None):
return mock_state._data.get(key, default)
def mock_set(key, value):
mock_state._data[key] = value
mock_state.get = MagicMock(side_effect=mock_get)
mock_state.set = MagicMock(side_effect=mock_set)
return mock_state
async def test_system_conversation_id(self, mock_state):
"""Test System.ConversationId access."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: conversationId: =System.ConversationId
state.set("System.ConversationId", "conv-12345")
result = state.eval("=System.ConversationId")
assert result == "conv-12345"
async def test_system_last_message_text(self, mock_state):
"""Test System.LastMessage.Text access."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Upper(System.LastMessage.Text) <> "EXIT"
state.set("System.LastMessage", {"Text": "Hello"})
result = state.eval("=System.LastMessage.Text")
assert result == "Hello"
async def test_system_last_message_exit_check(self, mock_state):
"""Test the exit check pattern from YAML."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: when: =Upper(System.LastMessage.Text) <> "EXIT"
state.set("System.LastMessage", {"Text": "hello"})
result = state.eval('=Upper(System.LastMessage.Text) <> "EXIT"')
assert result is True
state.set("System.LastMessage", {"Text": "exit"})
result = state.eval('=Upper(System.LastMessage.Text) <> "EXIT"')
assert result is False
class TestPowerFxComparisonOperators:
"""Test comparison operators used in YAML workflows."""
@pytest.fixture
def mock_state(self):
"""Create a mock shared state."""
mock_state = MagicMock()
mock_state._data = {}
def mock_get(key, default=None):
return mock_state._data.get(key, default)
def mock_set(key, value):
mock_state._data[key] = value
mock_state.get = MagicMock(side_effect=mock_get)
mock_state.set = MagicMock(side_effect=mock_set)
return mock_state
async def test_less_than(self, mock_state):
"""Test < operator."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: condition: =Local.age < 65
state.set("Local.age", 30)
assert state.eval("=Local.age < 65") is True
state.set("Local.age", 70)
assert state.eval("=Local.age < 65") is False
async def test_less_than_with_local(self, mock_state):
"""Test < with Local namespace."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: condition: =Local.TurnCount < 4
state.set("Local.TurnCount", 2)
assert state.eval("=Local.TurnCount < 4") is True
state.set("Local.TurnCount", 5)
assert state.eval("=Local.TurnCount < 4") is False
async def test_equality(self, mock_state):
"""Test = equality operator."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Local.feeling = "great"
state.set("Local.feeling", "great")
assert state.eval('=Local.feeling = "great"') is True
state.set("Local.feeling", "bad")
assert state.eval('=Local.feeling = "great"') is False
async def test_inequality(self, mock_state):
"""Test <> inequality operator."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Upper(System.LastMessage.Text) <> "EXIT"
state.set("Local.status", "active")
assert state.eval('=Local.status <> "done"') is True
assert state.eval('=Local.status <> "active"') is False
class TestPowerFxArithmetic:
"""Test arithmetic operations."""
@pytest.fixture
def mock_state(self):
"""Create a mock shared state."""
mock_state = MagicMock()
mock_state._data = {}
def mock_get(key, default=None):
return mock_state._data.get(key, default)
def mock_set(key, value):
mock_state._data[key] = value
mock_state.get = MagicMock(side_effect=mock_get)
mock_state.set = MagicMock(side_effect=mock_set)
return mock_state
async def test_addition(self, mock_state):
"""Test + operator."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: value: =Local.TurnCount + 1
state.set("Local.TurnCount", 3)
result = state.eval("=Local.TurnCount + 1")
assert result == 4
class TestPowerFxCustomFunctions:
"""Test custom functions (UserMessage, MessageText, AgentMessage)."""
@pytest.fixture
def mock_state(self):
"""Create a mock shared state."""
mock_state = MagicMock()
mock_state._data = {}
def mock_get(key, default=None):
return mock_state._data.get(key, default)
def mock_set(key, value):
mock_state._data[key] = value
mock_state.get = MagicMock(side_effect=mock_get)
mock_state.set = MagicMock(side_effect=mock_set)
return mock_state
@pytest.mark.asyncio
async def test_agent_message_function(self, mock_state):
"""Test AgentMessage function (.NET compatibility alias for AssistantMessage)."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From .NET YAML: messages: =AgentMessage(Local.Response)
state.set("Local.Response", "Here is the analysis result")
result = state.eval("=AgentMessage(Local.Response)")
assert isinstance(result, dict)
assert result["role"] == "assistant"
assert result["text"] == "Here is the analysis result"
@pytest.mark.asyncio
async def test_agent_message_with_empty_string(self, mock_state):
"""Test AgentMessage with empty string."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
state.set("Local.Response", "")
result = state.eval("=AgentMessage(Local.Response)")
assert result["role"] == "assistant"
assert result["text"] == ""
@pytest.mark.asyncio
async def test_user_message_with_variable(self, mock_state):
"""Test UserMessage function with variable reference."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: messages: =UserMessage(Local.ServiceParameters.IssueDescription)
state.set("Local.ServiceParameters", {"IssueDescription": "My computer won't boot"})
result = state.eval("=UserMessage(Local.ServiceParameters.IssueDescription)")
assert isinstance(result, dict)
assert result["role"] == "user"
assert result["text"] == "My computer won't boot"
async def test_user_message_with_simple_variable(self, mock_state):
"""Test UserMessage with simple variable."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: messages: =Local.Problem
state.set("Local.Problem", "What is 2+2?")
result = state.eval("=UserMessage(Local.Problem)")
assert result["role"] == "user"
assert result["text"] == "What is 2+2?"
async def test_message_text_with_list(self, mock_state):
"""Test MessageText extracts text from message list."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
state.set(
"Local.messages",
[
{"role": "user", "text": "Hello"},
{"role": "assistant", "text": "Hi there!"},
],
)
result = state.eval("=MessageText(Local.messages)")
assert result == "Hi there!"
async def test_message_text_empty_list(self, mock_state):
"""Test MessageText with empty list returns empty string."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
state.set("Local.messages", [])
result = state.eval("=MessageText(Local.messages)")
assert result == ""
class TestPowerFxNestedVariables:
"""Test nested variable access patterns from YAML."""
@pytest.fixture
def mock_state(self):
"""Create a mock shared state."""
mock_state = MagicMock()
mock_state._data = {}
def mock_get(key, default=None):
return mock_state._data.get(key, default)
def mock_set(key, value):
mock_state._data[key] = value
mock_state.get = MagicMock(side_effect=mock_get)
mock_state.set = MagicMock(side_effect=mock_set)
return mock_state
async def test_nested_local_variable(self, mock_state):
"""Test nested Local.* variable access."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Local.ServiceParameters.IssueDescription
state.set("Local.ServiceParameters", {"IssueDescription": "Screen is black"})
result = state.eval("=Local.ServiceParameters.IssueDescription")
assert result == "Screen is black"
async def test_nested_routing_parameters(self, mock_state):
"""Test RoutingParameters access."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Local.RoutingParameters.TeamName
state.set("Local.RoutingParameters", {"TeamName": "Windows Support"})
result = state.eval("=Local.RoutingParameters.TeamName")
assert result == "Windows Support"
async def test_nested_ticket_parameters(self, mock_state):
"""Test TicketParameters access."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Local.TicketParameters.TicketId
state.set("Local.TicketParameters", {"TicketId": "TKT-12345"})
result = state.eval("=Local.TicketParameters.TicketId")
assert result == "TKT-12345"
class TestPowerFxUndefinedVariables:
"""Test graceful handling of undefined variables."""
@pytest.fixture
def mock_state(self):
"""Create a mock shared state."""
mock_state = MagicMock()
mock_state._data = {}
def mock_get(key, default=None):
return mock_state._data.get(key, default)
def mock_set(key, value):
mock_state._data[key] = value
mock_state.get = MagicMock(side_effect=mock_get)
mock_state.set = MagicMock(side_effect=mock_set)
return mock_state
async def test_undefined_local_variable_returns_none(self, mock_state):
"""Test that undefined Local.* variables return None."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# Variable not set - should return None (not raise)
result = state.eval("=Local.UndefinedVariable")
assert result is None
async def test_undefined_nested_variable_returns_none(self, mock_state):
"""Test that undefined nested variables return None."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# Nested undefined variable
result = state.eval("=Local.Something.Nested.Deep")
assert result is None
async def test_undefined_variable_returns_none_with_non_english_ui_culture(self, mock_state):
"""Test that undefined variables return None even when locale is non-English.
Regression test for #4321: on non-English systems, locale settings can cause
PowerFx to emit localized error messages that don't match the English
string guards ("isn't recognized", "Name isn't valid"), crashing the workflow.
The fix evaluates with locale='en-US' and restores the ambient LC_NUMERIC.
"""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# Simulate a non-English locale (e.g. Italian)
original_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
test_numeric_locale: str | None = None
try:
for locale_candidate in ("it_IT.UTF-8", "it_IT", "fr_FR.UTF-8", "fr_FR", "de_DE.UTF-8", "de_DE"):
try:
locale.setlocale(locale.LC_NUMERIC, locale_candidate)
test_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
break
except locale.Error:
continue
if test_numeric_locale is None:
pytest.skip("No non-English LC_NUMERIC locale available on this system")
# Should return None, not raise ValueError with Italian error text
result = state.eval("=Local.StatusConversationId")
assert result is None
# Verify the production code restored LC_NUMERIC after eval
assert locale.setlocale(locale.LC_NUMERIC) == test_numeric_locale
finally:
locale.setlocale(locale.LC_NUMERIC, original_numeric_locale)
class TestStringInterpolation:
"""Test string interpolation patterns."""
@pytest.fixture
def mock_state(self):
"""Create a mock shared state."""
mock_state = MagicMock()
mock_state._data = {}
def mock_get(key, default=None):
return mock_state._data.get(key, default)
def mock_set(key, value):
mock_state._data[key] = value
mock_state.get = MagicMock(side_effect=mock_get)
mock_state.set = MagicMock(side_effect=mock_set)
return mock_state
async def test_interpolate_local_variable(self, mock_state):
"""Test {Local.Variable} interpolation."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: activity: "Created ticket #{Local.TicketParameters.TicketId}"
state.set("Local.TicketParameters", {"TicketId": "TKT-999"})
result = state.interpolate_string("Created ticket #{Local.TicketParameters.TicketId}")
assert result == "Created ticket #TKT-999"
async def test_interpolate_routing_team(self, mock_state):
"""Test routing team interpolation."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: activity: Routing to {Local.RoutingParameters.TeamName}
state.set("Local.RoutingParameters", {"TeamName": "Linux Support"})
result = state.interpolate_string("Routing to {Local.RoutingParameters.TeamName}")
assert result == "Routing to Linux Support"
class TestWorkflowInputsAccess:
"""Test Workflow.Inputs access patterns."""
@pytest.fixture
def mock_state(self):
"""Create a mock shared state."""
mock_state = MagicMock()
mock_state._data = {}
def mock_get(key, default=None):
return mock_state._data.get(key, default)
def mock_set(key, value):
mock_state._data[key] = value
mock_state.get = MagicMock(side_effect=mock_get)
mock_state.set = MagicMock(side_effect=mock_set)
return mock_state
async def test_inputs_name(self, mock_state):
"""Test inputs.name access."""
state = DeclarativeWorkflowState(mock_state)
state.initialize({"name": "Alice", "age": 25})
# .NET style (standard)
result = state.eval("=Workflow.Inputs.name")
assert result == "Alice"
# Also test inputs.name shorthand
result = state.eval("=inputs.name")
assert result == "Alice"
async def test_inputs_problem(self, mock_state):
"""Test inputs.problem access."""
state = DeclarativeWorkflowState(mock_state)
state.initialize({"problem": "What is 5 * 6?"})
# .NET style (standard)
result = state.eval("=Workflow.Inputs.problem")
assert result == "What is 5 * 6?"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,236 @@
# Copyright (c) Microsoft. All rights reserved.
"""Integration tests for workflow samples.
These tests verify that the workflow samples from declarative-agents/workflow-samples/ directory
can be parsed and validated by the WorkflowFactory.
"""
from pathlib import Path
import pytest
import yaml
# Path to workflow samples - navigate from tests dir up to repo root
# tests/test_*.py -> packages/declarative/tests/ -> packages/declarative/ -> packages/ -> python/ -> repo root
WORKFLOW_SAMPLES_DIR = Path(__file__).parent.parent.parent.parent.parent / "declarative-agents" / "workflow-samples"
def get_workflow_sample_files():
"""Get all .yaml files from the workflow-samples directory."""
if not WORKFLOW_SAMPLES_DIR.exists():
return []
return list(WORKFLOW_SAMPLES_DIR.glob("*.yaml"))
class TestWorkflowSampleParsing:
"""Tests that verify workflow samples can be parsed correctly."""
@pytest.fixture
def sample_files(self):
"""Get list of sample files."""
return get_workflow_sample_files()
def test_samples_directory_exists(self):
"""Verify the workflow-samples directory exists."""
assert WORKFLOW_SAMPLES_DIR.exists(), f"Workflow samples directory not found at {WORKFLOW_SAMPLES_DIR}"
def test_samples_exist(self, sample_files):
"""Verify there are workflow sample files."""
assert len(sample_files) > 0, "No workflow sample files found"
@pytest.mark.parametrize("yaml_file", get_workflow_sample_files(), ids=lambda f: f.name)
def test_sample_yaml_is_valid(self, yaml_file):
"""Test that each sample YAML file can be parsed."""
with open(yaml_file) as f:
data = yaml.safe_load(f)
assert data is not None, f"Failed to parse {yaml_file.name}"
assert "kind" in data, f"Missing 'kind' field in {yaml_file.name}"
assert data["kind"] == "Workflow", f"Expected kind: Workflow in {yaml_file.name}"
@pytest.mark.parametrize("yaml_file", get_workflow_sample_files(), ids=lambda f: f.name)
def test_sample_has_trigger(self, yaml_file):
"""Test that each sample has a trigger defined."""
with open(yaml_file) as f:
data = yaml.safe_load(f)
assert "trigger" in data, f"Missing 'trigger' field in {yaml_file.name}"
trigger = data["trigger"]
assert trigger is not None, f"Trigger is empty in {yaml_file.name}"
@pytest.mark.parametrize("yaml_file", get_workflow_sample_files(), ids=lambda f: f.name)
def test_sample_has_actions(self, yaml_file):
"""Test that each sample has actions defined."""
with open(yaml_file) as f:
data = yaml.safe_load(f)
trigger = data.get("trigger", {})
actions = trigger.get("actions", [])
assert len(actions) > 0, f"No actions defined in {yaml_file.name}"
@pytest.mark.parametrize("yaml_file", get_workflow_sample_files(), ids=lambda f: f.name)
def test_sample_actions_have_kind(self, yaml_file):
"""Test that each action has a 'kind' field."""
with open(yaml_file) as f:
data = yaml.safe_load(f)
def check_actions(actions, path=""):
for i, action in enumerate(actions):
action_path = f"{path}[{i}]"
assert "kind" in action, f"Action missing 'kind' at {action_path} in {yaml_file.name}"
# Check nested actions
for nested_key in ["actions", "elseActions", "thenActions"]:
if nested_key in action:
check_actions(action[nested_key], f"{action_path}.{nested_key}")
# Check conditions
if "conditions" in action:
for j, cond in enumerate(action["conditions"]):
if "actions" in cond:
check_actions(cond["actions"], f"{action_path}.conditions[{j}].actions")
# Check cases
if "cases" in action:
for j, case in enumerate(action["cases"]):
if "actions" in case:
check_actions(case["actions"], f"{action_path}.cases[{j}].actions")
trigger = data.get("trigger", {})
actions = trigger.get("actions", [])
check_actions(actions, "trigger.actions")
class TestWorkflowDefinitionParsing:
"""Tests for parsing workflow definitions into structured objects."""
@pytest.mark.parametrize("yaml_file", get_workflow_sample_files(), ids=lambda f: f.name)
def test_extract_actions_from_sample(self, yaml_file):
"""Test extracting all actions from a workflow sample."""
with open(yaml_file) as f:
data = yaml.safe_load(f)
# Collect all action kinds used
action_kinds: set[str] = set()
def collect_actions(actions):
for action in actions:
action_kinds.add(action.get("kind", "Unknown"))
# Collect from nested actions
for nested_key in ["actions", "elseActions", "thenActions"]:
if nested_key in action:
collect_actions(action[nested_key])
if "conditions" in action:
for cond in action["conditions"]:
if "actions" in cond:
collect_actions(cond["actions"])
if "cases" in action:
for case in action["cases"]:
if "actions" in case:
collect_actions(case["actions"])
trigger = data.get("trigger", {})
actions = trigger.get("actions", [])
collect_actions(actions)
# Verify we found some actions
assert len(action_kinds) > 0, f"No action kinds found in {yaml_file.name}"
@pytest.mark.parametrize("yaml_file", get_workflow_sample_files(), ids=lambda f: f.name)
def test_extract_agent_names_from_sample(self, yaml_file):
"""Test extracting agent names referenced in a workflow sample."""
with open(yaml_file) as f:
data = yaml.safe_load(f)
agent_names: set[str] = set()
def collect_agents(actions):
for action in actions:
kind = action.get("kind", "")
if kind in ("InvokeAzureAgent", "InvokePromptAgent"):
agent_config = action.get("agent", {})
name = agent_config.get("name") if isinstance(agent_config, dict) else agent_config
if name and not str(name).startswith("="):
agent_names.add(name)
# Collect from nested actions
for nested_key in ["actions", "elseActions", "thenActions"]:
if nested_key in action:
collect_agents(action[nested_key])
if "conditions" in action:
for cond in action["conditions"]:
if "actions" in cond:
collect_agents(cond["actions"])
if "cases" in action:
for case in action["cases"]:
if "actions" in case:
collect_agents(case["actions"])
trigger = data.get("trigger", {})
actions = trigger.get("actions", [])
collect_agents(actions)
# Log the agents found (some workflows may not use agents)
# Agent names: {agent_names}
class TestHandlerCoverage:
"""Tests to verify handler coverage for workflow actions."""
@pytest.fixture
def all_action_kinds(self):
"""Collect all action kinds used across all samples."""
action_kinds: set[str] = set()
def collect_actions(actions):
for action in actions:
action_kinds.add(action.get("kind", "Unknown"))
for nested_key in ["actions", "elseActions", "thenActions"]:
if nested_key in action:
collect_actions(action[nested_key])
if "conditions" in action:
for cond in action["conditions"]:
if "actions" in cond:
collect_actions(cond["actions"])
if "cases" in action:
for case in action["cases"]:
if "actions" in case:
collect_actions(case["actions"])
for yaml_file in get_workflow_sample_files():
with open(yaml_file) as f:
data = yaml.safe_load(f)
trigger = data.get("trigger", {})
actions = trigger.get("actions", [])
collect_actions(actions)
return action_kinds
def test_executors_exist_for_sample_actions(self, all_action_kinds):
"""Test that executors exist for all action kinds used in samples."""
from agent_framework_declarative._workflows._declarative_builder import ALL_ACTION_EXECUTORS
registered_executors = set(ALL_ACTION_EXECUTORS.keys())
# Kinds handled structurally by the builder (not registered as executors)
structural_kinds = {
"OnConversationStart", # Trigger kind, not an action
"ConditionGroup", # Decomposed into evaluator/join nodes
"GotoAction", # Resolved as graph edges, not executor nodes
}
missing_executors = all_action_kinds - registered_executors - structural_kinds
assert not missing_executors, (
f"Missing executors for action kinds used in workflow samples: {sorted(missing_executors)}"
)
@@ -0,0 +1,584 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for WorkflowState class."""
import pytest
from agent_framework_declarative._workflows._state import WorkflowState
class TestWorkflowStateInitialization:
"""Tests for WorkflowState initialization."""
def test_empty_initialization(self):
"""Test creating a WorkflowState with no inputs."""
state = WorkflowState()
assert state.inputs == {}
assert state.outputs == {}
assert state.local == {}
assert state.agent == {}
def test_initialization_with_inputs(self):
"""Test creating a WorkflowState with inputs."""
state = WorkflowState(inputs={"query": "Hello", "count": 5})
assert state.inputs == {"query": "Hello", "count": 5}
assert state.outputs == {}
def test_inputs_are_immutable(self):
"""Test that inputs cannot be modified through set()."""
state = WorkflowState(inputs={"query": "Hello"})
with pytest.raises(ValueError, match="Cannot modify Workflow.Inputs"):
state.set("Workflow.Inputs.query", "Modified")
class TestWorkflowStateGetSet:
"""Tests for get and set operations."""
def test_set_and_get_turn_variable(self):
"""Test setting and getting a turn variable."""
state = WorkflowState()
state.set("Local.counter", 10)
assert state.get("Local.counter") == 10
def test_set_and_get_nested_turn_variable(self):
"""Test setting and getting a nested turn variable."""
state = WorkflowState()
state.set("Local.data.nested.value", "test")
assert state.get("Local.data.nested.value") == "test"
def test_set_and_get_workflow_output(self):
"""Test setting and getting workflow output."""
state = WorkflowState()
state.set("Workflow.Outputs.result", "success")
assert state.get("Workflow.Outputs.result") == "success"
assert state.outputs["result"] == "success"
def test_get_with_default(self):
"""Test get with default value."""
state = WorkflowState()
assert state.get("Local.nonexistent") is None
assert state.get("Local.nonexistent", "default") == "default"
def test_get_workflow_inputs(self):
"""Test getting workflow inputs."""
state = WorkflowState(inputs={"query": "test"})
assert state.get("Workflow.Inputs.query") == "test"
def test_set_custom_namespace(self):
"""Test setting a custom namespace variable."""
state = WorkflowState()
state.set("custom.myvar", "value")
assert state.get("custom.myvar") == "value"
class TestWorkflowStateAppend:
"""Tests for append operation."""
def test_append_to_nonexistent_list(self):
"""Test appending to a path that doesn't exist yet."""
state = WorkflowState()
state.append("Local.results", "item1")
assert state.get("Local.results") == ["item1"]
def test_append_to_existing_list(self):
"""Test appending to an existing list."""
state = WorkflowState()
state.set("Local.results", ["item1"])
state.append("Local.results", "item2")
assert state.get("Local.results") == ["item1", "item2"]
def test_append_to_non_list_raises(self):
"""Test that appending to a non-list raises ValueError."""
state = WorkflowState()
state.set("Local.value", "not a list")
with pytest.raises(ValueError, match="Cannot append to non-list"):
state.append("Local.value", "item")
class TestWorkflowStateAgentResult:
"""Tests for agent result management."""
def test_set_agent_result(self):
"""Test setting agent result."""
state = WorkflowState()
state.set_agent_result(
text="Agent response",
messages=[{"role": "assistant", "content": "Hello"}],
tool_calls=[{"name": "tool1"}],
)
assert state.agent["text"] == "Agent response"
assert len(state.agent["messages"]) == 1
assert len(state.agent["toolCalls"]) == 1
def test_get_agent_result_via_path(self):
"""Test getting agent result via path."""
state = WorkflowState()
state.set_agent_result(text="Response")
assert state.get("Agent.text") == "Response"
def test_reset_agent(self):
"""Test resetting agent result."""
state = WorkflowState()
state.set_agent_result(text="Response")
state.reset_agent()
assert state.agent == {}
class TestWorkflowStateConversation:
"""Tests for conversation management."""
def test_add_conversation_message(self):
"""Test adding a conversation message."""
state = WorkflowState()
message = {"role": "user", "content": "Hello"}
state.add_conversation_message(message)
assert len(state.conversation["messages"]) == 1
assert state.conversation["messages"][0] == message
def test_get_conversation_history(self):
"""Test getting conversation history."""
state = WorkflowState()
state.add_conversation_message({"role": "user", "content": "Hi"})
state.add_conversation_message({"role": "assistant", "content": "Hello"})
assert len(state.get("Conversation.history")) == 2
class TestWorkflowStatePowerFx:
"""Tests for PowerFx expression evaluation."""
def test_eval_non_expression(self):
"""Test that non-expressions are returned as-is."""
state = WorkflowState()
assert state.eval("plain text") == "plain text"
def test_eval_if_expression_with_literal(self):
"""Test eval_if_expression with a literal value."""
state = WorkflowState()
assert state.eval_if_expression(42) == 42
assert state.eval_if_expression(["a", "b"]) == ["a", "b"]
def test_eval_if_expression_with_non_expression_string(self):
"""Test eval_if_expression with a non-expression string."""
state = WorkflowState()
assert state.eval_if_expression("plain text") == "plain text"
def test_to_powerfx_symbols(self):
"""Test converting state to PowerFx symbols."""
state = WorkflowState(inputs={"query": "test"})
state.set("Local.counter", 5)
state.set("Workflow.Outputs.result", "done")
symbols = state.to_powerfx_symbols()
assert symbols["Workflow"]["Inputs"]["query"] == "test"
assert symbols["Workflow"]["Outputs"]["result"] == "done"
assert symbols["Local"]["counter"] == 5
class TestWorkflowStateClone:
"""Tests for state cloning."""
def test_clone_creates_copy(self):
"""Test that clone creates a copy of the state."""
state = WorkflowState(inputs={"query": "test"})
state.set("Local.counter", 5)
cloned = state.clone()
assert cloned.get("Workflow.Inputs.query") == "test"
assert cloned.get("Local.counter") == 5
def test_clone_is_independent(self):
"""Test that modifications to clone don't affect original."""
state = WorkflowState()
state.set("Local.value", "original")
cloned = state.clone()
cloned.set("Local.value", "modified")
assert state.get("Local.value") == "original"
assert cloned.get("Local.value") == "modified"
class TestWorkflowStateResetTurn:
"""Tests for turn reset."""
def test_reset_local_clears_turn_variables(self):
"""Test that reset_local clears turn variables."""
state = WorkflowState()
state.set("Local.var1", "value1")
state.set("Local.var2", "value2")
state.reset_local()
assert state.get("Local.var1") is None
assert state.get("Local.var2") is None
assert state.local == {}
def test_reset_local_preserves_other_state(self):
"""Test that reset_local preserves other state."""
state = WorkflowState(inputs={"query": "test"})
state.set("Workflow.Outputs.result", "done")
state.set("Local.temp", "will be cleared")
state.reset_local()
assert state.get("Workflow.Inputs.query") == "test"
assert state.get("Workflow.Outputs.result") == "done"
class TestWorkflowStateEvalSimple:
"""Tests for _eval_simple fallback PowerFx evaluation."""
def test_negation_prefix(self):
"""Test negation with ! prefix."""
state = WorkflowState()
state.set("Local.value", True)
assert state._eval_simple("!Local.value") is False
state.set("Local.value", False)
assert state._eval_simple("!Local.value") is True
def test_not_function(self):
"""Test Not() function."""
state = WorkflowState()
state.set("Local.flag", True)
assert state._eval_simple("Not(Local.flag)") is False
state.set("Local.flag", False)
assert state._eval_simple("Not(Local.flag)") is True
def test_and_operator(self):
"""Test And operator."""
state = WorkflowState()
state.set("Local.a", True)
state.set("Local.b", True)
assert state._eval_simple("Local.a And Local.b") is True
state.set("Local.b", False)
assert state._eval_simple("Local.a And Local.b") is False
def test_or_operator(self):
"""Test Or operator."""
state = WorkflowState()
state.set("Local.a", False)
state.set("Local.b", False)
assert state._eval_simple("Local.a Or Local.b") is False
state.set("Local.b", True)
assert state._eval_simple("Local.a Or Local.b") is True
def test_or_operator_double_pipe(self):
"""Test || operator."""
state = WorkflowState()
state.set("Local.x", False)
state.set("Local.y", True)
assert state._eval_simple("Local.x || Local.y") is True
def test_less_than(self):
"""Test < comparison."""
state = WorkflowState()
state.set("Local.num", 5)
assert state._eval_simple("Local.num < 10") is True
assert state._eval_simple("Local.num < 3") is False
def test_greater_than(self):
"""Test > comparison."""
state = WorkflowState()
state.set("Local.num", 5)
assert state._eval_simple("Local.num > 3") is True
assert state._eval_simple("Local.num > 10") is False
def test_less_than_or_equal(self):
"""Test <= comparison."""
state = WorkflowState()
state.set("Local.num", 5)
assert state._eval_simple("Local.num <= 5") is True
assert state._eval_simple("Local.num <= 4") is False
def test_greater_than_or_equal(self):
"""Test >= comparison."""
state = WorkflowState()
state.set("Local.num", 5)
assert state._eval_simple("Local.num >= 5") is True
assert state._eval_simple("Local.num >= 6") is False
def test_not_equal(self):
"""Test <> comparison."""
state = WorkflowState()
state.set("Local.val", "hello")
assert state._eval_simple('Local.val <> "world"') is True
assert state._eval_simple('Local.val <> "hello"') is False
def test_equal(self):
"""Test = comparison."""
state = WorkflowState()
state.set("Local.val", "test")
assert state._eval_simple('Local.val = "test"') is True
assert state._eval_simple('Local.val = "other"') is False
def test_addition_numeric(self):
"""Test + operator with numbers."""
state = WorkflowState()
state.set("Local.a", 3)
state.set("Local.b", 4)
assert state._eval_simple("Local.a + Local.b") == 7.0
def test_addition_string_concat(self):
"""Test + operator falls back to string concat."""
state = WorkflowState()
state.set("Local.a", "hello")
state.set("Local.b", "world")
assert state._eval_simple("Local.a + Local.b") == "helloworld"
def test_addition_with_none(self):
"""Test + treats None as 0."""
state = WorkflowState()
state.set("Local.a", 5)
# Local.b doesn't exist, so it's None
assert state._eval_simple("Local.a + Local.b") == 5.0
def test_subtraction(self):
"""Test - operator."""
state = WorkflowState()
state.set("Local.a", 10)
state.set("Local.b", 3)
assert state._eval_simple("Local.a - Local.b") == 7.0
def test_subtraction_with_none(self):
"""Test - treats None as 0."""
state = WorkflowState()
state.set("Local.a", 5)
assert state._eval_simple("Local.a - Local.missing") == 5.0
def test_multiplication(self):
"""Test * operator."""
state = WorkflowState()
state.set("Local.a", 4)
state.set("Local.b", 5)
assert state._eval_simple("Local.a * Local.b") == 20.0
def test_multiplication_with_none(self):
"""Test * treats None as 0."""
state = WorkflowState()
state.set("Local.a", 5)
assert state._eval_simple("Local.a * Local.missing") == 0.0
def test_division(self):
"""Test / operator."""
state = WorkflowState()
state.set("Local.a", 20)
state.set("Local.b", 4)
assert state._eval_simple("Local.a / Local.b") == 5.0
def test_division_by_zero(self):
"""Test / by zero returns None."""
state = WorkflowState()
state.set("Local.a", 10)
state.set("Local.b", 0)
assert state._eval_simple("Local.a / Local.b") is None
def test_string_literal_double_quotes(self):
"""Test string literal with double quotes."""
state = WorkflowState()
assert state._eval_simple('"hello world"') == "hello world"
def test_string_literal_single_quotes(self):
"""Test string literal with single quotes."""
state = WorkflowState()
assert state._eval_simple("'hello world'") == "hello world"
def test_integer_literal(self):
"""Test integer literal."""
state = WorkflowState()
assert state._eval_simple("42") == 42
def test_float_literal(self):
"""Test float literal."""
state = WorkflowState()
assert state._eval_simple("3.14") == 3.14
def test_boolean_true_literal(self):
"""Test true literal (case insensitive)."""
state = WorkflowState()
assert state._eval_simple("true") is True
assert state._eval_simple("True") is True
assert state._eval_simple("TRUE") is True
def test_boolean_false_literal(self):
"""Test false literal (case insensitive)."""
state = WorkflowState()
assert state._eval_simple("false") is False
assert state._eval_simple("False") is False
assert state._eval_simple("FALSE") is False
def test_variable_reference(self):
"""Test simple variable reference."""
state = WorkflowState()
state.set("Local.myvar", "myvalue")
assert state._eval_simple("Local.myvar") == "myvalue"
def test_unknown_expression_returned_as_is(self):
"""Test that unknown expressions are returned as-is."""
state = WorkflowState()
result = state._eval_simple("unknown_identifier")
assert result == "unknown_identifier"
def test_agent_namespace_reference(self):
"""Test Agent namespace variable reference."""
state = WorkflowState()
state.set_agent_result(text="agent response")
assert state._eval_simple("Agent.text") == "agent response"
def test_conversation_namespace_reference(self):
"""Test Conversation namespace variable reference."""
state = WorkflowState()
state.add_conversation_message({"role": "user", "content": "hello"})
result = state._eval_simple("Conversation.messages")
assert len(result) == 1
def test_workflow_inputs_reference(self):
"""Test Workflow.Inputs reference."""
state = WorkflowState(inputs={"name": "test"})
assert state._eval_simple("Workflow.Inputs.name") == "test"
class TestWorkflowStateParseFunctionArgs:
"""Tests for _parse_function_args helper."""
def test_simple_args(self):
"""Test parsing simple comma-separated args."""
state = WorkflowState()
args = state._parse_function_args("1, 2, 3")
assert args == ["1", "2", "3"]
def test_string_args_with_commas(self):
"""Test parsing string args containing commas."""
state = WorkflowState()
args = state._parse_function_args('"hello, world", "another"')
assert args == ['"hello, world"', '"another"']
def test_nested_function_args(self):
"""Test parsing nested function calls."""
state = WorkflowState()
args = state._parse_function_args("Concat(a, b), c")
assert args == ["Concat(a, b)", "c"]
def test_empty_args(self):
"""Test parsing empty args string."""
state = WorkflowState()
args = state._parse_function_args("")
assert args == []
def test_single_arg(self):
"""Test parsing single argument."""
state = WorkflowState()
args = state._parse_function_args("single")
assert args == ["single"]
def test_deeply_nested_parens(self):
"""Test parsing deeply nested parentheses."""
state = WorkflowState()
args = state._parse_function_args("Func1(Func2(a, b)), c")
assert args == ["Func1(Func2(a, b))", "c"]
class TestWorkflowStateEvalIfExpression:
"""Tests for eval_if_expression method."""
def test_dict_values_evaluated(self):
"""Test that dict values are recursively evaluated."""
state = WorkflowState()
state.set("Local.name", "World")
result = state.eval_if_expression({"greeting": "=Local.name", "static": "value"})
assert result == {"greeting": "World", "static": "value"}
def test_list_values_evaluated(self):
"""Test that list values are recursively evaluated."""
state = WorkflowState()
state.set("Local.val", 42)
result = state.eval_if_expression(["=Local.val", "static"])
assert result == [42, "static"]
def test_nested_dict_in_list(self):
"""Test nested dict in list is evaluated."""
state = WorkflowState()
state.set("Local.x", 10)
result = state.eval_if_expression([{"key": "=Local.x"}])
assert result == [{"key": 10}]
class TestWorkflowStateSetErrors:
"""Tests for set() error handling."""
def test_set_workflow_directly_raises(self):
"""Test that setting Workflow directly raises error."""
state = WorkflowState()
with pytest.raises(ValueError, match="Cannot set 'Workflow' directly"):
state.set("Workflow", "value")
def test_set_unknown_workflow_namespace_raises(self):
"""Test that setting unknown Workflow sub-namespace raises."""
state = WorkflowState()
with pytest.raises(ValueError, match="Unknown Workflow namespace"):
state.set("Workflow.Unknown.path", "value")
def test_set_namespace_root_raises(self):
"""Test that setting namespace root raises error."""
state = WorkflowState()
with pytest.raises(ValueError, match="Cannot replace entire namespace"):
state.set("Local", "value")
class TestWorkflowStateGetEdgeCases:
"""Tests for get() edge cases."""
def test_get_empty_path(self):
"""Test get with empty path returns default."""
state = WorkflowState()
assert state.get("", "default") == "default"
def test_get_unknown_namespace(self):
"""Test get from unknown namespace returns default."""
state = WorkflowState()
assert state.get("Unknown.path") is None
assert state.get("Unknown.path", "fallback") == "fallback"
def test_get_with_object_attribute(self):
"""Test get navigates object attributes."""
state = WorkflowState()
class MockObj:
attr = "attribute_value"
state.set("Local.obj", MockObj())
assert state.get("Local.obj.attr") == "attribute_value"
def test_get_unknown_workflow_subspace(self):
"""Test get from unknown Workflow sub-namespace."""
state = WorkflowState()
assert state.get("Workflow.Unknown.path") is None
class TestWorkflowStateConversationIdInit:
"""Tests that WorkflowState generates a real UUID for System.ConversationId."""
def test_conversation_id_is_not_default(self):
"""System.ConversationId should be a UUID, not 'default'."""
import uuid
state = WorkflowState()
conv_id = state.get("System.ConversationId")
assert conv_id is not None
assert conv_id != "default"
uuid.UUID(conv_id) # Raises ValueError if not a valid UUID
def test_conversations_dict_initialized(self):
"""System.conversations should contain an entry matching ConversationId."""
state = WorkflowState()
conv_id = state.get("System.ConversationId")
conversations = state.get("System.conversations")
assert conversations is not None
assert conv_id in conversations
assert conversations[conv_id]["id"] == conv_id
assert conversations[conv_id]["messages"] == []
def test_each_instance_generates_unique_id(self):
"""Each WorkflowState instance should have a different ConversationId."""
state1 = WorkflowState()
state2 = WorkflowState()
assert state1.get("System.ConversationId") != state2.get("System.ConversationId")
@@ -0,0 +1,29 @@
#
# Integration fixture: end-to-end HttpRequestAction round-trip using a
# stub HttpRequestHandler. Mirrors the .NET integration fixture in
# dotnet/tests/.../Workflows/HttpRequest.yaml.
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_http_request_test
actions:
# Set the repo owner used to form the request URL.
- kind: SetVariable
id: set_repo_owner
variable: Local.RepoOwner
value: dotnet
# Invoke the (stubbed) GitHub repo API.
- kind: HttpRequestAction
id: fetch_repo_info
conversationId: =System.ConversationId
method: GET
url: =Concatenate("https://api.github.com/repos/", Local.RepoOwner, "/runtime")
headers:
Accept: application/vnd.github+json
User-Agent: agent-framework-integration-test
response: Local.RepoInfo
responseHeaders: Local.RepoHeaders