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
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:
@@ -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
|
||||
Reference in New Issue
Block a user