mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: [BREAKING] simplify ag-ui run logic, fix mcp bugs, fix anthropic client issues in ag-ui (#3322)
* Refactor ag-ui to simplify flow * Refactoring * Fix backend tool * Update tests * Improvements * Fix mypy * Fixes * Fix json serialize errors
This commit is contained in:
committed by
GitHub
Unverified
parent
9f893a32a6
commit
5436354a83
@@ -420,17 +420,25 @@ async def test_tool_result_function_approval_rejected():
|
||||
|
||||
|
||||
async def test_thread_metadata_tracking():
|
||||
"""Test that thread metadata includes ag_ui_thread_id and ag_ui_run_id."""
|
||||
"""Test that thread metadata includes ag_ui_thread_id and ag_ui_run_id.
|
||||
|
||||
AG-UI internal metadata is stored in thread.metadata for orchestration,
|
||||
but filtered out before passing to the chat client's options.metadata.
|
||||
"""
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
thread_metadata: dict[str, Any] = {}
|
||||
captured_thread: dict[str, Any] = {}
|
||||
captured_options: dict[str, Any] = {}
|
||||
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
metadata = options.get("metadata")
|
||||
if metadata:
|
||||
thread_metadata.update(metadata)
|
||||
# Capture the thread object from kwargs
|
||||
thread = kwargs.get("thread")
|
||||
if thread and hasattr(thread, "metadata"):
|
||||
captured_thread["metadata"] = thread.metadata
|
||||
# Capture options to verify internal keys are NOT passed to chat client
|
||||
captured_options.update(options)
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
@@ -446,22 +454,37 @@ async def test_thread_metadata_tracking():
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
events.append(event)
|
||||
|
||||
# AG-UI internal metadata should be stored in thread.metadata
|
||||
thread_metadata = captured_thread.get("metadata", {})
|
||||
assert thread_metadata.get("ag_ui_thread_id") == "test_thread_123"
|
||||
assert thread_metadata.get("ag_ui_run_id") == "test_run_456"
|
||||
|
||||
# Internal metadata should NOT be passed to chat client options
|
||||
options_metadata = captured_options.get("metadata", {})
|
||||
assert "ag_ui_thread_id" not in options_metadata
|
||||
assert "ag_ui_run_id" not in options_metadata
|
||||
|
||||
|
||||
async def test_state_context_injection():
|
||||
"""Test that current state is injected into thread metadata."""
|
||||
"""Test that current state is injected into thread metadata.
|
||||
|
||||
AG-UI internal metadata (including current_state) is stored in thread.metadata
|
||||
for orchestration, but filtered out before passing to the chat client's options.metadata.
|
||||
"""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
|
||||
thread_metadata: dict[str, Any] = {}
|
||||
captured_thread: dict[str, Any] = {}
|
||||
captured_options: dict[str, Any] = {}
|
||||
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
metadata = options.get("metadata")
|
||||
if metadata:
|
||||
thread_metadata.update(metadata)
|
||||
# Capture the thread object from kwargs
|
||||
thread = kwargs.get("thread")
|
||||
if thread and hasattr(thread, "metadata"):
|
||||
captured_thread["metadata"] = thread.metadata
|
||||
# Capture options to verify internal keys are NOT passed to chat client
|
||||
captured_options.update(options)
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
@@ -479,11 +502,17 @@ async def test_state_context_injection():
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Current state should be stored in thread.metadata
|
||||
thread_metadata = captured_thread.get("metadata", {})
|
||||
current_state = thread_metadata.get("current_state")
|
||||
if isinstance(current_state, str):
|
||||
current_state = json.loads(current_state)
|
||||
assert current_state == {"document": "Test content"}
|
||||
|
||||
# Internal metadata should NOT be passed to chat client options
|
||||
options_metadata = captured_options.get("metadata", {})
|
||||
assert "current_state" not in options_metadata
|
||||
|
||||
|
||||
async def test_no_messages_provided():
|
||||
"""Test handling when no messages are provided."""
|
||||
@@ -595,48 +624,6 @@ async def test_json_decode_error_in_tool_result():
|
||||
assert len(tool_events) == 0
|
||||
|
||||
|
||||
async def test_suppressed_summary_with_document_state():
|
||||
"""Test suppressed summary uses document state for confirmation message."""
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy
|
||||
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Response")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
wrapper = AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
state_schema={"document": {"type": "string"}},
|
||||
predict_state_config={"document": {"tool": "write_doc", "tool_argument": "content"}},
|
||||
confirmation_strategy=DocumentWriterConfirmationStrategy(),
|
||||
)
|
||||
|
||||
# Simulate confirmation with document state
|
||||
tool_result: dict[str, Any] = {"accepted": True, "steps": []}
|
||||
input_data: dict[str, Any] = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "tool",
|
||||
"content": json.dumps(tool_result),
|
||||
"toolCallId": "confirm_123",
|
||||
}
|
||||
],
|
||||
"state": {"document": "This is the beginning of a document. It contains important information."},
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should generate fallback summary from document state
|
||||
text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"]
|
||||
assert len(text_events) > 0
|
||||
# Should contain some reference to the document
|
||||
full_text = "".join(e.delta for e in text_events)
|
||||
assert "written" in full_text.lower() or "document" in full_text.lower()
|
||||
|
||||
|
||||
async def test_agent_with_use_service_thread_is_false():
|
||||
"""Test that when use_service_thread is False, the AgentThread used to run the agent is NOT set to the service thread ID."""
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for backend tool rendering."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from ag_ui.core import (
|
||||
TextMessageContentEvent,
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallResultEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from agent_framework import AgentResponseUpdate, Content
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
|
||||
async def test_tool_call_flow():
|
||||
"""Test complete tool call flow: call -> args -> end -> result."""
|
||||
bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread")
|
||||
|
||||
# Step 1: Tool call starts
|
||||
tool_call = Content.from_function_call(
|
||||
call_id="weather-123",
|
||||
name="get_weather",
|
||||
arguments={"location": "Seattle"},
|
||||
)
|
||||
|
||||
update1 = AgentResponseUpdate(contents=[tool_call])
|
||||
events1 = await bridge.from_agent_run_update(update1)
|
||||
|
||||
# Should have: ToolCallStartEvent, ToolCallArgsEvent
|
||||
assert len(events1) == 2
|
||||
assert isinstance(events1[0], ToolCallStartEvent)
|
||||
assert isinstance(events1[1], ToolCallArgsEvent)
|
||||
|
||||
start_event = events1[0]
|
||||
assert start_event.tool_call_id == "weather-123"
|
||||
assert start_event.tool_call_name == "get_weather"
|
||||
|
||||
args_event = events1[1]
|
||||
assert "Seattle" in args_event.delta
|
||||
|
||||
# Step 2: Tool result comes back
|
||||
tool_result = Content.from_function_result(
|
||||
call_id="weather-123",
|
||||
result="Weather in Seattle: Rainy, 52°F",
|
||||
)
|
||||
|
||||
update2 = AgentResponseUpdate(contents=[tool_result])
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
|
||||
# Should have: ToolCallEndEvent, ToolCallResultEvent
|
||||
assert len(events2) == 2
|
||||
assert isinstance(events2[0], ToolCallEndEvent)
|
||||
assert isinstance(events2[1], ToolCallResultEvent)
|
||||
|
||||
end_event = events2[0]
|
||||
assert end_event.tool_call_id == "weather-123"
|
||||
|
||||
result_event = events2[1]
|
||||
assert result_event.tool_call_id == "weather-123"
|
||||
assert "Seattle" in result_event.content
|
||||
assert "Rainy" in result_event.content
|
||||
|
||||
|
||||
async def test_text_with_tool_call():
|
||||
"""Test agent response with both text and tool calls."""
|
||||
bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread")
|
||||
|
||||
# Agent says something then calls a tool
|
||||
text_content = Content.from_text(text="Let me check the weather for you.")
|
||||
tool_call = Content.from_function_call(
|
||||
call_id="weather-456",
|
||||
name="get_forecast",
|
||||
arguments={"location": "San Francisco", "days": 3},
|
||||
)
|
||||
|
||||
update = AgentResponseUpdate(contents=[text_content, tool_call])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should have: TextMessageStart, TextMessageContent, ToolCallStart, ToolCallArgs
|
||||
assert len(events) == 4
|
||||
|
||||
assert isinstance(events[0], TextMessageStartEvent)
|
||||
assert isinstance(events[1], TextMessageContentEvent)
|
||||
assert isinstance(events[2], ToolCallStartEvent)
|
||||
assert isinstance(events[3], ToolCallArgsEvent)
|
||||
|
||||
text_event = events[1]
|
||||
assert "check the weather" in text_event.delta
|
||||
|
||||
tool_start = events[2]
|
||||
assert tool_start.tool_call_name == "get_forecast"
|
||||
|
||||
|
||||
async def test_multiple_tool_results():
|
||||
"""Test handling multiple tool results in sequence."""
|
||||
bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread")
|
||||
|
||||
# Multiple tool results
|
||||
results = [
|
||||
Content.from_function_result(call_id="tool-1", result="Result 1"),
|
||||
Content.from_function_result(call_id="tool-2", result="Result 2"),
|
||||
Content.from_function_result(call_id="tool-3", result="Result 3"),
|
||||
]
|
||||
|
||||
update = AgentResponseUpdate(contents=results)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should have 3 pairs of ToolCallEndEvent + ToolCallResultEvent = 6 events
|
||||
assert len(events) == 6
|
||||
|
||||
# Verify the pattern: End, Result, End, Result, End, Result
|
||||
for i in range(3):
|
||||
end_idx = i * 2
|
||||
result_idx = i * 2 + 1
|
||||
|
||||
assert isinstance(events[end_idx], ToolCallEndEvent)
|
||||
assert isinstance(events[result_idx], ToolCallResultEvent)
|
||||
|
||||
end_event = cast(ToolCallEndEvent, events[end_idx])
|
||||
result_event = cast(ToolCallResultEvent, events[result_idx])
|
||||
|
||||
assert end_event.tool_call_id == f"tool-{i + 1}"
|
||||
assert result_event.tool_call_id == f"tool-{i + 1}"
|
||||
assert f"Result {i + 1}" in result_event.content
|
||||
@@ -1,275 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Comprehensive tests for all confirmation strategies."""
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_ag_ui._confirmation_strategies import (
|
||||
ConfirmationStrategy,
|
||||
DefaultConfirmationStrategy,
|
||||
DocumentWriterConfirmationStrategy,
|
||||
RecipeConfirmationStrategy,
|
||||
TaskPlannerConfirmationStrategy,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_steps() -> list[dict[str, str]]:
|
||||
"""Sample steps for testing approval messages."""
|
||||
return [
|
||||
{"description": "Step 1: Do something", "status": "enabled"},
|
||||
{"description": "Step 2: Do another thing", "status": "enabled"},
|
||||
{"description": "Step 3: Disabled step", "status": "disabled"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def all_enabled_steps() -> list[dict[str, str]]:
|
||||
"""All steps enabled."""
|
||||
return [
|
||||
{"description": "Task A", "status": "enabled"},
|
||||
{"description": "Task B", "status": "enabled"},
|
||||
{"description": "Task C", "status": "enabled"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_steps() -> list[dict[str, str]]:
|
||||
"""Empty steps list."""
|
||||
return []
|
||||
|
||||
|
||||
class TestDefaultConfirmationStrategy:
|
||||
"""Tests for DefaultConfirmationStrategy."""
|
||||
|
||||
def test_on_approval_accepted_with_enabled_steps(self, sample_steps: list[dict[str, str]]) -> None:
|
||||
strategy = DefaultConfirmationStrategy()
|
||||
message = strategy.on_approval_accepted(sample_steps)
|
||||
|
||||
assert "Executing 2 approved steps" in message
|
||||
assert "Step 1: Do something" in message
|
||||
assert "Step 2: Do another thing" in message
|
||||
assert "Step 3" not in message # Disabled step shouldn't appear
|
||||
assert "All steps completed successfully!" in message
|
||||
|
||||
def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps: list[dict[str, str]]) -> None:
|
||||
strategy = DefaultConfirmationStrategy()
|
||||
message = strategy.on_approval_accepted(all_enabled_steps)
|
||||
|
||||
assert "Executing 3 approved steps" in message
|
||||
assert "Task A" in message
|
||||
assert "Task B" in message
|
||||
assert "Task C" in message
|
||||
|
||||
def test_on_approval_accepted_with_empty_steps(self, empty_steps: list[dict[str, str]]) -> None:
|
||||
strategy = DefaultConfirmationStrategy()
|
||||
message = strategy.on_approval_accepted(empty_steps)
|
||||
|
||||
assert "Executing 0 approved steps" in message
|
||||
assert "All steps completed successfully!" in message
|
||||
|
||||
def test_on_approval_rejected(self, sample_steps: list[dict[str, str]]) -> None:
|
||||
strategy = DefaultConfirmationStrategy()
|
||||
message = strategy.on_approval_rejected(sample_steps)
|
||||
|
||||
assert "No problem!" in message
|
||||
assert "What would you like me to change" in message
|
||||
|
||||
def test_on_state_confirmed(self) -> None:
|
||||
strategy = DefaultConfirmationStrategy()
|
||||
message = strategy.on_state_confirmed()
|
||||
|
||||
assert "Changes confirmed" in message
|
||||
assert "successfully" in message
|
||||
|
||||
def test_on_state_rejected(self) -> None:
|
||||
strategy = DefaultConfirmationStrategy()
|
||||
message = strategy.on_state_rejected()
|
||||
|
||||
assert "No problem!" in message
|
||||
assert "What would you like me to change" in message
|
||||
|
||||
|
||||
class TestTaskPlannerConfirmationStrategy:
|
||||
"""Tests for TaskPlannerConfirmationStrategy."""
|
||||
|
||||
def test_on_approval_accepted_with_enabled_steps(self, sample_steps: list[dict[str, str]]) -> None:
|
||||
strategy = TaskPlannerConfirmationStrategy()
|
||||
message = strategy.on_approval_accepted(sample_steps)
|
||||
|
||||
assert "Executing your requested tasks" in message
|
||||
assert "1. Step 1: Do something" in message
|
||||
assert "2. Step 2: Do another thing" in message
|
||||
assert "Step 3" not in message
|
||||
assert "All tasks completed successfully!" in message
|
||||
|
||||
def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps: list[dict[str, str]]) -> None:
|
||||
strategy = TaskPlannerConfirmationStrategy()
|
||||
message = strategy.on_approval_accepted(all_enabled_steps)
|
||||
|
||||
assert "Executing your requested tasks" in message
|
||||
assert "1. Task A" in message
|
||||
assert "2. Task B" in message
|
||||
assert "3. Task C" in message
|
||||
|
||||
def test_on_approval_accepted_with_empty_steps(self, empty_steps: list[dict[str, str]]) -> None:
|
||||
strategy = TaskPlannerConfirmationStrategy()
|
||||
message = strategy.on_approval_accepted(empty_steps)
|
||||
|
||||
assert "Executing your requested tasks" in message
|
||||
assert "All tasks completed successfully!" in message
|
||||
|
||||
def test_on_approval_rejected(self, sample_steps: list[dict[str, str]]) -> None:
|
||||
strategy = TaskPlannerConfirmationStrategy()
|
||||
message = strategy.on_approval_rejected(sample_steps)
|
||||
|
||||
assert "No problem!" in message
|
||||
assert "revise the plan" in message
|
||||
|
||||
def test_on_state_confirmed(self) -> None:
|
||||
strategy = TaskPlannerConfirmationStrategy()
|
||||
message = strategy.on_state_confirmed()
|
||||
|
||||
assert "Tasks confirmed" in message
|
||||
assert "ready to execute" in message
|
||||
|
||||
def test_on_state_rejected(self) -> None:
|
||||
strategy = TaskPlannerConfirmationStrategy()
|
||||
message = strategy.on_state_rejected()
|
||||
|
||||
assert "No problem!" in message
|
||||
assert "adjust the task list" in message
|
||||
|
||||
|
||||
class TestRecipeConfirmationStrategy:
|
||||
"""Tests for RecipeConfirmationStrategy."""
|
||||
|
||||
def test_on_approval_accepted_with_enabled_steps(self, sample_steps: list[dict[str, str]]) -> None:
|
||||
strategy = RecipeConfirmationStrategy()
|
||||
message = strategy.on_approval_accepted(sample_steps)
|
||||
|
||||
assert "Updating your recipe" in message
|
||||
assert "1. Step 1: Do something" in message
|
||||
assert "2. Step 2: Do another thing" in message
|
||||
assert "Step 3" not in message
|
||||
assert "Recipe updated successfully!" in message
|
||||
|
||||
def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps: list[dict[str, str]]) -> None:
|
||||
strategy = RecipeConfirmationStrategy()
|
||||
message = strategy.on_approval_accepted(all_enabled_steps)
|
||||
|
||||
assert "Updating your recipe" in message
|
||||
assert "1. Task A" in message
|
||||
assert "2. Task B" in message
|
||||
assert "3. Task C" in message
|
||||
|
||||
def test_on_approval_accepted_with_empty_steps(self, empty_steps: list[dict[str, str]]) -> None:
|
||||
strategy = RecipeConfirmationStrategy()
|
||||
message = strategy.on_approval_accepted(empty_steps)
|
||||
|
||||
assert "Updating your recipe" in message
|
||||
assert "Recipe updated successfully!" in message
|
||||
|
||||
def test_on_approval_rejected(self, sample_steps: list[dict[str, str]]) -> None:
|
||||
strategy = RecipeConfirmationStrategy()
|
||||
message = strategy.on_approval_rejected(sample_steps)
|
||||
|
||||
assert "No problem!" in message
|
||||
assert "ingredients or steps" in message
|
||||
|
||||
def test_on_state_confirmed(self) -> None:
|
||||
strategy = RecipeConfirmationStrategy()
|
||||
message = strategy.on_state_confirmed()
|
||||
|
||||
assert "Recipe changes applied" in message
|
||||
assert "successfully" in message
|
||||
|
||||
def test_on_state_rejected(self) -> None:
|
||||
strategy = RecipeConfirmationStrategy()
|
||||
message = strategy.on_state_rejected()
|
||||
|
||||
assert "No problem!" in message
|
||||
assert "adjust in the recipe" in message
|
||||
|
||||
|
||||
class TestDocumentWriterConfirmationStrategy:
|
||||
"""Tests for DocumentWriterConfirmationStrategy."""
|
||||
|
||||
def test_on_approval_accepted_with_enabled_steps(self, sample_steps: list[dict[str, str]]) -> None:
|
||||
strategy = DocumentWriterConfirmationStrategy()
|
||||
message = strategy.on_approval_accepted(sample_steps)
|
||||
|
||||
assert "Applying your edits" in message
|
||||
assert "1. Step 1: Do something" in message
|
||||
assert "2. Step 2: Do another thing" in message
|
||||
assert "Step 3" not in message
|
||||
assert "Document updated successfully!" in message
|
||||
|
||||
def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps: list[dict[str, str]]) -> None:
|
||||
strategy = DocumentWriterConfirmationStrategy()
|
||||
message = strategy.on_approval_accepted(all_enabled_steps)
|
||||
|
||||
assert "Applying your edits" in message
|
||||
assert "1. Task A" in message
|
||||
assert "2. Task B" in message
|
||||
assert "3. Task C" in message
|
||||
|
||||
def test_on_approval_accepted_with_empty_steps(self, empty_steps: list[dict[str, str]]) -> None:
|
||||
strategy = DocumentWriterConfirmationStrategy()
|
||||
message = strategy.on_approval_accepted(empty_steps)
|
||||
|
||||
assert "Applying your edits" in message
|
||||
assert "Document updated successfully!" in message
|
||||
|
||||
def test_on_approval_rejected(self, sample_steps: list[dict[str, str]]) -> None:
|
||||
strategy = DocumentWriterConfirmationStrategy()
|
||||
message = strategy.on_approval_rejected(sample_steps)
|
||||
|
||||
assert "No problem!" in message
|
||||
assert "keep or modify" in message
|
||||
|
||||
def test_on_state_confirmed(self) -> None:
|
||||
strategy = DocumentWriterConfirmationStrategy()
|
||||
message = strategy.on_state_confirmed()
|
||||
|
||||
assert "Document edits applied!" in message
|
||||
|
||||
def test_on_state_rejected(self) -> None:
|
||||
strategy = DocumentWriterConfirmationStrategy()
|
||||
message = strategy.on_state_rejected()
|
||||
|
||||
assert "No problem!" in message
|
||||
assert "change about the document" in message
|
||||
|
||||
|
||||
class TestConfirmationStrategyInterface:
|
||||
"""Tests for ConfirmationStrategy abstract base class."""
|
||||
|
||||
def test_cannot_instantiate_abstract_class(self):
|
||||
"""Verify ConfirmationStrategy is abstract and cannot be instantiated."""
|
||||
with pytest.raises(TypeError):
|
||||
ConfirmationStrategy() # type: ignore
|
||||
|
||||
def test_all_strategies_implement_interface(self):
|
||||
"""Verify all concrete strategies implement the full interface."""
|
||||
strategies = [
|
||||
DefaultConfirmationStrategy(),
|
||||
TaskPlannerConfirmationStrategy(),
|
||||
RecipeConfirmationStrategy(),
|
||||
DocumentWriterConfirmationStrategy(),
|
||||
]
|
||||
|
||||
sample_steps = [{"description": "Test", "status": "enabled"}]
|
||||
|
||||
for strategy in strategies:
|
||||
# All should have these methods
|
||||
assert callable(strategy.on_approval_accepted)
|
||||
assert callable(strategy.on_approval_rejected)
|
||||
assert callable(strategy.on_state_confirmed)
|
||||
assert callable(strategy.on_state_rejected)
|
||||
|
||||
# All should return strings
|
||||
assert isinstance(strategy.on_approval_accepted(sample_steps), str)
|
||||
assert isinstance(strategy.on_approval_rejected(sample_steps), str)
|
||||
assert isinstance(strategy.on_state_confirmed(), str)
|
||||
assert isinstance(strategy.on_state_rejected(), str)
|
||||
@@ -1,236 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for document writer predictive state flow with confirm_changes."""
|
||||
|
||||
from ag_ui.core import EventType, StateDeltaEvent, ToolCallArgsEvent, ToolCallEndEvent, ToolCallStartEvent
|
||||
from agent_framework import AgentResponseUpdate, Content
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
|
||||
async def test_streaming_document_with_state_deltas():
|
||||
"""Test that streaming tool arguments emit progressive StateDeltaEvents."""
|
||||
predict_config = {
|
||||
"document": {"tool": "write_document_local", "tool_argument": "document"},
|
||||
}
|
||||
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
predict_state_config=predict_config,
|
||||
)
|
||||
|
||||
# Simulate streaming tool call - first chunk with name
|
||||
tool_call_start = Content.from_function_call(
|
||||
call_id="call_123",
|
||||
name="write_document_local",
|
||||
arguments='{"document":"Once',
|
||||
)
|
||||
update1 = AgentResponseUpdate(contents=[tool_call_start])
|
||||
events1 = await bridge.from_agent_run_update(update1)
|
||||
|
||||
# Should have ToolCallStartEvent and ToolCallArgsEvent
|
||||
assert any(e.type == EventType.TOOL_CALL_START for e in events1)
|
||||
assert any(e.type == EventType.TOOL_CALL_ARGS for e in events1)
|
||||
|
||||
# Second chunk - incomplete JSON, should try partial extraction
|
||||
tool_call_chunk2 = Content.from_function_call(
|
||||
call_id="call_123", name="write_document_local", arguments=" upon a time"
|
||||
)
|
||||
update2 = AgentResponseUpdate(contents=[tool_call_chunk2])
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
|
||||
# Should emit StateDeltaEvent with partial document
|
||||
state_deltas = [e for e in events2 if isinstance(e, StateDeltaEvent)]
|
||||
assert len(state_deltas) >= 1
|
||||
|
||||
# Check JSON Patch format
|
||||
delta = state_deltas[0]
|
||||
assert isinstance(delta.delta, list)
|
||||
assert len(delta.delta) > 0
|
||||
assert delta.delta[0]["op"] == "replace"
|
||||
assert delta.delta[0]["path"] == "/document"
|
||||
assert "Once upon a time" in delta.delta[0]["value"]
|
||||
|
||||
|
||||
async def test_confirm_changes_emission():
|
||||
"""Test that confirm_changes tool call is emitted after predictive tool completion."""
|
||||
predict_config = {
|
||||
"document": {"tool": "write_document_local", "tool_argument": "document"},
|
||||
}
|
||||
|
||||
current_state: dict[str, str] = {}
|
||||
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
predict_state_config=predict_config,
|
||||
current_state=current_state,
|
||||
)
|
||||
|
||||
# Set current tool name (simulating earlier tool call start)
|
||||
bridge.current_tool_call_name = "write_document_local"
|
||||
bridge.pending_state_updates = {"document": "A short story"}
|
||||
|
||||
# Tool result
|
||||
tool_result = Content.from_function_result(
|
||||
call_id="call_123",
|
||||
result="Document written.",
|
||||
)
|
||||
|
||||
update = AgentResponseUpdate(contents=[tool_result])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should have: ToolCallEndEvent, ToolCallResultEvent, StateSnapshotEvent, confirm_changes sequence
|
||||
assert any(e.type == EventType.TOOL_CALL_END for e in events)
|
||||
assert any(e.type == EventType.TOOL_CALL_RESULT for e in events)
|
||||
assert any(e.type == EventType.STATE_SNAPSHOT for e in events)
|
||||
|
||||
# Check for confirm_changes tool call
|
||||
confirm_starts = [e for e in events if isinstance(e, ToolCallStartEvent) and e.tool_call_name == "confirm_changes"]
|
||||
assert len(confirm_starts) == 1
|
||||
|
||||
confirm_args = [e for e in events if isinstance(e, ToolCallArgsEvent) and e.delta == "{}"]
|
||||
assert len(confirm_args) >= 1
|
||||
|
||||
confirm_ends = [e for e in events if isinstance(e, ToolCallEndEvent)]
|
||||
# At least 2: one for write_document_local, one for confirm_changes
|
||||
assert len(confirm_ends) >= 2
|
||||
|
||||
# Check that stop flag is set
|
||||
assert bridge.should_stop_after_confirm is True
|
||||
|
||||
|
||||
async def test_text_suppression_before_confirm():
|
||||
"""Test that text messages are suppressed when confirm_changes is pending."""
|
||||
predict_config = {
|
||||
"document": {"tool": "write_document_local", "tool_argument": "document"},
|
||||
}
|
||||
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
predict_state_config=predict_config,
|
||||
)
|
||||
|
||||
# Set flag indicating we're waiting for confirmation
|
||||
bridge.should_stop_after_confirm = True
|
||||
|
||||
# Text content that should be suppressed
|
||||
text = Content.from_text(text="I have written a story about pirates.")
|
||||
update = AgentResponseUpdate(contents=[text])
|
||||
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should NOT emit TextMessageContentEvent
|
||||
text_events = [e for e in events if e.type == EventType.TEXT_MESSAGE_CONTENT]
|
||||
assert len(text_events) == 0
|
||||
|
||||
# But should save the text
|
||||
assert bridge.suppressed_summary == "I have written a story about pirates."
|
||||
|
||||
|
||||
async def test_no_confirm_for_non_predictive_tools():
|
||||
"""Test that confirm_changes is NOT emitted for regular tool calls."""
|
||||
predict_config = {
|
||||
"document": {"tool": "write_document_local", "tool_argument": "document"},
|
||||
}
|
||||
|
||||
current_state: dict[str, str] = {}
|
||||
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
predict_state_config=predict_config,
|
||||
current_state=current_state,
|
||||
)
|
||||
|
||||
# Different tool (not in predict_state_config)
|
||||
bridge.current_tool_call_name = "get_weather"
|
||||
|
||||
tool_result = Content.from_function_result(
|
||||
call_id="call_456",
|
||||
result="Sunny, 72°F",
|
||||
)
|
||||
|
||||
update = AgentResponseUpdate(contents=[tool_result])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should NOT have confirm_changes
|
||||
confirm_starts = [e for e in events if isinstance(e, ToolCallStartEvent) and e.tool_call_name == "confirm_changes"]
|
||||
assert len(confirm_starts) == 0
|
||||
|
||||
# Stop flag should NOT be set
|
||||
assert bridge.should_stop_after_confirm is False
|
||||
|
||||
|
||||
async def test_state_delta_deduplication():
|
||||
"""Test that duplicate state values don't emit multiple StateDeltaEvents."""
|
||||
predict_config = {
|
||||
"document": {"tool": "write_document_local", "tool_argument": "document"},
|
||||
}
|
||||
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
predict_state_config=predict_config,
|
||||
)
|
||||
|
||||
# First tool call with document
|
||||
tool_call1 = Content.from_function_call(
|
||||
call_id="call_1",
|
||||
name="write_document_local",
|
||||
arguments='{"document":"Same text"}',
|
||||
)
|
||||
update1 = AgentResponseUpdate(contents=[tool_call1])
|
||||
events1 = await bridge.from_agent_run_update(update1)
|
||||
|
||||
# Count state deltas
|
||||
state_deltas_1 = [e for e in events1 if isinstance(e, StateDeltaEvent)]
|
||||
assert len(state_deltas_1) >= 1
|
||||
|
||||
# Second tool call with SAME document (shouldn't emit new delta)
|
||||
bridge.current_tool_call_name = "write_document_local"
|
||||
tool_call2 = Content.from_function_call(
|
||||
call_id="call_2",
|
||||
name="write_document_local",
|
||||
arguments='{"document":"Same text"}', # Identical content
|
||||
)
|
||||
update2 = AgentResponseUpdate(contents=[tool_call2])
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
|
||||
# Should NOT emit state delta (same value)
|
||||
state_deltas_2 = [e for e in events2 if e.type == EventType.STATE_DELTA]
|
||||
assert len(state_deltas_2) == 0
|
||||
|
||||
|
||||
async def test_predict_state_config_multiple_fields():
|
||||
"""Test predictive state with multiple state fields."""
|
||||
predict_config = {
|
||||
"title": {"tool": "create_post", "tool_argument": "title"},
|
||||
"content": {"tool": "create_post", "tool_argument": "body"},
|
||||
}
|
||||
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
predict_state_config=predict_config,
|
||||
)
|
||||
|
||||
# Tool call with both fields
|
||||
tool_call = Content.from_function_call(
|
||||
call_id="call_999",
|
||||
name="create_post",
|
||||
arguments='{"title":"My Post","body":"Post content"}',
|
||||
)
|
||||
update = AgentResponseUpdate(contents=[tool_call])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should emit StateDeltaEvent for both fields
|
||||
state_deltas = [e for e in events if isinstance(e, StateDeltaEvent)]
|
||||
assert len(state_deltas) >= 2
|
||||
|
||||
# Check both fields are present
|
||||
paths = [delta.delta[0]["path"] for delta in state_deltas]
|
||||
assert "/title" in paths
|
||||
assert "/content" in paths
|
||||
@@ -1,917 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Comprehensive tests for AgentFrameworkEventBridge (_events.py)."""
|
||||
|
||||
import json
|
||||
|
||||
from agent_framework import (
|
||||
AgentResponseUpdate,
|
||||
Content,
|
||||
)
|
||||
|
||||
|
||||
async def test_basic_text_message_conversion():
|
||||
"""Test basic TextContent to AG-UI events."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 2
|
||||
assert events[0].type == "TEXT_MESSAGE_START"
|
||||
assert events[0].role == "assistant"
|
||||
assert events[1].type == "TEXT_MESSAGE_CONTENT"
|
||||
assert events[1].delta == "Hello"
|
||||
|
||||
|
||||
async def test_text_message_streaming():
|
||||
"""Test streaming TextContent with multiple chunks."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update1 = AgentResponseUpdate(contents=[Content.from_text(text="Hello ")])
|
||||
update2 = AgentResponseUpdate(contents=[Content.from_text(text="world")])
|
||||
|
||||
events1 = await bridge.from_agent_run_update(update1)
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
|
||||
# First update: START + CONTENT
|
||||
assert len(events1) == 2
|
||||
assert events1[0].type == "TEXT_MESSAGE_START"
|
||||
assert events1[1].delta == "Hello "
|
||||
|
||||
# Second update: just CONTENT (same message)
|
||||
assert len(events2) == 1
|
||||
assert events2[0].type == "TEXT_MESSAGE_CONTENT"
|
||||
assert events2[0].delta == "world"
|
||||
|
||||
# Both content events should have same message_id
|
||||
assert events1[1].message_id == events2[0].message_id
|
||||
|
||||
|
||||
async def test_skip_text_content_for_structured_outputs():
|
||||
"""Test that text content is skipped when skip_text_content=True."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread", skip_text_content=True)
|
||||
|
||||
update = AgentResponseUpdate(contents=[Content.from_text(text='{"result": "data"}')])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# No events should be emitted
|
||||
assert len(events) == 0
|
||||
|
||||
|
||||
async def test_skip_text_content_for_empty_text():
|
||||
"""Test streaming TextContent with empty chunks."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update1 = AgentResponseUpdate(contents=[Content.from_text(text="Hello ")])
|
||||
update2 = AgentResponseUpdate(contents=[Content.from_text(text="")]) # Empty chunk
|
||||
update3 = AgentResponseUpdate(contents=[Content.from_text(text="world")])
|
||||
|
||||
events1 = await bridge.from_agent_run_update(update1)
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
events3 = await bridge.from_agent_run_update(update3)
|
||||
|
||||
# First update: START + CONTENT
|
||||
assert len(events1) == 2
|
||||
assert events1[0].type == "TEXT_MESSAGE_START"
|
||||
assert events1[1].delta == "Hello "
|
||||
|
||||
# Second update: should skip empty chunk, no events
|
||||
assert len(events2) == 0
|
||||
|
||||
# Third update: just CONTENT (same message)
|
||||
assert len(events3) == 1
|
||||
assert events3[0].type == "TEXT_MESSAGE_CONTENT"
|
||||
assert events3[0].delta == "world"
|
||||
|
||||
# Both content events should have same message_id
|
||||
assert events1[1].message_id == events3[0].message_id
|
||||
|
||||
|
||||
async def test_tool_call_with_name():
|
||||
"""Test FunctionCallContent with name emits ToolCallStartEvent."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentResponseUpdate(contents=[Content.from_function_call(name="search_web", call_id="call_123")])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "TOOL_CALL_START"
|
||||
assert events[0].tool_call_name == "search_web"
|
||||
assert events[0].tool_call_id == "call_123"
|
||||
|
||||
|
||||
async def test_tool_call_streaming_args():
|
||||
"""Test streaming tool call arguments."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
# First chunk: name only
|
||||
update1 = AgentResponseUpdate(contents=[Content.from_function_call(name="search_web", call_id="call_123")])
|
||||
events1 = await bridge.from_agent_run_update(update1)
|
||||
|
||||
# Second chunk: arguments chunk 1 (name can be empty string for continuation)
|
||||
update2 = AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="", call_id="call_123", arguments='{"query": "')]
|
||||
)
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
|
||||
# Third chunk: arguments chunk 2
|
||||
update3 = AgentResponseUpdate(contents=[Content.from_function_call(name="", call_id="call_123", arguments='AI"}')])
|
||||
events3 = await bridge.from_agent_run_update(update3)
|
||||
|
||||
# First update: ToolCallStartEvent
|
||||
assert len(events1) == 1
|
||||
assert events1[0].type == "TOOL_CALL_START"
|
||||
|
||||
# Second update: ToolCallArgsEvent
|
||||
assert len(events2) == 1
|
||||
assert events2[0].type == "TOOL_CALL_ARGS"
|
||||
assert events2[0].delta == '{"query": "'
|
||||
|
||||
# Third update: ToolCallArgsEvent
|
||||
assert len(events3) == 1
|
||||
assert events3[0].type == "TOOL_CALL_ARGS"
|
||||
assert events3[0].delta == 'AI"}'
|
||||
|
||||
# All should have same tool_call_id
|
||||
assert events1[0].tool_call_id == events2[0].tool_call_id == events3[0].tool_call_id
|
||||
|
||||
|
||||
async def test_streaming_tool_call_no_duplicate_start_events():
|
||||
"""Test that streaming tool calls emit exactly one ToolCallStartEvent.
|
||||
|
||||
This is a regression test for the Anthropic streaming fix where input_json_delta
|
||||
events were incorrectly passing the tool name, causing duplicate ToolCallStartEvents.
|
||||
|
||||
The correct behavior is:
|
||||
- Initial FunctionCallContent with name -> emits ToolCallStartEvent
|
||||
- Subsequent FunctionCallContent with name="" -> emits only ToolCallArgsEvent
|
||||
|
||||
See: https://github.com/microsoft/agent-framework/pull/3051
|
||||
"""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
# Simulate streaming tool call: first chunk has name, subsequent chunks have name=""
|
||||
update1 = AgentResponseUpdate(contents=[Content.from_function_call(name="get_weather", call_id="call_789")])
|
||||
update2 = AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="", call_id="call_789", arguments='{"loc":')]
|
||||
)
|
||||
update3 = AgentResponseUpdate(contents=[Content.from_function_call(name="", call_id="call_789", arguments='"SF"}')])
|
||||
|
||||
events1 = await bridge.from_agent_run_update(update1)
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
events3 = await bridge.from_agent_run_update(update3)
|
||||
|
||||
# Count all ToolCallStartEvents - should be exactly 1
|
||||
all_events = events1 + events2 + events3
|
||||
tool_call_start_count = sum(1 for e in all_events if e.type == "TOOL_CALL_START")
|
||||
assert tool_call_start_count == 1, f"Expected 1 ToolCallStartEvent, got {tool_call_start_count}"
|
||||
|
||||
# Verify event types
|
||||
assert events1[0].type == "TOOL_CALL_START"
|
||||
assert events2[0].type == "TOOL_CALL_ARGS"
|
||||
assert events3[0].type == "TOOL_CALL_ARGS"
|
||||
|
||||
|
||||
async def test_tool_result_with_dict():
|
||||
"""Test FunctionResultContent with dict result."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
result_data = {"status": "success", "count": 42}
|
||||
update = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_123", result=result_data)])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should emit ToolCallEndEvent + ToolCallResultEvent
|
||||
assert len(events) == 2
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[0].tool_call_id == "call_123"
|
||||
|
||||
assert events[1].type == "TOOL_CALL_RESULT"
|
||||
assert events[1].tool_call_id == "call_123"
|
||||
assert events[1].role == "tool"
|
||||
# Result should be JSON-serialized
|
||||
assert json.loads(events[1].content) == result_data
|
||||
|
||||
|
||||
async def test_tool_result_with_string():
|
||||
"""Test FunctionResultContent with string result."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_123", result="Search complete")])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 2
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[1].type == "TOOL_CALL_RESULT"
|
||||
assert events[1].content == "Search complete"
|
||||
|
||||
|
||||
async def test_tool_result_with_none():
|
||||
"""Test FunctionResultContent with None result."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_123", result=None)])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 2
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[1].type == "TOOL_CALL_RESULT"
|
||||
# prepare_function_call_results serializes None as JSON "null"
|
||||
assert events[1].content == "null"
|
||||
|
||||
|
||||
async def test_multiple_tool_results_in_sequence():
|
||||
"""Test multiple tool results processed sequentially."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_result(call_id="call_1", result="Result 1"),
|
||||
Content.from_function_result(call_id="call_2", result="Result 2"),
|
||||
]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Each result emits: ToolCallEndEvent + ToolCallResultEvent = 4 events total
|
||||
assert len(events) == 4
|
||||
assert events[0].tool_call_id == "call_1"
|
||||
assert events[1].tool_call_id == "call_1"
|
||||
assert events[2].tool_call_id == "call_2"
|
||||
assert events[3].tool_call_id == "call_2"
|
||||
|
||||
|
||||
async def test_function_approval_request_basic():
|
||||
"""Test FunctionApprovalRequestContent conversion."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
# Set require_confirmation=False to test just the function_approval_request event
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
require_confirmation=False,
|
||||
)
|
||||
|
||||
func_call = Content.from_function_call(
|
||||
call_id="call_123",
|
||||
name="send_email",
|
||||
arguments={"to": "user@example.com", "subject": "Test"},
|
||||
)
|
||||
approval = Content.from_function_approval_request(
|
||||
id="approval_001",
|
||||
function_call=func_call,
|
||||
)
|
||||
|
||||
update = AgentResponseUpdate(contents=[approval])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should emit: ToolCallEndEvent + CustomEvent
|
||||
assert len(events) == 2
|
||||
|
||||
# First: ToolCallEndEvent to close the tool call
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[0].tool_call_id == "call_123"
|
||||
|
||||
# Second: CustomEvent with approval details
|
||||
assert events[1].type == "CUSTOM"
|
||||
assert events[1].name == "function_approval_request"
|
||||
assert events[1].value["id"] == "approval_001"
|
||||
assert events[1].value["function_call"]["name"] == "send_email"
|
||||
|
||||
|
||||
async def test_empty_predict_state_config():
|
||||
"""Test behavior with no predictive state configuration."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
predict_state_config={}, # Empty config
|
||||
)
|
||||
|
||||
# Tool call with arguments
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(name="write_doc", call_id="call_1", arguments='{"content": "test"}'),
|
||||
Content.from_function_result(call_id="call_1", result="Done"),
|
||||
]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should NOT emit StateDeltaEvent or confirm_changes
|
||||
event_types = [e.type for e in events]
|
||||
assert "STATE_DELTA" not in event_types
|
||||
assert "STATE_SNAPSHOT" not in event_types
|
||||
|
||||
# Should have: ToolCallStart, ToolCallArgs, ToolCallEnd, ToolCallResult
|
||||
assert event_types == [
|
||||
"TOOL_CALL_START",
|
||||
"TOOL_CALL_ARGS",
|
||||
"TOOL_CALL_END",
|
||||
"TOOL_CALL_RESULT",
|
||||
]
|
||||
|
||||
|
||||
async def test_tool_not_in_predict_state_config():
|
||||
"""Test tool that doesn't match any predict_state_config entry."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
predict_state_config={
|
||||
"document": {"tool": "write_document", "tool_argument": "content"},
|
||||
},
|
||||
)
|
||||
|
||||
# Different tool name
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(name="search_web", call_id="call_1", arguments='{"query": "AI"}'),
|
||||
Content.from_function_result(call_id="call_1", result="Results"),
|
||||
]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should NOT emit StateDeltaEvent or confirm_changes
|
||||
event_types = [e.type for e in events]
|
||||
assert "STATE_DELTA" not in event_types
|
||||
assert "STATE_SNAPSHOT" not in event_types
|
||||
|
||||
|
||||
async def test_state_management_tracking():
|
||||
"""Test current_state and pending_state_updates tracking."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
initial_state = {"document": ""}
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
predict_state_config={
|
||||
"document": {"tool": "write_doc", "tool_argument": "content"},
|
||||
},
|
||||
current_state=initial_state,
|
||||
)
|
||||
|
||||
# Streaming tool call
|
||||
update1 = AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(name="write_doc", call_id="call_1"),
|
||||
Content.from_function_call(name="", call_id="call_1", arguments='{"content": "Hello"}'),
|
||||
]
|
||||
)
|
||||
await bridge.from_agent_run_update(update1)
|
||||
|
||||
# Check pending_state_updates was populated
|
||||
assert "document" in bridge.pending_state_updates
|
||||
assert bridge.pending_state_updates["document"] == "Hello"
|
||||
|
||||
# Tool result should update current_state
|
||||
update2 = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_1", result="Done")])
|
||||
await bridge.from_agent_run_update(update2)
|
||||
|
||||
# current_state should be updated
|
||||
assert bridge.current_state["document"] == "Hello"
|
||||
|
||||
# pending_state_updates should be cleared
|
||||
assert len(bridge.pending_state_updates) == 0
|
||||
|
||||
|
||||
async def test_wildcard_tool_argument():
|
||||
"""Test tool_argument='*' uses all arguments as state value."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
predict_state_config={
|
||||
"recipe": {"tool": "create_recipe", "tool_argument": "*"},
|
||||
},
|
||||
current_state={},
|
||||
)
|
||||
|
||||
# Complete tool call with dict arguments
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="create_recipe",
|
||||
call_id="call_1",
|
||||
arguments={"title": "Pasta", "ingredients": ["pasta", "sauce"]},
|
||||
),
|
||||
Content.from_function_result(call_id="call_1", result="Created"),
|
||||
]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Find StateDeltaEvent
|
||||
delta_events = [e for e in events if e.type == "STATE_DELTA"]
|
||||
assert len(delta_events) > 0
|
||||
|
||||
# Value should be the entire arguments dict
|
||||
delta = delta_events[0].delta[0]
|
||||
assert delta["path"] == "/recipe"
|
||||
assert delta["value"] == {"title": "Pasta", "ingredients": ["pasta", "sauce"]}
|
||||
|
||||
|
||||
async def test_run_lifecycle_events():
|
||||
"""Test RunStartedEvent and RunFinishedEvent creation."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
started = bridge.create_run_started_event()
|
||||
assert started.type == "RUN_STARTED"
|
||||
assert started.run_id == "test_run"
|
||||
assert started.thread_id == "test_thread"
|
||||
|
||||
finished = bridge.create_run_finished_event(result={"status": "complete"})
|
||||
assert finished.type == "RUN_FINISHED"
|
||||
assert finished.run_id == "test_run"
|
||||
assert finished.thread_id == "test_thread"
|
||||
assert finished.result == {"status": "complete"}
|
||||
|
||||
|
||||
async def test_message_lifecycle_events():
|
||||
"""Test TextMessageStartEvent and TextMessageEndEvent creation."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
start = bridge.create_message_start_event("msg_123", role="assistant")
|
||||
assert start.type == "TEXT_MESSAGE_START"
|
||||
assert start.message_id == "msg_123"
|
||||
assert start.role == "assistant"
|
||||
|
||||
end = bridge.create_message_end_event("msg_123")
|
||||
assert end.type == "TEXT_MESSAGE_END"
|
||||
assert end.message_id == "msg_123"
|
||||
|
||||
|
||||
async def test_state_event_creation():
|
||||
"""Test StateSnapshotEvent and StateDeltaEvent creation helpers."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
# StateSnapshotEvent
|
||||
snapshot = bridge.create_state_snapshot_event({"document": "content"})
|
||||
assert snapshot.type == "STATE_SNAPSHOT"
|
||||
assert snapshot.snapshot == {"document": "content"}
|
||||
|
||||
# StateDeltaEvent with JSON Patch
|
||||
delta = bridge.create_state_delta_event([{"op": "replace", "path": "/document", "value": "new content"}])
|
||||
assert delta.type == "STATE_DELTA"
|
||||
assert len(delta.delta) == 1
|
||||
assert delta.delta[0]["op"] == "replace"
|
||||
assert delta.delta[0]["path"] == "/document"
|
||||
assert delta.delta[0]["value"] == "new content"
|
||||
|
||||
|
||||
async def test_state_snapshot_after_tool_result():
|
||||
"""Test StateSnapshotEvent emission after tool result with pending updates."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
predict_state_config={
|
||||
"document": {"tool": "write_doc", "tool_argument": "content"},
|
||||
},
|
||||
current_state={"document": ""},
|
||||
)
|
||||
|
||||
# Tool call with streaming args
|
||||
update1 = AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(name="write_doc", call_id="call_1"),
|
||||
Content.from_function_call(name="", call_id="call_1", arguments='{"content": "Test"}'),
|
||||
]
|
||||
)
|
||||
await bridge.from_agent_run_update(update1)
|
||||
|
||||
# Tool result should trigger StateSnapshotEvent
|
||||
update2 = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_1", result="Done")])
|
||||
events = await bridge.from_agent_run_update(update2)
|
||||
|
||||
# Should have: ToolCallEnd, ToolCallResult, StateSnapshot, ToolCallStart (confirm_changes), ToolCallArgs, ToolCallEnd
|
||||
snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"]
|
||||
assert len(snapshot_events) == 1
|
||||
assert snapshot_events[0].snapshot["document"] == "Test"
|
||||
|
||||
|
||||
async def test_message_id_persistence_across_chunks():
|
||||
"""Test that message_id persists across multiple text chunks."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
# First chunk
|
||||
update1 = AgentResponseUpdate(contents=[Content.from_text(text="Hello ")])
|
||||
events1 = await bridge.from_agent_run_update(update1)
|
||||
message_id = events1[0].message_id
|
||||
|
||||
# Second chunk
|
||||
update2 = AgentResponseUpdate(contents=[Content.from_text(text="world")])
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
|
||||
# Should use same message_id
|
||||
assert events2[0].message_id == message_id
|
||||
assert bridge.current_message_id == message_id
|
||||
|
||||
|
||||
async def test_tool_call_id_tracking():
|
||||
"""Test tool_call_id tracking across streaming chunks."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
# First chunk with name
|
||||
update1 = AgentResponseUpdate(contents=[Content.from_function_call(name="search", call_id="call_1")])
|
||||
await bridge.from_agent_run_update(update1)
|
||||
|
||||
assert bridge.current_tool_call_id == "call_1"
|
||||
assert bridge.current_tool_call_name == "search"
|
||||
|
||||
# Second chunk with args but no name
|
||||
update2 = AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="", call_id="call_1", arguments='{"q":"AI"}')]
|
||||
)
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
|
||||
# Should still track same tool call
|
||||
assert bridge.current_tool_call_id == "call_1"
|
||||
assert events2[0].tool_call_id == "call_1"
|
||||
|
||||
|
||||
async def test_tool_name_reset_after_result():
|
||||
"""Test current_tool_call_name is reset after tool result."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
predict_state_config={
|
||||
"document": {"tool": "write_doc", "tool_argument": "content"},
|
||||
},
|
||||
)
|
||||
|
||||
# Tool call
|
||||
update1 = AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(name="write_doc", call_id="call_1"),
|
||||
Content.from_function_call(name="", call_id="call_1", arguments='{"content": "Test"}'),
|
||||
]
|
||||
)
|
||||
await bridge.from_agent_run_update(update1)
|
||||
|
||||
assert bridge.current_tool_call_name == "write_doc"
|
||||
|
||||
# Tool result with predictive state (should trigger confirm_changes and reset)
|
||||
update2 = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_1", result="Done")])
|
||||
await bridge.from_agent_run_update(update2)
|
||||
|
||||
# Tool name should be reset
|
||||
assert bridge.current_tool_call_name is None
|
||||
|
||||
|
||||
async def test_function_approval_with_wildcard_argument():
|
||||
"""Test function approval with wildcard * argument."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
predict_state_config={
|
||||
"payload": {"tool": "submit", "tool_argument": "*"},
|
||||
},
|
||||
)
|
||||
|
||||
approval_content = Content.from_function_approval_request(
|
||||
id="approval_1",
|
||||
function_call=Content.from_function_call(
|
||||
name="submit", call_id="call_1", arguments='{"key1": "value1", "key2": "value2"}'
|
||||
),
|
||||
)
|
||||
|
||||
update = AgentResponseUpdate(contents=[approval_content])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should emit StateSnapshotEvent with entire parsed args as value
|
||||
snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"]
|
||||
assert len(snapshot_events) == 1
|
||||
assert snapshot_events[0].snapshot["payload"] == {"key1": "value1", "key2": "value2"}
|
||||
|
||||
|
||||
async def test_function_approval_missing_argument():
|
||||
"""Test function approval when specified argument is not in parsed args."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
predict_state_config={
|
||||
"data": {"tool": "process", "tool_argument": "missing_field"},
|
||||
},
|
||||
)
|
||||
|
||||
approval_content = Content.from_function_approval_request(
|
||||
id="approval_1",
|
||||
function_call=Content.from_function_call(
|
||||
name="process", call_id="call_1", arguments='{"other_field": "value"}'
|
||||
),
|
||||
)
|
||||
|
||||
update = AgentResponseUpdate(contents=[approval_content])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should not emit StateSnapshotEvent since argument not found
|
||||
snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"]
|
||||
assert len(snapshot_events) == 0
|
||||
|
||||
|
||||
async def test_empty_predict_state_config_no_deltas():
|
||||
"""Test with empty predict_state_config (no predictive updates)."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread", predict_state_config={})
|
||||
|
||||
# Tool call with arguments
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(name="search", call_id="call_1"),
|
||||
Content.from_function_call(name="", call_id="call_1", arguments='{"query": "test"}'),
|
||||
]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should not emit any StateDeltaEvents
|
||||
delta_events = [e for e in events if e.type == "STATE_DELTA"]
|
||||
assert len(delta_events) == 0
|
||||
|
||||
|
||||
async def test_tool_with_no_matching_config():
|
||||
"""Test tool call for tool not in predict_state_config."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
predict_state_config={"document": {"tool": "write_doc", "tool_argument": "content"}},
|
||||
)
|
||||
|
||||
# Tool call for different tool
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(name="search_web", call_id="call_1"),
|
||||
Content.from_function_call(name="", call_id="call_1", arguments='{"query": "test"}'),
|
||||
]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should not emit StateDeltaEvents
|
||||
delta_events = [e for e in events if e.type == "STATE_DELTA"]
|
||||
assert len(delta_events) == 0
|
||||
|
||||
|
||||
async def test_tool_call_without_name_or_id():
|
||||
"""Test handling FunctionCallContent with no name and no call_id."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
# This should not crash but log an error
|
||||
update = AgentResponseUpdate(contents=[Content.from_function_call(name="", call_id="", arguments='{"arg": "val"}')])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should emit ToolCallArgsEvent with generated ID
|
||||
assert len(events) >= 1
|
||||
|
||||
|
||||
async def test_state_delta_count_logging():
|
||||
"""Test that state delta count increments and logs at intervals."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
predict_state_config={"doc": {"tool": "write", "tool_argument": "text"}},
|
||||
)
|
||||
|
||||
# Emit multiple state deltas with different content each time
|
||||
for i in range(15):
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(name="", call_id="call_1", arguments=f'{{"text": "Content variation {i}"}}'),
|
||||
]
|
||||
)
|
||||
# Set the tool name to match config
|
||||
bridge.current_tool_call_name = "write"
|
||||
await bridge.from_agent_run_update(update)
|
||||
|
||||
# State delta count should have incremented (one per unique state update)
|
||||
assert bridge.state_delta_count >= 1
|
||||
|
||||
|
||||
# Tests for list type tool results (MCP tool serialization)
|
||||
|
||||
|
||||
async def test_tool_result_with_empty_list():
|
||||
"""Test FunctionResultContent with empty list result."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_123", result=[])])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 2
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[1].type == "TOOL_CALL_RESULT"
|
||||
# Empty list serializes as JSON empty array
|
||||
assert events[1].content == "[]"
|
||||
|
||||
|
||||
async def test_tool_result_with_single_text_content():
|
||||
"""Test FunctionResultContent with single TextContent-like item (MCP tool result)."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
@dataclass
|
||||
class MockTextContent:
|
||||
text: str
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentResponseUpdate(
|
||||
contents=[Content.from_function_result(call_id="call_123", result=[MockTextContent("Hello from MCP tool!")])]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 2
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[1].type == "TOOL_CALL_RESULT"
|
||||
# TextContent text is extracted and serialized as JSON array
|
||||
assert events[1].content == '["Hello from MCP tool!"]'
|
||||
|
||||
|
||||
async def test_tool_result_with_multiple_text_contents():
|
||||
"""Test FunctionResultContent with multiple TextContent-like items (MCP tool result)."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
@dataclass
|
||||
class MockTextContent:
|
||||
text: str
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_result(
|
||||
call_id="call_123",
|
||||
result=[MockTextContent("First result"), MockTextContent("Second result")],
|
||||
)
|
||||
]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 2
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[1].type == "TOOL_CALL_RESULT"
|
||||
# Multiple TextContent items should return JSON array
|
||||
assert events[1].content == '["First result", "Second result"]'
|
||||
|
||||
|
||||
async def test_tool_result_with_model_dump_objects():
|
||||
"""Test FunctionResultContent with Pydantic BaseModel objects."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
class MockModel(BaseModel):
|
||||
value: int
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentResponseUpdate(
|
||||
contents=[Content.from_function_result(call_id="call_123", result=[MockModel(value=1), MockModel(value=2)])]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 2
|
||||
assert events[1].type == "TOOL_CALL_RESULT"
|
||||
# Should be properly serialized JSON array without double escaping
|
||||
assert events[1].content == '[{"value": 1}, {"value": 2}]'
|
||||
|
||||
|
||||
async def test_function_call_with_dataclass_arguments():
|
||||
"""Test FunctionCallContent with dataclass arguments is serialized correctly.
|
||||
|
||||
This test verifies the fix for the AG-UI JSON serialization error when
|
||||
HandoffAgentUserRequest (a dataclass) is passed as FunctionCallContent.arguments.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
@dataclass
|
||||
class TestRequest:
|
||||
field1: str
|
||||
field2: int
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
# FunctionCallContent with a dataclass as arguments (not a string)
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="request_info",
|
||||
call_id="call_dataclass",
|
||||
arguments=TestRequest(field1="value", field2=42),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should have ToolCallStartEvent and ToolCallArgsEvent
|
||||
tool_args_events = [e for e in events if e.type == "TOOL_CALL_ARGS"]
|
||||
assert len(tool_args_events) == 1
|
||||
|
||||
# Verify the delta is valid JSON
|
||||
delta = tool_args_events[0].delta
|
||||
parsed = json.loads(delta)
|
||||
assert parsed == {"field1": "value", "field2": 42}
|
||||
|
||||
|
||||
async def test_function_call_with_nested_dataclass_arguments():
|
||||
"""Test FunctionCallContent with nested dataclass arguments is serialized correctly.
|
||||
|
||||
This test covers the scenario where HandoffAgentUserRequest contains an AgentResponse
|
||||
with nested content objects.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
@dataclass
|
||||
class InnerContent:
|
||||
text: str
|
||||
|
||||
@dataclass
|
||||
class AgentResponseMock:
|
||||
contents: list[InnerContent]
|
||||
|
||||
@dataclass
|
||||
class HandoffRequest:
|
||||
agent_response: AgentResponseMock
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
# Simulate a HandoffAgentUserRequest-like structure
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="request_info",
|
||||
call_id="call_nested",
|
||||
arguments=HandoffRequest(
|
||||
agent_response=AgentResponseMock(contents=[InnerContent(text="Hello from agent")])
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should have ToolCallStartEvent and ToolCallArgsEvent
|
||||
tool_args_events = [e for e in events if e.type == "TOOL_CALL_ARGS"]
|
||||
assert len(tool_args_events) == 1
|
||||
|
||||
# Verify the delta is valid JSON and contains nested structure
|
||||
delta = tool_args_events[0].delta
|
||||
parsed = json.loads(delta)
|
||||
assert "agent_response" in parsed
|
||||
assert parsed["agent_response"]["contents"] == [{"text": "Hello from agent"}]
|
||||
@@ -0,0 +1,502 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for orchestration helper functions."""
|
||||
|
||||
from agent_framework import ChatMessage, Content
|
||||
|
||||
from agent_framework_ag_ui._orchestration._helpers import (
|
||||
approval_steps,
|
||||
build_safe_metadata,
|
||||
ensure_tool_call_entry,
|
||||
is_state_context_message,
|
||||
is_step_based_approval,
|
||||
latest_approval_response,
|
||||
pending_tool_call_ids,
|
||||
schema_has_steps,
|
||||
select_approval_tool_name,
|
||||
tool_name_for_call_id,
|
||||
)
|
||||
|
||||
|
||||
class TestPendingToolCallIds:
|
||||
"""Tests for pending_tool_call_ids function."""
|
||||
|
||||
def test_empty_messages(self):
|
||||
"""Returns empty set for empty messages list."""
|
||||
result = pending_tool_call_ids([])
|
||||
assert result == set()
|
||||
|
||||
def test_no_tool_calls(self):
|
||||
"""Returns empty set when no tool calls in messages."""
|
||||
messages = [
|
||||
ChatMessage(role="user", contents=[Content.from_text("Hello")]),
|
||||
ChatMessage(role="assistant", contents=[Content.from_text("Hi there")]),
|
||||
]
|
||||
result = pending_tool_call_ids(messages)
|
||||
assert result == set()
|
||||
|
||||
def test_pending_tool_call(self):
|
||||
"""Returns pending tool call ID when no result exists."""
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(call_id="call_123", name="get_weather", arguments="{}")],
|
||||
),
|
||||
]
|
||||
result = pending_tool_call_ids(messages)
|
||||
assert result == {"call_123"}
|
||||
|
||||
def test_resolved_tool_call(self):
|
||||
"""Returns empty set when tool call has result."""
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(call_id="call_123", name="get_weather", arguments="{}")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(call_id="call_123", result="sunny")],
|
||||
),
|
||||
]
|
||||
result = pending_tool_call_ids(messages)
|
||||
assert result == set()
|
||||
|
||||
def test_multiple_tool_calls_some_resolved(self):
|
||||
"""Returns only unresolved tool call IDs."""
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(call_id="call_1", name="tool_a", arguments="{}"),
|
||||
Content.from_function_call(call_id="call_2", name="tool_b", arguments="{}"),
|
||||
Content.from_function_call(call_id="call_3", name="tool_c", arguments="{}"),
|
||||
],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(call_id="call_1", result="result_a")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(call_id="call_3", result="result_c")],
|
||||
),
|
||||
]
|
||||
result = pending_tool_call_ids(messages)
|
||||
assert result == {"call_2"}
|
||||
|
||||
|
||||
class TestIsStateContextMessage:
|
||||
"""Tests for is_state_context_message function."""
|
||||
|
||||
def test_state_context_message(self):
|
||||
"""Returns True for state context message."""
|
||||
message = ChatMessage(
|
||||
role="system",
|
||||
contents=[Content.from_text("Current state of the application: {}")],
|
||||
)
|
||||
assert is_state_context_message(message) is True
|
||||
|
||||
def test_non_system_message(self):
|
||||
"""Returns False for non-system message."""
|
||||
message = ChatMessage(
|
||||
role="user",
|
||||
contents=[Content.from_text("Current state of the application: {}")],
|
||||
)
|
||||
assert is_state_context_message(message) is False
|
||||
|
||||
def test_system_message_without_state_prefix(self):
|
||||
"""Returns False for system message without state prefix."""
|
||||
message = ChatMessage(
|
||||
role="system",
|
||||
contents=[Content.from_text("You are a helpful assistant.")],
|
||||
)
|
||||
assert is_state_context_message(message) is False
|
||||
|
||||
def test_empty_contents(self):
|
||||
"""Returns False for message with empty contents."""
|
||||
message = ChatMessage(role="system", contents=[])
|
||||
assert is_state_context_message(message) is False
|
||||
|
||||
|
||||
class TestEnsureToolCallEntry:
|
||||
"""Tests for ensure_tool_call_entry function."""
|
||||
|
||||
def test_creates_new_entry(self):
|
||||
"""Creates new entry when ID not found."""
|
||||
tool_calls_by_id: dict = {}
|
||||
pending_tool_calls: list = []
|
||||
|
||||
entry = ensure_tool_call_entry("call_123", tool_calls_by_id, pending_tool_calls)
|
||||
|
||||
assert entry["id"] == "call_123"
|
||||
assert entry["type"] == "function"
|
||||
assert entry["function"]["name"] == ""
|
||||
assert entry["function"]["arguments"] == ""
|
||||
assert "call_123" in tool_calls_by_id
|
||||
assert len(pending_tool_calls) == 1
|
||||
|
||||
def test_returns_existing_entry(self):
|
||||
"""Returns existing entry when ID found."""
|
||||
existing_entry = {
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": '{"city": "NYC"}'},
|
||||
}
|
||||
tool_calls_by_id = {"call_123": existing_entry}
|
||||
pending_tool_calls: list = []
|
||||
|
||||
entry = ensure_tool_call_entry("call_123", tool_calls_by_id, pending_tool_calls)
|
||||
|
||||
assert entry is existing_entry
|
||||
assert entry["function"]["name"] == "get_weather"
|
||||
assert len(pending_tool_calls) == 0 # Not added again
|
||||
|
||||
|
||||
class TestToolNameForCallId:
|
||||
"""Tests for tool_name_for_call_id function."""
|
||||
|
||||
def test_returns_tool_name(self):
|
||||
"""Returns tool name for valid entry."""
|
||||
tool_calls_by_id = {
|
||||
"call_123": {
|
||||
"id": "call_123",
|
||||
"function": {"name": "get_weather", "arguments": "{}"},
|
||||
}
|
||||
}
|
||||
result = tool_name_for_call_id(tool_calls_by_id, "call_123")
|
||||
assert result == "get_weather"
|
||||
|
||||
def test_returns_none_for_missing_id(self):
|
||||
"""Returns None when ID not found."""
|
||||
tool_calls_by_id: dict = {}
|
||||
result = tool_name_for_call_id(tool_calls_by_id, "call_123")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_missing_function(self):
|
||||
"""Returns None when function key missing."""
|
||||
tool_calls_by_id = {"call_123": {"id": "call_123"}}
|
||||
result = tool_name_for_call_id(tool_calls_by_id, "call_123")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_non_dict_function(self):
|
||||
"""Returns None when function is not a dict."""
|
||||
tool_calls_by_id = {"call_123": {"id": "call_123", "function": "not_a_dict"}}
|
||||
result = tool_name_for_call_id(tool_calls_by_id, "call_123")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_empty_name(self):
|
||||
"""Returns None when name is empty."""
|
||||
tool_calls_by_id = {"call_123": {"id": "call_123", "function": {"name": "", "arguments": "{}"}}}
|
||||
result = tool_name_for_call_id(tool_calls_by_id, "call_123")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestSchemaHasSteps:
|
||||
"""Tests for schema_has_steps function."""
|
||||
|
||||
def test_schema_with_steps_array(self):
|
||||
"""Returns True when schema has steps array property."""
|
||||
schema = {"properties": {"steps": {"type": "array"}}}
|
||||
assert schema_has_steps(schema) is True
|
||||
|
||||
def test_schema_without_steps(self):
|
||||
"""Returns False when schema doesn't have steps."""
|
||||
schema = {"properties": {"name": {"type": "string"}}}
|
||||
assert schema_has_steps(schema) is False
|
||||
|
||||
def test_schema_with_non_array_steps(self):
|
||||
"""Returns False when steps is not array type."""
|
||||
schema = {"properties": {"steps": {"type": "string"}}}
|
||||
assert schema_has_steps(schema) is False
|
||||
|
||||
def test_non_dict_schema(self):
|
||||
"""Returns False for non-dict schema."""
|
||||
assert schema_has_steps(None) is False
|
||||
assert schema_has_steps("not a dict") is False
|
||||
assert schema_has_steps([]) is False
|
||||
|
||||
def test_missing_properties(self):
|
||||
"""Returns False when properties key is missing."""
|
||||
schema = {"type": "object"}
|
||||
assert schema_has_steps(schema) is False
|
||||
|
||||
def test_non_dict_properties(self):
|
||||
"""Returns False when properties is not a dict."""
|
||||
schema = {"properties": "not a dict"}
|
||||
assert schema_has_steps(schema) is False
|
||||
|
||||
def test_non_dict_steps(self):
|
||||
"""Returns False when steps is not a dict."""
|
||||
schema = {"properties": {"steps": "not a dict"}}
|
||||
assert schema_has_steps(schema) is False
|
||||
|
||||
|
||||
class TestSelectApprovalToolName:
|
||||
"""Tests for select_approval_tool_name function."""
|
||||
|
||||
def test_none_client_tools(self):
|
||||
"""Returns None when client_tools is None."""
|
||||
result = select_approval_tool_name(None)
|
||||
assert result is None
|
||||
|
||||
def test_empty_client_tools(self):
|
||||
"""Returns None when client_tools is empty."""
|
||||
result = select_approval_tool_name([])
|
||||
assert result is None
|
||||
|
||||
def test_finds_approval_tool(self):
|
||||
"""Returns tool name when tool has steps schema."""
|
||||
|
||||
class MockTool:
|
||||
name = "generate_task_steps"
|
||||
|
||||
def parameters(self):
|
||||
return {"properties": {"steps": {"type": "array"}}}
|
||||
|
||||
result = select_approval_tool_name([MockTool()])
|
||||
assert result == "generate_task_steps"
|
||||
|
||||
def test_skips_tool_without_name(self):
|
||||
"""Skips tools without name attribute."""
|
||||
|
||||
class MockToolNoName:
|
||||
def parameters(self):
|
||||
return {"properties": {"steps": {"type": "array"}}}
|
||||
|
||||
result = select_approval_tool_name([MockToolNoName()])
|
||||
assert result is None
|
||||
|
||||
def test_skips_tool_without_parameters_method(self):
|
||||
"""Skips tools without callable parameters method."""
|
||||
|
||||
class MockToolNoParams:
|
||||
name = "some_tool"
|
||||
parameters = "not callable"
|
||||
|
||||
result = select_approval_tool_name([MockToolNoParams()])
|
||||
assert result is None
|
||||
|
||||
def test_skips_tool_without_steps_schema(self):
|
||||
"""Skips tools that don't have steps in schema."""
|
||||
|
||||
class MockToolNoSteps:
|
||||
name = "other_tool"
|
||||
|
||||
def parameters(self):
|
||||
return {"properties": {"data": {"type": "string"}}}
|
||||
|
||||
result = select_approval_tool_name([MockToolNoSteps()])
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestBuildSafeMetadata:
|
||||
"""Tests for build_safe_metadata function."""
|
||||
|
||||
def test_none_metadata(self):
|
||||
"""Returns empty dict for None metadata."""
|
||||
result = build_safe_metadata(None)
|
||||
assert result == {}
|
||||
|
||||
def test_empty_metadata(self):
|
||||
"""Returns empty dict for empty metadata."""
|
||||
result = build_safe_metadata({})
|
||||
assert result == {}
|
||||
|
||||
def test_string_values_under_limit(self):
|
||||
"""Preserves string values under 512 chars."""
|
||||
metadata = {"key1": "short value", "key2": "another value"}
|
||||
result = build_safe_metadata(metadata)
|
||||
assert result == metadata
|
||||
|
||||
def test_truncates_long_string_values(self):
|
||||
"""Truncates string values over 512 chars."""
|
||||
long_value = "x" * 1000
|
||||
metadata = {"key": long_value}
|
||||
result = build_safe_metadata(metadata)
|
||||
assert len(result["key"]) == 512
|
||||
assert result["key"] == "x" * 512
|
||||
|
||||
def test_non_string_values_serialized(self):
|
||||
"""Serializes non-string values to JSON."""
|
||||
metadata = {"count": 42, "items": ["a", "b"]}
|
||||
result = build_safe_metadata(metadata)
|
||||
assert result["count"] == "42"
|
||||
assert result["items"] == '["a", "b"]'
|
||||
|
||||
def test_truncates_serialized_values(self):
|
||||
"""Truncates serialized JSON values over 512 chars."""
|
||||
long_list = list(range(200)) # Will serialize to >512 chars
|
||||
metadata = {"data": long_list}
|
||||
result = build_safe_metadata(metadata)
|
||||
assert len(result["data"]) == 512
|
||||
|
||||
|
||||
class TestLatestApprovalResponse:
|
||||
"""Tests for latest_approval_response function."""
|
||||
|
||||
def test_empty_messages(self):
|
||||
"""Returns None for empty messages."""
|
||||
result = latest_approval_response([])
|
||||
assert result is None
|
||||
|
||||
def test_no_approval_response(self):
|
||||
"""Returns None when no approval response in last message."""
|
||||
messages = [
|
||||
ChatMessage(role="assistant", contents=[Content.from_text("Hello")]),
|
||||
]
|
||||
result = latest_approval_response(messages)
|
||||
assert result is None
|
||||
|
||||
def test_finds_approval_response(self):
|
||||
"""Returns approval response from last message."""
|
||||
# Create a function call content first
|
||||
fc = Content.from_function_call(call_id="call_123", name="test_tool", arguments="{}")
|
||||
approval_content = Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id="approval_123",
|
||||
function_call=fc,
|
||||
)
|
||||
messages = [
|
||||
ChatMessage(role="user", contents=[approval_content]),
|
||||
]
|
||||
result = latest_approval_response(messages)
|
||||
assert result is approval_content
|
||||
|
||||
|
||||
class TestApprovalSteps:
|
||||
"""Tests for approval_steps function."""
|
||||
|
||||
def test_steps_from_ag_ui_state_args(self):
|
||||
"""Extracts steps from ag_ui_state_args."""
|
||||
fc = Content.from_function_call(call_id="call_123", name="test_tool", arguments="{}")
|
||||
approval = Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id="approval_123",
|
||||
function_call=fc,
|
||||
additional_properties={"ag_ui_state_args": {"steps": [{"id": 1}, {"id": 2}]}},
|
||||
)
|
||||
result = approval_steps(approval)
|
||||
assert result == [{"id": 1}, {"id": 2}]
|
||||
|
||||
def test_steps_from_function_call(self):
|
||||
"""Extracts steps from function call arguments."""
|
||||
fc = Content.from_function_call(
|
||||
call_id="call_123",
|
||||
name="test",
|
||||
arguments='{"steps": [{"step": 1}]}',
|
||||
)
|
||||
approval = Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id="approval_123",
|
||||
function_call=fc,
|
||||
)
|
||||
result = approval_steps(approval)
|
||||
assert result == [{"step": 1}]
|
||||
|
||||
def test_empty_steps_when_no_state_args(self):
|
||||
"""Returns empty list when no ag_ui_state_args."""
|
||||
fc = Content.from_function_call(call_id="call_123", name="test_tool", arguments="{}")
|
||||
approval = Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id="approval_123",
|
||||
function_call=fc,
|
||||
)
|
||||
result = approval_steps(approval)
|
||||
assert result == []
|
||||
|
||||
def test_empty_steps_when_state_args_not_dict(self):
|
||||
"""Returns empty list when ag_ui_state_args is not a dict."""
|
||||
fc = Content.from_function_call(call_id="call_123", name="test_tool", arguments="{}")
|
||||
approval = Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id="approval_123",
|
||||
function_call=fc,
|
||||
additional_properties={"ag_ui_state_args": "not a dict"},
|
||||
)
|
||||
result = approval_steps(approval)
|
||||
assert result == []
|
||||
|
||||
def test_empty_steps_when_steps_not_list(self):
|
||||
"""Returns empty list when steps is not a list."""
|
||||
fc = Content.from_function_call(call_id="call_123", name="test_tool", arguments="{}")
|
||||
approval = Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id="approval_123",
|
||||
function_call=fc,
|
||||
additional_properties={"ag_ui_state_args": {"steps": "not a list"}},
|
||||
)
|
||||
result = approval_steps(approval)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestIsStepBasedApproval:
|
||||
"""Tests for is_step_based_approval function."""
|
||||
|
||||
def test_returns_true_when_has_steps(self):
|
||||
"""Returns True when approval has steps."""
|
||||
fc = Content.from_function_call(call_id="call_123", name="test_tool", arguments="{}")
|
||||
approval = Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id="approval_123",
|
||||
function_call=fc,
|
||||
additional_properties={"ag_ui_state_args": {"steps": [{"id": 1}]}},
|
||||
)
|
||||
result = is_step_based_approval(approval, None)
|
||||
assert result is True
|
||||
|
||||
def test_returns_false_no_steps_no_function_call(self):
|
||||
"""Returns False when no steps and no function call."""
|
||||
# Create content directly to have no function_call
|
||||
approval = Content(
|
||||
type="function_approval_response",
|
||||
function_call=None,
|
||||
)
|
||||
result = is_step_based_approval(approval, None)
|
||||
assert result is False
|
||||
|
||||
def test_returns_false_no_predict_config(self):
|
||||
"""Returns False when no predict_state_config."""
|
||||
fc = Content.from_function_call(call_id="call_123", name="some_tool", arguments="{}")
|
||||
approval = Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id="approval_123",
|
||||
function_call=fc,
|
||||
)
|
||||
result = is_step_based_approval(approval, None)
|
||||
assert result is False
|
||||
|
||||
def test_returns_true_when_tool_matches_config(self):
|
||||
"""Returns True when tool matches predict_state_config with steps."""
|
||||
fc = Content.from_function_call(call_id="call_123", name="generate_steps", arguments="{}")
|
||||
approval = Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id="approval_123",
|
||||
function_call=fc,
|
||||
)
|
||||
config = {"steps": {"tool": "generate_steps", "tool_argument": "steps"}}
|
||||
result = is_step_based_approval(approval, config)
|
||||
assert result is True
|
||||
|
||||
def test_returns_false_when_tool_not_in_config(self):
|
||||
"""Returns False when tool not in predict_state_config."""
|
||||
fc = Content.from_function_call(call_id="call_123", name="other_tool", arguments="{}")
|
||||
approval = Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id="approval_123",
|
||||
function_call=fc,
|
||||
)
|
||||
config = {"steps": {"tool": "generate_steps", "tool_argument": "steps"}}
|
||||
result = is_step_based_approval(approval, config)
|
||||
assert result is False
|
||||
|
||||
def test_returns_false_when_tool_arg_not_steps(self):
|
||||
"""Returns False when tool_argument is not 'steps'."""
|
||||
fc = Content.from_function_call(call_id="call_123", name="generate_steps", arguments="{}")
|
||||
approval = Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id="approval_123",
|
||||
function_call=fc,
|
||||
)
|
||||
config = {"document": {"tool": "generate_steps", "tool_argument": "content"}}
|
||||
result = is_step_based_approval(approval, config)
|
||||
assert result is False
|
||||
@@ -1,180 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for human in the loop (function approval requests)."""
|
||||
|
||||
from agent_framework import AgentResponseUpdate, Content
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
|
||||
async def test_function_approval_request_emission():
|
||||
"""Test that CustomEvent is emitted for FunctionApprovalRequestContent."""
|
||||
# Set require_confirmation=False to test just the function_approval_request event
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
require_confirmation=False,
|
||||
)
|
||||
|
||||
# Create approval request
|
||||
func_call = Content.from_function_call(
|
||||
call_id="call_123",
|
||||
name="send_email",
|
||||
arguments={"to": "user@example.com", "subject": "Test"},
|
||||
)
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id="approval_001",
|
||||
function_call=func_call,
|
||||
)
|
||||
|
||||
update = AgentResponseUpdate(contents=[approval_request])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should emit ToolCallEndEvent + CustomEvent for approval request
|
||||
assert len(events) == 2
|
||||
|
||||
# First event: ToolCallEndEvent to close the tool call
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[0].tool_call_id == "call_123"
|
||||
|
||||
# Second event: CustomEvent with approval details
|
||||
event = events[1]
|
||||
assert event.type == "CUSTOM"
|
||||
assert event.name == "function_approval_request"
|
||||
assert event.value["id"] == "approval_001"
|
||||
assert event.value["function_call"]["call_id"] == "call_123"
|
||||
assert event.value["function_call"]["name"] == "send_email"
|
||||
assert event.value["function_call"]["arguments"]["to"] == "user@example.com"
|
||||
assert event.value["function_call"]["arguments"]["subject"] == "Test"
|
||||
|
||||
|
||||
async def test_function_approval_request_with_confirm_changes():
|
||||
"""Test that confirm_changes is also emitted when require_confirmation=True."""
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
require_confirmation=True,
|
||||
)
|
||||
|
||||
func_call = Content.from_function_call(
|
||||
call_id="call_456",
|
||||
name="delete_file",
|
||||
arguments={"path": "/tmp/test.txt"},
|
||||
)
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id="approval_002",
|
||||
function_call=func_call,
|
||||
)
|
||||
|
||||
update = AgentResponseUpdate(contents=[approval_request])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should emit: ToolCallEndEvent, CustomEvent, and confirm_changes (Start, Args, End) = 5 events
|
||||
assert len(events) == 5
|
||||
|
||||
# Check ToolCallEndEvent
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[0].tool_call_id == "call_456"
|
||||
|
||||
# Check function_approval_request CustomEvent
|
||||
assert events[1].type == "CUSTOM"
|
||||
assert events[1].name == "function_approval_request"
|
||||
|
||||
# Check confirm_changes tool call events
|
||||
assert events[2].type == "TOOL_CALL_START"
|
||||
assert events[2].tool_call_name == "confirm_changes"
|
||||
assert events[3].type == "TOOL_CALL_ARGS"
|
||||
# Verify confirm_changes includes function info for Dojo UI
|
||||
import json
|
||||
|
||||
args = json.loads(events[3].delta)
|
||||
assert args["function_name"] == "delete_file"
|
||||
assert args["function_call_id"] == "call_456"
|
||||
assert args["function_arguments"] == {"path": "/tmp/test.txt"}
|
||||
assert args["steps"] == [
|
||||
{
|
||||
"description": "Execute delete_file",
|
||||
"status": "enabled",
|
||||
}
|
||||
]
|
||||
assert events[4].type == "TOOL_CALL_END"
|
||||
|
||||
|
||||
async def test_multiple_approval_requests():
|
||||
"""Test handling multiple approval requests in one update."""
|
||||
# Set require_confirmation=False to simplify the test
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
require_confirmation=False,
|
||||
)
|
||||
|
||||
func_call_1 = Content.from_function_call(
|
||||
call_id="call_1",
|
||||
name="create_event",
|
||||
arguments={"title": "Meeting"},
|
||||
)
|
||||
approval_1 = Content.from_function_approval_request(
|
||||
id="approval_1",
|
||||
function_call=func_call_1,
|
||||
)
|
||||
|
||||
func_call_2 = Content.from_function_call(
|
||||
call_id="call_2",
|
||||
name="book_room",
|
||||
arguments={"room": "Conference A"},
|
||||
)
|
||||
approval_2 = Content.from_function_approval_request(
|
||||
id="approval_2",
|
||||
function_call=func_call_2,
|
||||
)
|
||||
|
||||
update = AgentResponseUpdate(contents=[approval_1, approval_2])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should emit ToolCallEndEvent + CustomEvent for each approval (4 events total)
|
||||
assert len(events) == 4
|
||||
|
||||
# Events should alternate: End, Custom, End, Custom
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[0].tool_call_id == "call_1"
|
||||
|
||||
assert events[1].type == "CUSTOM"
|
||||
assert events[1].name == "function_approval_request"
|
||||
assert events[1].value["id"] == "approval_1"
|
||||
|
||||
assert events[2].type == "TOOL_CALL_END"
|
||||
assert events[2].tool_call_id == "call_2"
|
||||
|
||||
assert events[3].type == "CUSTOM"
|
||||
assert events[3].name == "function_approval_request"
|
||||
assert events[3].value["id"] == "approval_2"
|
||||
|
||||
|
||||
async def test_function_approval_request_sets_stop_flag():
|
||||
"""Test that function approval request sets should_stop_after_confirm flag.
|
||||
|
||||
This ensures the orchestrator stops the run after emitting the approval request,
|
||||
allowing the UI to send back an approval response.
|
||||
"""
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
)
|
||||
|
||||
assert bridge.should_stop_after_confirm is False
|
||||
|
||||
func_call = Content.from_function_call(
|
||||
call_id="call_stop_test",
|
||||
name="get_datetime",
|
||||
arguments={},
|
||||
)
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id="approval_stop_test",
|
||||
function_call=func_call,
|
||||
)
|
||||
|
||||
update = AgentResponseUpdate(contents=[approval_request])
|
||||
await bridge.from_agent_run_update(update)
|
||||
|
||||
assert bridge.should_stop_after_confirm is True
|
||||
@@ -644,49 +644,107 @@ def test_agent_framework_to_agui_function_result_multiple_text_contents():
|
||||
assert agui_msg["content"] == '["First result", "Second result"]'
|
||||
|
||||
|
||||
def test_agui_tool_approval_with_dataclass_modified_args():
|
||||
"""Test that agui_messages_to_agent_framework handles dataclass in modified args.
|
||||
# Additional tests for better coverage
|
||||
|
||||
This tests the fix for json.dumps() serialization errors at line 274
|
||||
when modified_args contains non-serializable objects via make_json_safe.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class ModifiedData:
|
||||
field1: str
|
||||
field2: int
|
||||
def test_extract_text_from_contents_empty():
|
||||
"""Test extracting text from empty contents."""
|
||||
result = extract_text_from_contents([])
|
||||
assert result == ""
|
||||
|
||||
# Create AG-UI format messages that simulate tool approval flow
|
||||
# where modified args could contain a dataclass after parsing
|
||||
|
||||
# First, an assistant message with a tool call (string arguments)
|
||||
assistant_msg = {
|
||||
"id": "msg-1",
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-test",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_state",
|
||||
"arguments": '{"data": "original"}', # String args
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
def test_extract_text_from_contents_multiple():
|
||||
"""Test extracting text from multiple text contents."""
|
||||
contents = [
|
||||
Content.from_text("Hello "),
|
||||
Content.from_text("World"),
|
||||
]
|
||||
result = extract_text_from_contents(contents)
|
||||
assert result == "Hello World"
|
||||
|
||||
# Then a user approval message (the approval path will merge modified args)
|
||||
approval_msg = {
|
||||
"id": "msg-2",
|
||||
"role": "user",
|
||||
"content": '{"approved": true}',
|
||||
"toolCallId": "call-test",
|
||||
}
|
||||
|
||||
# This should NOT raise TypeError
|
||||
result = agui_messages_to_agent_framework([assistant_msg, approval_msg])
|
||||
def test_extract_text_from_contents_non_text():
|
||||
"""Test extracting text ignores non-text contents."""
|
||||
contents = [
|
||||
Content.from_text("Hello"),
|
||||
Content.from_function_call(call_id="call_1", name="tool", arguments="{}"),
|
||||
]
|
||||
result = extract_text_from_contents(contents)
|
||||
assert result == "Hello"
|
||||
|
||||
|
||||
def test_agui_to_agent_framework_with_tool_calls():
|
||||
"""Test converting AG-UI message with tool_calls."""
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": '{"city": "NYC"}'},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = agui_messages_to_agent_framework(messages)
|
||||
|
||||
assert len(result) == 1
|
||||
assert len(result[0].contents) == 1
|
||||
assert result[0].contents[0].type == "function_call"
|
||||
assert result[0].contents[0].name == "get_weather"
|
||||
|
||||
|
||||
def test_agui_to_agent_framework_tool_result():
|
||||
"""Test converting AG-UI tool result message."""
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "Sunny",
|
||||
"toolCallId": "call_123",
|
||||
},
|
||||
]
|
||||
|
||||
result = agui_messages_to_agent_framework(messages)
|
||||
|
||||
# Should have processed both messages without error
|
||||
assert len(result) == 2
|
||||
# Second message should be tool result
|
||||
tool_msg = result[1]
|
||||
assert tool_msg.role == Role.TOOL
|
||||
assert tool_msg.contents[0].type == "function_result"
|
||||
assert tool_msg.contents[0].result == "Sunny"
|
||||
|
||||
|
||||
def test_agui_messages_to_snapshot_format_empty():
|
||||
"""Test converting empty messages to snapshot format."""
|
||||
result = agui_messages_to_snapshot_format([])
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_agui_messages_to_snapshot_format_basic():
|
||||
"""Test converting messages to snapshot format."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello", "id": "msg_1"},
|
||||
{"role": "assistant", "content": "Hi there", "id": "msg_2"},
|
||||
]
|
||||
|
||||
result = agui_messages_to_snapshot_format(messages)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["role"] == "user"
|
||||
assert result[0]["content"] == "Hello"
|
||||
assert result[1]["role"] == "assistant"
|
||||
assert result[1]["content"] == "Hi there"
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for AG-UI orchestrators."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from ag_ui.core import BaseEvent, RunFinishedEvent
|
||||
from agent_framework import (
|
||||
AgentResponseUpdate,
|
||||
AgentThread,
|
||||
BaseChatClient,
|
||||
ChatAgent,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
FunctionInvocationConfiguration,
|
||||
ai_function,
|
||||
)
|
||||
|
||||
from agent_framework_ag_ui._agent import AgentConfig
|
||||
from agent_framework_ag_ui._orchestrators import DefaultOrchestrator, ExecutionContext
|
||||
|
||||
|
||||
@ai_function
|
||||
def server_tool() -> str:
|
||||
"""Server-executable tool."""
|
||||
return "server"
|
||||
|
||||
|
||||
def _create_mock_chat_agent(
|
||||
tools: list[Any] | None = None,
|
||||
response_format: Any = None,
|
||||
capture_tools: list[Any] | None = None,
|
||||
capture_messages: list[Any] | None = None,
|
||||
) -> ChatAgent:
|
||||
"""Create a ChatAgent with mocked chat client for testing.
|
||||
|
||||
Args:
|
||||
tools: Tools to configure on the agent.
|
||||
response_format: Response format to configure.
|
||||
capture_tools: If provided, tools passed to run_stream will be appended here.
|
||||
capture_messages: If provided, messages passed to run_stream will be appended here.
|
||||
"""
|
||||
mock_chat_client = MagicMock(spec=BaseChatClient)
|
||||
mock_chat_client.function_invocation_configuration = FunctionInvocationConfiguration()
|
||||
|
||||
agent = ChatAgent(
|
||||
chat_client=mock_chat_client,
|
||||
tools=tools or [server_tool],
|
||||
response_format=response_format,
|
||||
)
|
||||
|
||||
# Create a mock run_stream that captures parameters and yields a simple response
|
||||
async def mock_run_stream(
|
||||
messages: list[Any],
|
||||
*,
|
||||
# thread: AgentThread,
|
||||
# tools: list[Any] | None = None,
|
||||
# **kwargs: Any,
|
||||
# ) -> AsyncGenerator[AgentRunResponseUpdate, None]:
|
||||
# self.seen_tools = tools
|
||||
# yield AgentRunResponseUpdate(
|
||||
# contents=[TextContent(text="ok")],
|
||||
# role="assistant",
|
||||
# response_id=thread.metadata.get("ag_ui_run_id"), # type: ignore[attr-defined] (metadata always created in orchestrator)
|
||||
# raw_representation=ChatResponseUpdate(
|
||||
# contents=[TextContent(text="ok")],
|
||||
# conversation_id=thread.metadata.get("ag_ui_thread_id"), # type: ignore[attr-defined] (metadata always created in orchestrator)
|
||||
# response_id=thread.metadata.get("ag_ui_run_id"), # type: ignore[attr-defined] (metadata always created in orchestrator)
|
||||
# ),
|
||||
# )
|
||||
thread: AgentThread,
|
||||
tools: list[Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[AgentResponseUpdate, None]:
|
||||
if capture_tools is not None and tools is not None:
|
||||
capture_tools.extend(tools)
|
||||
if capture_messages is not None:
|
||||
capture_messages.extend(messages)
|
||||
yield AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="ok")],
|
||||
role="assistant",
|
||||
response_id=thread.metadata.get("ag_ui_run_id"), # type: ignore[attr-defined] (metadata always created in orchestrator)
|
||||
raw_representation=ChatResponseUpdate(
|
||||
contents=[Content.from_text(text="ok")],
|
||||
conversation_id=thread.metadata.get("ag_ui_thread_id"), # type: ignore[attr-defined] (metadata always created in orchestrator)
|
||||
response_id=thread.metadata.get("ag_ui_run_id"), # type: ignore[attr-defined] (metadata always created in orchestrator)
|
||||
),
|
||||
)
|
||||
|
||||
# Patch the run_stream method
|
||||
agent.run_stream = mock_run_stream # type: ignore[method-assign]
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
async def test_default_orchestrator_merges_client_tools() -> None:
|
||||
"""Client tool declarations are merged with server tools before running agent."""
|
||||
captured_tools: list[Any] = []
|
||||
agent = _create_mock_chat_agent(tools=[server_tool], capture_tools=captured_tools)
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "Hello"}],
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Client weather lookup.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"location": {"type": "string"}},
|
||||
"required": ["location"],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
assert len(captured_tools) > 0
|
||||
tool_names = [getattr(tool, "name", "?") for tool in captured_tools]
|
||||
assert "server_tool" in tool_names
|
||||
assert "get_weather" in tool_names
|
||||
assert agent.chat_client.function_invocation_configuration.additional_tools
|
||||
|
||||
|
||||
async def test_default_orchestrator_with_camel_case_ids() -> None:
|
||||
"""Client tool is able to extract camelCase IDs."""
|
||||
agent = _create_mock_chat_agent()
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data = {
|
||||
"runId": "test-camelcase-runid",
|
||||
"threadId": "test-camelcase-threadid",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "Hello"}],
|
||||
}
|
||||
],
|
||||
"tools": [],
|
||||
}
|
||||
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# assert the last event has the expected run_id and thread_id
|
||||
assert isinstance(events[-1], RunFinishedEvent)
|
||||
last_event = events[-1]
|
||||
assert last_event.run_id == "test-camelcase-runid"
|
||||
assert last_event.thread_id == "test-camelcase-threadid"
|
||||
|
||||
|
||||
async def test_default_orchestrator_with_snake_case_ids() -> None:
|
||||
"""Client tool is able to extract snake_case IDs."""
|
||||
agent = _create_mock_chat_agent()
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data = {
|
||||
"run_id": "test-snakecase-runid",
|
||||
"thread_id": "test-snakecase-threadid",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "Hello"}],
|
||||
}
|
||||
],
|
||||
"tools": [],
|
||||
}
|
||||
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
|
||||
events: list[BaseEvent] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# assert the last event has the expected run_id and thread_id
|
||||
assert isinstance(events[-1], RunFinishedEvent)
|
||||
last_event = events[-1]
|
||||
assert last_event.run_id == "test-snakecase-runid"
|
||||
assert last_event.thread_id == "test-snakecase-threadid"
|
||||
|
||||
|
||||
async def test_state_context_injected_when_tool_call_state_mismatch() -> None:
|
||||
"""State context should be injected when current state differs from tool call args."""
|
||||
captured_messages: list[Any] = []
|
||||
agent = _create_mock_chat_agent(tools=[], capture_messages=captured_messages)
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
tool_recipe = {"title": "Salad", "special_preferences": []}
|
||||
current_recipe = {"title": "Salad", "special_preferences": ["Vegetarian"]}
|
||||
|
||||
input_data = {
|
||||
"state": {"recipe": current_recipe},
|
||||
"messages": [
|
||||
{"role": "system", "content": "Instructions"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "update_recipe", "arguments": {"recipe": tool_recipe}},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "What are the dietary preferences?"},
|
||||
],
|
||||
}
|
||||
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(
|
||||
state_schema={"recipe": {"type": "object"}},
|
||||
predict_state_config={"recipe": {"tool": "update_recipe", "tool_argument": "recipe"}},
|
||||
require_confirmation=False,
|
||||
),
|
||||
)
|
||||
|
||||
async for _event in orchestrator.run(context):
|
||||
pass
|
||||
|
||||
assert len(captured_messages) > 0
|
||||
state_messages = []
|
||||
for msg in captured_messages:
|
||||
role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
if role_value != "system":
|
||||
continue
|
||||
for content in msg.contents or []:
|
||||
if content.type == "text" and content.text.startswith("Current state of the application:"):
|
||||
state_messages.append(content.text)
|
||||
assert state_messages
|
||||
assert "Vegetarian" in state_messages[0]
|
||||
|
||||
|
||||
async def test_state_context_not_injected_when_tool_call_matches_state() -> None:
|
||||
"""State context should be skipped when tool call args match current state."""
|
||||
captured_messages: list[Any] = []
|
||||
agent = _create_mock_chat_agent(tools=[], capture_messages=captured_messages)
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data = {
|
||||
"messages": [
|
||||
{"role": "system", "content": "Instructions"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "update_recipe", "arguments": {"recipe": {}}},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "What are the dietary preferences?"},
|
||||
],
|
||||
}
|
||||
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(
|
||||
state_schema={"recipe": {"type": "object"}},
|
||||
predict_state_config={"recipe": {"tool": "update_recipe", "tool_argument": "recipe"}},
|
||||
require_confirmation=False,
|
||||
),
|
||||
)
|
||||
|
||||
async for _event in orchestrator.run(context):
|
||||
pass
|
||||
|
||||
assert len(captured_messages) > 0
|
||||
state_messages = []
|
||||
for msg in captured_messages:
|
||||
role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
if role_value != "system":
|
||||
continue
|
||||
for content in msg.contents or []:
|
||||
if content.type == "text" and content.text.startswith("Current state of the application:"):
|
||||
state_messages.append(content.text)
|
||||
assert not state_messages
|
||||
@@ -1,929 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Comprehensive tests for orchestrator coverage."""
|
||||
|
||||
import sys
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentResponseUpdate, ChatMessage, Content, ai_function
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_ag_ui._agent import AgentConfig
|
||||
from agent_framework_ag_ui._orchestrators import DefaultOrchestrator, HumanInTheLoopOrchestrator
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from utils_test_ag_ui import StubAgent, TestExecutionContext
|
||||
|
||||
|
||||
@ai_function(approval_mode="always_require")
|
||||
def approval_tool(param: str) -> str:
|
||||
"""Tool requiring approval."""
|
||||
return f"executed: {param}"
|
||||
|
||||
|
||||
DEFAULT_OPTIONS: dict[str, Any] = {"tools": [approval_tool], "response_format": None}
|
||||
|
||||
|
||||
async def test_human_in_the_loop_json_decode_error() -> None:
|
||||
"""Test HumanInTheLoopOrchestrator handles invalid JSON in tool result."""
|
||||
orchestrator = HumanInTheLoopOrchestrator()
|
||||
|
||||
input_data: dict[str, Any] = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [{"type": "text", "text": "not valid json {"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[Content.from_text(text="not valid json {")],
|
||||
additional_properties={"is_tool_result": True},
|
||||
)
|
||||
]
|
||||
|
||||
agent = StubAgent(
|
||||
default_options={"tools": [approval_tool], "response_format": None},
|
||||
updates=[AgentResponseUpdate(contents=[Content.from_text(text="response")], role="assistant")],
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context.set_messages(messages, normalize=False)
|
||||
|
||||
assert orchestrator.can_handle(context)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should emit RunErrorEvent for invalid JSON
|
||||
error_events: list[Any] = [e for e in events if e.type == "RUN_ERROR"]
|
||||
assert len(error_events) == 1
|
||||
assert "Invalid tool result format" in error_events[0].message
|
||||
|
||||
|
||||
async def test_sanitize_tool_history_confirm_changes() -> None:
|
||||
"""Test sanitize_tool_history logic for confirm_changes synthetic result."""
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
# Create messages that will trigger confirm_changes synthetic result injection
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="confirm_changes",
|
||||
call_id="call_confirm_123",
|
||||
arguments='{"changes": "test"}',
|
||||
)
|
||||
],
|
||||
),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[Content.from_text(text='{"accepted": true}')],
|
||||
),
|
||||
]
|
||||
|
||||
# The sanitize_tool_history function is internal to DefaultOrchestrator.run
|
||||
# We'll test it indirectly by checking the orchestrator processes it correctly
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
# Use pre-constructed ChatMessage objects to bypass message adapter
|
||||
input_data: dict[str, Any] = {"messages": []}
|
||||
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
# Override the messages property to use our pre-constructed messages
|
||||
context.set_messages(messages)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Agent should receive synthetic tool result
|
||||
assert len(agent.messages_received) > 0
|
||||
tool_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool"
|
||||
]
|
||||
assert len(tool_messages) == 1
|
||||
assert str(tool_messages[0].contents[0].call_id) == "call_confirm_123"
|
||||
assert tool_messages[0].contents[0].result == "Confirmed"
|
||||
|
||||
|
||||
async def test_sanitize_tool_history_orphaned_tool_result() -> None:
|
||||
"""Test sanitize_tool_history removes orphaned tool results."""
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
# Tool result without preceding assistant tool call
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(call_id="orphan_123", result="orphaned data")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="Hello")],
|
||||
),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data: dict[str, Any] = {"messages": []}
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context.set_messages(messages)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Orphaned tool result should be filtered out
|
||||
tool_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool"
|
||||
]
|
||||
assert len(tool_messages) == 0
|
||||
|
||||
|
||||
async def test_orphaned_tool_result_sanitization() -> None:
|
||||
"""Test that orphaned tool results are filtered out."""
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data: dict[str, Any] = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [{"type": "tool_result", "tool_call_id": "orphan_123", "content": "result"}],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Hello"}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Orphaned tool result should be filtered, only user message remains
|
||||
tool_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool"
|
||||
]
|
||||
assert len(tool_messages) == 0
|
||||
|
||||
|
||||
async def test_deduplicate_messages_empty_tool_results() -> None:
|
||||
"""Test deduplicate_messages prefers non-empty tool results."""
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(name="test_tool", call_id="call_789", arguments="{}")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(call_id="call_789", result="")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(call_id="call_789", result="real data")],
|
||||
),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data: dict[str, Any] = {"messages": []}
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context.set_messages(messages)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should have only one tool result with actual data
|
||||
tool_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool"
|
||||
]
|
||||
assert len(tool_messages) == 1
|
||||
assert tool_messages[0].contents[0].result == "real data"
|
||||
|
||||
|
||||
async def test_deduplicate_messages_duplicate_assistant_tool_calls() -> None:
|
||||
"""Test deduplicate_messages removes duplicate assistant tool call messages."""
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(name="test_tool", call_id="call_abc", arguments="{}")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(name="test_tool", call_id="call_abc", arguments="{}")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(call_id="call_abc", result="result")],
|
||||
),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data: dict[str, Any] = {"messages": []}
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context.set_messages(messages)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should have only one assistant message
|
||||
assistant_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "assistant"
|
||||
]
|
||||
assert len(assistant_messages) == 1
|
||||
|
||||
|
||||
async def test_deduplicate_messages_duplicate_system_messages() -> None:
|
||||
"""Test that deduplication logic is invoked for system messages."""
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="system",
|
||||
contents=[Content.from_text(text="You are a helpful assistant.")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="system",
|
||||
contents=[Content.from_text(text="You are a helpful assistant.")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="Hello")],
|
||||
),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data: dict[str, Any] = {"messages": []}
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context.set_messages(messages)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Deduplication uses hash() which may not deduplicate identical content
|
||||
# This test verifies deduplication logic runs without errors
|
||||
system_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "system"
|
||||
]
|
||||
# At least one system message should be present
|
||||
assert len(system_messages) >= 1
|
||||
|
||||
|
||||
async def test_state_context_injection() -> None:
|
||||
"""Test state context message injection for first request."""
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data: dict[str, Any] = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Hello"}],
|
||||
}
|
||||
],
|
||||
"state": {"items": ["apple", "banana"]},
|
||||
}
|
||||
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(state_schema={"items": {"type": "array"}}),
|
||||
)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should inject system message with current state
|
||||
system_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "system"
|
||||
]
|
||||
assert len(system_messages) == 1
|
||||
assert "apple" in system_messages[0].contents[0].text
|
||||
assert "banana" in system_messages[0].contents[0].text
|
||||
|
||||
|
||||
async def test_state_context_injection_with_tool_calls_and_input_state() -> None:
|
||||
"""Test state context is injected when state is provided, even with tool calls."""
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(name="get_weather", call_id="call_xyz", arguments="{}")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(call_id="call_xyz", result="sunny")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="Thanks")],
|
||||
),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data: dict[str, Any] = {"messages": [], "state": {"weather": "sunny"}}
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(state_schema={"weather": {"type": "string"}}),
|
||||
)
|
||||
context.set_messages(messages)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should inject state context system message because input state is provided
|
||||
system_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "system"
|
||||
]
|
||||
assert len(system_messages) == 1
|
||||
|
||||
|
||||
async def test_structured_output_processing() -> None:
|
||||
"""Test structured output extraction and state update."""
|
||||
|
||||
class RecipeState(BaseModel):
|
||||
ingredients: list[str]
|
||||
message: str
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data: dict[str, Any] = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Add tomato"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# Agent with structured output
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
updates=[
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_text(text='{"ingredients": ["tomato"], "message": "Added tomato"}')],
|
||||
role="assistant",
|
||||
)
|
||||
],
|
||||
)
|
||||
agent.default_options["response_format"] = RecipeState
|
||||
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(state_schema={"ingredients": {"type": "array"}}),
|
||||
)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should emit StateSnapshotEvent with ingredients
|
||||
state_events: list[Any] = [e for e in events if e.type == "STATE_SNAPSHOT"]
|
||||
assert len(state_events) >= 1
|
||||
|
||||
# Should emit TextMessage with message field
|
||||
text_content_events: list[Any] = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"]
|
||||
assert len(text_content_events) >= 1
|
||||
assert any("Added tomato" in e.delta for e in text_content_events)
|
||||
|
||||
|
||||
async def test_duplicate_client_tools_filtered() -> None:
|
||||
"""Test that client tools duplicating server tools are filtered out."""
|
||||
|
||||
@ai_function
|
||||
def get_weather(location: str) -> str:
|
||||
"""Get weather for location."""
|
||||
return f"Weather in {location}"
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data: dict[str, Any] = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Hello"}],
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Client weather tool.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"location": {"type": "string"}},
|
||||
"required": ["location"],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
)
|
||||
agent.default_options["tools"] = [get_weather]
|
||||
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# tools parameter should not be passed since client tool duplicates server tool
|
||||
assert agent.tools_received is None
|
||||
|
||||
|
||||
async def test_unique_client_tools_merged() -> None:
|
||||
"""Test that unique client tools are merged with server tools."""
|
||||
|
||||
@ai_function
|
||||
def server_tool() -> str:
|
||||
"""Server tool."""
|
||||
return "server"
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data: dict[str, Any] = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Hello"}],
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "client_tool",
|
||||
"description": "Unique client tool.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"param": {"type": "string"}},
|
||||
"required": ["param"],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
)
|
||||
agent.default_options["tools"] = [server_tool]
|
||||
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# tools parameter should be passed with both server and client tools
|
||||
assert agent.tools_received is not None
|
||||
tool_names = [getattr(tool, "name", None) for tool in agent.tools_received]
|
||||
assert "server_tool" in tool_names
|
||||
assert "client_tool" in tool_names
|
||||
|
||||
|
||||
async def test_empty_messages_handling() -> None:
|
||||
"""Test orchestrator handles empty message list gracefully."""
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data: dict[str, Any] = {"messages": []}
|
||||
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should emit run lifecycle events but not call agent
|
||||
assert len(agent.messages_received) == 0
|
||||
run_started = [e for e in events if e.type == "RUN_STARTED"]
|
||||
run_finished = [e for e in events if e.type == "RUN_FINISHED"]
|
||||
assert len(run_started) == 1
|
||||
assert len(run_finished) == 1
|
||||
|
||||
|
||||
async def test_all_messages_filtered_handling() -> None:
|
||||
"""Test orchestrator handles case where all messages are filtered out."""
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data: dict[str, Any] = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [{"type": "tool_result", "tool_call_id": "orphan", "content": "data"}],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should finish without calling agent
|
||||
assert len(agent.messages_received) == 0
|
||||
run_finished = [e for e in events if e.type == "RUN_FINISHED"]
|
||||
assert len(run_finished) == 1
|
||||
|
||||
|
||||
async def test_confirm_changes_with_invalid_json_fallback() -> None:
|
||||
"""Test confirm_changes with invalid JSON falls back to normal processing."""
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="confirm_changes",
|
||||
call_id="call_confirm_invalid",
|
||||
arguments='{"changes": "test"}',
|
||||
)
|
||||
],
|
||||
),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="invalid json {")],
|
||||
),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data: dict[str, Any] = {"messages": []}
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context.set_messages(messages)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Invalid JSON should fall back - user message should be included
|
||||
user_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "user"
|
||||
]
|
||||
assert len(user_messages) == 1
|
||||
|
||||
|
||||
async def test_confirm_changes_closes_active_message_before_finish() -> None:
|
||||
"""Confirm-changes flow closes any active text message before run finishes."""
|
||||
from ag_ui.core import TextMessageEndEvent, TextMessageStartEvent
|
||||
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="write_document_local",
|
||||
call_id="call_1",
|
||||
arguments='{"document": "Draft"}',
|
||||
)
|
||||
]
|
||||
),
|
||||
AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_1", result="Done")]),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data: dict[str, Any] = {"messages": [{"role": "user", "content": "Start"}]}
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
updates=updates,
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(
|
||||
predict_state_config={"document": {"tool": "write_document_local", "tool_argument": "document"}},
|
||||
require_confirmation=True,
|
||||
),
|
||||
)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
start_events = [e for e in events if isinstance(e, TextMessageStartEvent)]
|
||||
end_events = [e for e in events if isinstance(e, TextMessageEndEvent)]
|
||||
assert len(start_events) == 1
|
||||
assert len(end_events) == 1
|
||||
assert end_events[0].message_id == start_events[0].message_id
|
||||
|
||||
end_index = events.index(end_events[0])
|
||||
finished_index = events.index([e for e in events if e.type == "RUN_FINISHED"][0])
|
||||
assert end_index < finished_index
|
||||
|
||||
|
||||
async def test_tool_result_kept_when_call_id_matches() -> None:
|
||||
"""Test tool result is kept when call_id matches pending tool calls."""
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(name="get_data", call_id="call_match", arguments="{}")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(call_id="call_match", result="data")],
|
||||
),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data: dict[str, Any] = {"messages": []}
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context.set_messages(messages)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Tool result should be kept
|
||||
tool_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool"
|
||||
]
|
||||
assert len(tool_messages) == 1
|
||||
assert tool_messages[0].contents[0].result == "data"
|
||||
|
||||
|
||||
async def test_agent_protocol_fallback_paths() -> None:
|
||||
"""Test fallback paths for non-ChatAgent implementations."""
|
||||
|
||||
class CustomAgent:
|
||||
"""Custom agent without ChatAgent type."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.default_options: dict[str, Any] = {"tools": [], "response_format": None}
|
||||
self.chat_client = SimpleNamespace(function_invocation_configuration=SimpleNamespace())
|
||||
self.messages_received: list[Any] = []
|
||||
|
||||
async def run_stream(
|
||||
self,
|
||||
messages: list[Any],
|
||||
*,
|
||||
thread: Any = None,
|
||||
tools: list[Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[AgentResponseUpdate, None]:
|
||||
self.messages_received = messages
|
||||
yield AgentResponseUpdate(contents=[Content.from_text(text="response")], role="assistant")
|
||||
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data: dict[str, Any] = {"messages": []}
|
||||
agent = CustomAgent()
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent, # type: ignore
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context.set_messages(messages)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should work with custom agent implementation
|
||||
assert len(agent.messages_received) > 0
|
||||
|
||||
|
||||
async def test_initial_state_snapshot_with_array_schema() -> None:
|
||||
"""Test state initialization with array type schema."""
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data: dict[str, Any] = {"messages": [], "state": {}}
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(state_schema={"items": {"type": "array"}}),
|
||||
)
|
||||
context.set_messages(messages)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should emit state snapshot with empty array for items
|
||||
state_events: list[Any] = [e for e in events if e.type == "STATE_SNAPSHOT"]
|
||||
assert len(state_events) >= 1
|
||||
|
||||
|
||||
async def test_response_format_skip_text_content() -> None:
|
||||
"""Test that response_format causes skip_text_content to be set."""
|
||||
|
||||
class OutputModel(BaseModel):
|
||||
result: str
|
||||
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data: dict[str, Any] = {"messages": []}
|
||||
|
||||
agent = StubAgent(
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
)
|
||||
agent.default_options["response_format"] = OutputModel
|
||||
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context.set_messages(messages)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Test passes if no errors occur - verifies response_format code path
|
||||
assert len(events) > 0
|
||||
|
||||
|
||||
async def test_human_in_the_loop_handles_none_additional_properties() -> None:
|
||||
"""Test that HumanInTheLoopOrchestrator handles None additional_properties gracefully.
|
||||
|
||||
This test ensures the null safety fix for msg.additional_properties.get() works.
|
||||
"""
|
||||
orchestrator = HumanInTheLoopOrchestrator()
|
||||
|
||||
# Create a message with None additional_properties
|
||||
msg = ChatMessage(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="Hello")],
|
||||
)
|
||||
# Explicitly set additional_properties to None
|
||||
msg.additional_properties = None # type: ignore[assignment]
|
||||
|
||||
agent = StubAgent() # Use default StubAgent
|
||||
config = AgentConfig()
|
||||
context = TestExecutionContext(
|
||||
input_data={"messages": [{"role": "user", "content": "Hello"}]},
|
||||
agent=agent,
|
||||
config=config,
|
||||
)
|
||||
context.set_messages([msg])
|
||||
|
||||
# can_handle should return False (not crash) when additional_properties is None
|
||||
result = orchestrator.can_handle(context)
|
||||
assert result is False
|
||||
|
||||
|
||||
async def test_default_orchestrator_handles_none_default_options() -> None:
|
||||
"""Test that DefaultOrchestrator handles None default_options gracefully.
|
||||
|
||||
This test ensures the null safety fix for context.agent.default_options.get() works.
|
||||
"""
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
# Use StubAgent with default_options set to None
|
||||
agent = StubAgent(default_options=None)
|
||||
config = AgentConfig()
|
||||
context = TestExecutionContext(
|
||||
input_data={"messages": [{"role": "user", "content": "Hello"}]},
|
||||
agent=agent,
|
||||
config=config,
|
||||
)
|
||||
|
||||
# This should NOT crash when accessing default_options.get()
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
# Just check a few events to verify it's working
|
||||
if len(events) > 3:
|
||||
break
|
||||
|
||||
# Test passes if no AttributeError occurred
|
||||
assert True
|
||||
@@ -0,0 +1,320 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for predictive state handling."""
|
||||
|
||||
from ag_ui.core import StateDeltaEvent
|
||||
|
||||
from agent_framework_ag_ui._orchestration._predictive_state import PredictiveStateHandler
|
||||
|
||||
|
||||
class TestPredictiveStateHandlerInit:
|
||||
"""Tests for PredictiveStateHandler initialization."""
|
||||
|
||||
def test_default_init(self):
|
||||
"""Initializes with default values."""
|
||||
handler = PredictiveStateHandler()
|
||||
assert handler.predict_state_config == {}
|
||||
assert handler.current_state == {}
|
||||
assert handler.streaming_tool_args == ""
|
||||
assert handler.last_emitted_state == {}
|
||||
assert handler.state_delta_count == 0
|
||||
assert handler.pending_state_updates == {}
|
||||
|
||||
def test_init_with_config(self):
|
||||
"""Initializes with provided config."""
|
||||
config = {"document": {"tool": "write_doc", "tool_argument": "content"}}
|
||||
state = {"document": "initial"}
|
||||
handler = PredictiveStateHandler(predict_state_config=config, current_state=state)
|
||||
assert handler.predict_state_config == config
|
||||
assert handler.current_state == state
|
||||
|
||||
|
||||
class TestResetStreaming:
|
||||
"""Tests for reset_streaming method."""
|
||||
|
||||
def test_resets_streaming_state(self):
|
||||
"""Resets streaming-related state."""
|
||||
handler = PredictiveStateHandler()
|
||||
handler.streaming_tool_args = "some accumulated args"
|
||||
handler.state_delta_count = 5
|
||||
|
||||
handler.reset_streaming()
|
||||
|
||||
assert handler.streaming_tool_args == ""
|
||||
assert handler.state_delta_count == 0
|
||||
|
||||
|
||||
class TestExtractStateValue:
|
||||
"""Tests for extract_state_value method."""
|
||||
|
||||
def test_no_config(self):
|
||||
"""Returns None when no config."""
|
||||
handler = PredictiveStateHandler()
|
||||
result = handler.extract_state_value("some_tool", {"arg": "value"})
|
||||
assert result is None
|
||||
|
||||
def test_no_args(self):
|
||||
"""Returns None when args is None."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"key": {"tool": "tool", "tool_argument": "arg"}})
|
||||
result = handler.extract_state_value("tool", None)
|
||||
assert result is None
|
||||
|
||||
def test_empty_args(self):
|
||||
"""Returns None when args is empty string."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"key": {"tool": "tool", "tool_argument": "arg"}})
|
||||
result = handler.extract_state_value("tool", "")
|
||||
assert result is None
|
||||
|
||||
def test_tool_not_in_config(self):
|
||||
"""Returns None when tool not in config."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"key": {"tool": "other_tool", "tool_argument": "arg"}})
|
||||
result = handler.extract_state_value("some_tool", {"arg": "value"})
|
||||
assert result is None
|
||||
|
||||
def test_extracts_specific_argument(self):
|
||||
"""Extracts value from specific tool argument."""
|
||||
handler = PredictiveStateHandler(
|
||||
predict_state_config={"document": {"tool": "write_doc", "tool_argument": "content"}}
|
||||
)
|
||||
result = handler.extract_state_value("write_doc", {"content": "Hello world"})
|
||||
assert result == ("document", "Hello world")
|
||||
|
||||
def test_extracts_with_wildcard(self):
|
||||
"""Extracts entire args with * wildcard."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"data": {"tool": "update_data", "tool_argument": "*"}})
|
||||
args = {"key1": "value1", "key2": "value2"}
|
||||
result = handler.extract_state_value("update_data", args)
|
||||
assert result == ("data", args)
|
||||
|
||||
def test_extracts_from_json_string(self):
|
||||
"""Extracts value from JSON string args."""
|
||||
handler = PredictiveStateHandler(
|
||||
predict_state_config={"document": {"tool": "write_doc", "tool_argument": "content"}}
|
||||
)
|
||||
result = handler.extract_state_value("write_doc", '{"content": "Hello world"}')
|
||||
assert result == ("document", "Hello world")
|
||||
|
||||
def test_argument_not_in_args(self):
|
||||
"""Returns None when tool_argument not in args."""
|
||||
handler = PredictiveStateHandler(
|
||||
predict_state_config={"document": {"tool": "write_doc", "tool_argument": "content"}}
|
||||
)
|
||||
result = handler.extract_state_value("write_doc", {"other": "value"})
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestIsPredictiveTool:
|
||||
"""Tests for is_predictive_tool method."""
|
||||
|
||||
def test_none_tool_name(self):
|
||||
"""Returns False for None tool name."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"key": {"tool": "some_tool", "tool_argument": "arg"}})
|
||||
assert handler.is_predictive_tool(None) is False
|
||||
|
||||
def test_no_config(self):
|
||||
"""Returns False when no config."""
|
||||
handler = PredictiveStateHandler()
|
||||
assert handler.is_predictive_tool("some_tool") is False
|
||||
|
||||
def test_tool_in_config(self):
|
||||
"""Returns True when tool is in config."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"key": {"tool": "some_tool", "tool_argument": "arg"}})
|
||||
assert handler.is_predictive_tool("some_tool") is True
|
||||
|
||||
def test_tool_not_in_config(self):
|
||||
"""Returns False when tool not in config."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"key": {"tool": "other_tool", "tool_argument": "arg"}})
|
||||
assert handler.is_predictive_tool("some_tool") is False
|
||||
|
||||
|
||||
class TestEmitStreamingDeltas:
|
||||
"""Tests for emit_streaming_deltas method."""
|
||||
|
||||
def test_no_tool_name(self):
|
||||
"""Returns empty list for None tool name."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"key": {"tool": "tool", "tool_argument": "arg"}})
|
||||
result = handler.emit_streaming_deltas(None, '{"arg": "value"}')
|
||||
assert result == []
|
||||
|
||||
def test_no_config(self):
|
||||
"""Returns empty list when no config."""
|
||||
handler = PredictiveStateHandler()
|
||||
result = handler.emit_streaming_deltas("some_tool", '{"arg": "value"}')
|
||||
assert result == []
|
||||
|
||||
def test_accumulates_args(self):
|
||||
"""Accumulates argument chunks."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"doc": {"tool": "write", "tool_argument": "text"}})
|
||||
handler.emit_streaming_deltas("write", '{"text')
|
||||
handler.emit_streaming_deltas("write", '": "hello')
|
||||
assert handler.streaming_tool_args == '{"text": "hello'
|
||||
|
||||
def test_emits_delta_on_complete_json(self):
|
||||
"""Emits delta when JSON is complete."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"doc": {"tool": "write", "tool_argument": "text"}})
|
||||
events = handler.emit_streaming_deltas("write", '{"text": "hello"}')
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], StateDeltaEvent)
|
||||
assert events[0].delta[0]["path"] == "/doc"
|
||||
assert events[0].delta[0]["value"] == "hello"
|
||||
assert events[0].delta[0]["op"] == "replace"
|
||||
|
||||
def test_emits_delta_on_partial_json(self):
|
||||
"""Emits delta from partial JSON using regex."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"doc": {"tool": "write", "tool_argument": "text"}})
|
||||
# First chunk - partial
|
||||
events = handler.emit_streaming_deltas("write", '{"text": "hel')
|
||||
assert len(events) == 1
|
||||
assert events[0].delta[0]["value"] == "hel"
|
||||
|
||||
def test_does_not_emit_duplicate_deltas(self):
|
||||
"""Does not emit delta when value unchanged."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"doc": {"tool": "write", "tool_argument": "text"}})
|
||||
# First emission
|
||||
events1 = handler.emit_streaming_deltas("write", '{"text": "hello"}')
|
||||
assert len(events1) == 1
|
||||
|
||||
# Reset and emit same value again
|
||||
handler.streaming_tool_args = ""
|
||||
events2 = handler.emit_streaming_deltas("write", '{"text": "hello"}')
|
||||
assert len(events2) == 0 # No duplicate
|
||||
|
||||
def test_emits_delta_on_value_change(self):
|
||||
"""Emits delta when value changes."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"doc": {"tool": "write", "tool_argument": "text"}})
|
||||
# First value
|
||||
events1 = handler.emit_streaming_deltas("write", '{"text": "hello"}')
|
||||
assert len(events1) == 1
|
||||
|
||||
# Reset and new value
|
||||
handler.streaming_tool_args = ""
|
||||
events2 = handler.emit_streaming_deltas("write", '{"text": "world"}')
|
||||
assert len(events2) == 1
|
||||
assert events2[0].delta[0]["value"] == "world"
|
||||
|
||||
def test_tracks_pending_updates(self):
|
||||
"""Tracks pending state updates."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"doc": {"tool": "write", "tool_argument": "text"}})
|
||||
handler.emit_streaming_deltas("write", '{"text": "hello"}')
|
||||
assert handler.pending_state_updates == {"doc": "hello"}
|
||||
|
||||
|
||||
class TestEmitPartialDeltas:
|
||||
"""Tests for _emit_partial_deltas method."""
|
||||
|
||||
def test_unescapes_newlines(self):
|
||||
"""Unescapes \\n in partial values."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"doc": {"tool": "write", "tool_argument": "text"}})
|
||||
handler.streaming_tool_args = '{"text": "line1\\nline2'
|
||||
events = handler._emit_partial_deltas("write")
|
||||
assert len(events) == 1
|
||||
assert events[0].delta[0]["value"] == "line1\nline2"
|
||||
|
||||
def test_handles_escaped_quotes_partially(self):
|
||||
"""Handles escaped quotes - regex stops at quote character."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"doc": {"tool": "write", "tool_argument": "text"}})
|
||||
# The regex pattern [^"]* stops at ANY quote, including escaped ones.
|
||||
# This is expected behavior for partial streaming - the full JSON
|
||||
# will be parsed correctly when complete.
|
||||
handler.streaming_tool_args = '{"text": "say \\"hi'
|
||||
events = handler._emit_partial_deltas("write")
|
||||
assert len(events) == 1
|
||||
# Captures "say \" then the backslash gets converted to empty string
|
||||
# by the replace("\\\\", "\\") first, then replace('\\"', '"')
|
||||
# but since there's no closing quote, we get "say \"
|
||||
# After .replace("\\\\", "\\") -> "say \"
|
||||
# After .replace('\\"', '"') -> "say " (but actually still "say \" due to order)
|
||||
# The actual result: backslash is preserved since it's not a valid escape sequence
|
||||
assert events[0].delta[0]["value"] == "say \\"
|
||||
|
||||
def test_unescapes_backslashes(self):
|
||||
"""Unescapes \\\\ in partial values."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"doc": {"tool": "write", "tool_argument": "text"}})
|
||||
handler.streaming_tool_args = '{"text": "path\\\\to\\\\file'
|
||||
events = handler._emit_partial_deltas("write")
|
||||
assert len(events) == 1
|
||||
assert events[0].delta[0]["value"] == "path\\to\\file"
|
||||
|
||||
|
||||
class TestEmitCompleteDeltas:
|
||||
"""Tests for _emit_complete_deltas method."""
|
||||
|
||||
def test_emits_for_matching_tool(self):
|
||||
"""Emits delta for tool matching config."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"doc": {"tool": "write", "tool_argument": "text"}})
|
||||
events = handler._emit_complete_deltas("write", {"text": "content"})
|
||||
assert len(events) == 1
|
||||
assert events[0].delta[0]["value"] == "content"
|
||||
|
||||
def test_skips_non_matching_tool(self):
|
||||
"""Skips tools not matching config."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"doc": {"tool": "write", "tool_argument": "text"}})
|
||||
events = handler._emit_complete_deltas("other_tool", {"text": "content"})
|
||||
assert len(events) == 0
|
||||
|
||||
def test_handles_wildcard_argument(self):
|
||||
"""Handles * wildcard for entire args."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"data": {"tool": "update", "tool_argument": "*"}})
|
||||
args = {"key1": "val1", "key2": "val2"}
|
||||
events = handler._emit_complete_deltas("update", args)
|
||||
assert len(events) == 1
|
||||
assert events[0].delta[0]["value"] == args
|
||||
|
||||
def test_skips_missing_argument(self):
|
||||
"""Skips when tool_argument not in args."""
|
||||
handler = PredictiveStateHandler(predict_state_config={"doc": {"tool": "write", "tool_argument": "text"}})
|
||||
events = handler._emit_complete_deltas("write", {"other": "value"})
|
||||
assert len(events) == 0
|
||||
|
||||
|
||||
class TestCreateDeltaEvent:
|
||||
"""Tests for _create_delta_event method."""
|
||||
|
||||
def test_creates_event(self):
|
||||
"""Creates StateDeltaEvent with correct structure."""
|
||||
handler = PredictiveStateHandler()
|
||||
event = handler._create_delta_event("key", "value")
|
||||
|
||||
assert isinstance(event, StateDeltaEvent)
|
||||
assert event.delta[0]["op"] == "replace"
|
||||
assert event.delta[0]["path"] == "/key"
|
||||
assert event.delta[0]["value"] == "value"
|
||||
|
||||
def test_increments_count(self):
|
||||
"""Increments state_delta_count."""
|
||||
handler = PredictiveStateHandler()
|
||||
handler._create_delta_event("key", "value")
|
||||
assert handler.state_delta_count == 1
|
||||
handler._create_delta_event("key", "value2")
|
||||
assert handler.state_delta_count == 2
|
||||
|
||||
|
||||
class TestApplyPendingUpdates:
|
||||
"""Tests for apply_pending_updates method."""
|
||||
|
||||
def test_applies_pending_to_current(self):
|
||||
"""Applies pending updates to current state."""
|
||||
handler = PredictiveStateHandler(current_state={"existing": "value"})
|
||||
handler.pending_state_updates = {"doc": "new content", "count": 5}
|
||||
|
||||
handler.apply_pending_updates()
|
||||
|
||||
assert handler.current_state == {"existing": "value", "doc": "new content", "count": 5}
|
||||
|
||||
def test_clears_pending_updates(self):
|
||||
"""Clears pending updates after applying."""
|
||||
handler = PredictiveStateHandler()
|
||||
handler.pending_state_updates = {"doc": "content"}
|
||||
|
||||
handler.apply_pending_updates()
|
||||
|
||||
assert handler.pending_state_updates == {}
|
||||
|
||||
def test_overwrites_existing_keys(self):
|
||||
"""Overwrites existing keys in current state."""
|
||||
handler = PredictiveStateHandler(current_state={"doc": "old"})
|
||||
handler.pending_state_updates = {"doc": "new"}
|
||||
|
||||
handler.apply_pending_updates()
|
||||
|
||||
assert handler.current_state["doc"] == "new"
|
||||
@@ -0,0 +1,373 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for _run.py helper functions and FlowState."""
|
||||
|
||||
from agent_framework import ChatMessage, Content
|
||||
|
||||
from agent_framework_ag_ui._run import (
|
||||
FlowState,
|
||||
_build_safe_metadata,
|
||||
_create_state_context_message,
|
||||
_has_only_tool_calls,
|
||||
_inject_state_context,
|
||||
_should_suppress_intermediate_snapshot,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildSafeMetadata:
|
||||
"""Tests for _build_safe_metadata function."""
|
||||
|
||||
def test_none_metadata(self):
|
||||
"""Returns empty dict for None."""
|
||||
result = _build_safe_metadata(None)
|
||||
assert result == {}
|
||||
|
||||
def test_empty_metadata(self):
|
||||
"""Returns empty dict for empty dict."""
|
||||
result = _build_safe_metadata({})
|
||||
assert result == {}
|
||||
|
||||
def test_short_string_values(self):
|
||||
"""Preserves short string values."""
|
||||
metadata = {"key1": "short", "key2": "value"}
|
||||
result = _build_safe_metadata(metadata)
|
||||
assert result == metadata
|
||||
|
||||
def test_truncates_long_strings(self):
|
||||
"""Truncates strings over 512 chars."""
|
||||
long_value = "x" * 1000
|
||||
metadata = {"key": long_value}
|
||||
result = _build_safe_metadata(metadata)
|
||||
assert len(result["key"]) == 512
|
||||
|
||||
def test_serializes_non_strings(self):
|
||||
"""Serializes non-string values to JSON."""
|
||||
metadata = {"count": 42, "items": [1, 2, 3]}
|
||||
result = _build_safe_metadata(metadata)
|
||||
assert result["count"] == "42"
|
||||
assert result["items"] == "[1, 2, 3]"
|
||||
|
||||
def test_truncates_serialized_values(self):
|
||||
"""Truncates serialized values over 512 chars."""
|
||||
long_list = list(range(200))
|
||||
metadata = {"data": long_list}
|
||||
result = _build_safe_metadata(metadata)
|
||||
assert len(result["data"]) == 512
|
||||
|
||||
|
||||
class TestHasOnlyToolCalls:
|
||||
"""Tests for _has_only_tool_calls function."""
|
||||
|
||||
def test_only_tool_calls(self):
|
||||
"""Returns True when only function_call content."""
|
||||
contents = [
|
||||
Content.from_function_call(call_id="call_1", name="tool1", arguments="{}"),
|
||||
]
|
||||
assert _has_only_tool_calls(contents) is True
|
||||
|
||||
def test_tool_call_with_text(self):
|
||||
"""Returns False when both tool call and text."""
|
||||
contents = [
|
||||
Content.from_text("Some text"),
|
||||
Content.from_function_call(call_id="call_1", name="tool1", arguments="{}"),
|
||||
]
|
||||
assert _has_only_tool_calls(contents) is False
|
||||
|
||||
def test_only_text(self):
|
||||
"""Returns False when only text."""
|
||||
contents = [Content.from_text("Just text")]
|
||||
assert _has_only_tool_calls(contents) is False
|
||||
|
||||
def test_empty_contents(self):
|
||||
"""Returns False for empty contents."""
|
||||
assert _has_only_tool_calls([]) is False
|
||||
|
||||
def test_tool_call_with_empty_text(self):
|
||||
"""Returns True when text content has empty text."""
|
||||
contents = [
|
||||
Content.from_text(""),
|
||||
Content.from_function_call(call_id="call_1", name="tool1", arguments="{}"),
|
||||
]
|
||||
assert _has_only_tool_calls(contents) is True
|
||||
|
||||
|
||||
class TestShouldSuppressIntermediateSnapshot:
|
||||
"""Tests for _should_suppress_intermediate_snapshot function."""
|
||||
|
||||
def test_no_tool_name(self):
|
||||
"""Returns False when no tool name."""
|
||||
result = _should_suppress_intermediate_snapshot(
|
||||
None, {"key": {"tool": "write_doc", "tool_argument": "content"}}, False
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_no_config(self):
|
||||
"""Returns False when no config."""
|
||||
result = _should_suppress_intermediate_snapshot("write_doc", None, False)
|
||||
assert result is False
|
||||
|
||||
def test_confirmation_required(self):
|
||||
"""Returns False when confirmation is required."""
|
||||
config = {"key": {"tool": "write_doc", "tool_argument": "content"}}
|
||||
result = _should_suppress_intermediate_snapshot("write_doc", config, True)
|
||||
assert result is False
|
||||
|
||||
def test_tool_not_in_config(self):
|
||||
"""Returns False when tool not in config."""
|
||||
config = {"key": {"tool": "other_tool", "tool_argument": "content"}}
|
||||
result = _should_suppress_intermediate_snapshot("write_doc", config, False)
|
||||
assert result is False
|
||||
|
||||
def test_suppresses_predictive_tool(self):
|
||||
"""Returns True for predictive tool without confirmation."""
|
||||
config = {"document": {"tool": "write_doc", "tool_argument": "content"}}
|
||||
result = _should_suppress_intermediate_snapshot("write_doc", config, False)
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestFlowState:
|
||||
"""Tests for FlowState dataclass."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Tests default initialization."""
|
||||
flow = FlowState()
|
||||
assert flow.message_id is None
|
||||
assert flow.tool_call_id is None
|
||||
assert flow.tool_call_name is None
|
||||
assert flow.waiting_for_approval is False
|
||||
assert flow.current_state == {}
|
||||
assert flow.accumulated_text == ""
|
||||
assert flow.pending_tool_calls == []
|
||||
assert flow.tool_calls_by_id == {}
|
||||
assert flow.tool_results == []
|
||||
assert flow.tool_calls_ended == set()
|
||||
|
||||
def test_get_tool_name(self):
|
||||
"""Tests get_tool_name method."""
|
||||
flow = FlowState()
|
||||
flow.tool_calls_by_id = {"call_123": {"function": {"name": "get_weather", "arguments": "{}"}}}
|
||||
|
||||
assert flow.get_tool_name("call_123") == "get_weather"
|
||||
assert flow.get_tool_name("nonexistent") is None
|
||||
assert flow.get_tool_name(None) is None
|
||||
|
||||
def test_get_tool_name_empty_name(self):
|
||||
"""Tests get_tool_name with empty name."""
|
||||
flow = FlowState()
|
||||
flow.tool_calls_by_id = {"call_123": {"function": {"name": "", "arguments": "{}"}}}
|
||||
|
||||
assert flow.get_tool_name("call_123") is None
|
||||
|
||||
def test_get_pending_without_end(self):
|
||||
"""Tests get_pending_without_end method."""
|
||||
flow = FlowState()
|
||||
flow.pending_tool_calls = [
|
||||
{"id": "call_1", "function": {"name": "tool1"}},
|
||||
{"id": "call_2", "function": {"name": "tool2"}},
|
||||
{"id": "call_3", "function": {"name": "tool3"}},
|
||||
]
|
||||
flow.tool_calls_ended = {"call_1", "call_3"}
|
||||
|
||||
result = flow.get_pending_without_end()
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "call_2"
|
||||
|
||||
|
||||
class TestCreateStateContextMessage:
|
||||
"""Tests for _create_state_context_message function."""
|
||||
|
||||
def test_no_state(self):
|
||||
"""Returns None when no state."""
|
||||
result = _create_state_context_message({}, {"properties": {}})
|
||||
assert result is None
|
||||
|
||||
def test_no_schema(self):
|
||||
"""Returns None when no schema."""
|
||||
result = _create_state_context_message({"key": "value"}, {})
|
||||
assert result is None
|
||||
|
||||
def test_creates_message(self):
|
||||
"""Creates state context message."""
|
||||
from agent_framework import Role
|
||||
|
||||
state = {"document": "Hello world"}
|
||||
schema = {"properties": {"document": {"type": "string"}}}
|
||||
|
||||
result = _create_state_context_message(state, schema)
|
||||
|
||||
assert result is not None
|
||||
assert result.role == Role.SYSTEM
|
||||
assert len(result.contents) == 1
|
||||
assert "Hello world" in result.contents[0].text
|
||||
assert "Current state" in result.contents[0].text
|
||||
|
||||
|
||||
class TestInjectStateContext:
|
||||
"""Tests for _inject_state_context function."""
|
||||
|
||||
def test_no_state_message(self):
|
||||
"""Returns original messages when no state context needed."""
|
||||
messages = [ChatMessage(role="user", contents=[Content.from_text("Hello")])]
|
||||
result = _inject_state_context(messages, {}, {})
|
||||
assert result == messages
|
||||
|
||||
def test_empty_messages(self):
|
||||
"""Returns empty list for empty messages."""
|
||||
result = _inject_state_context([], {"key": "value"}, {"properties": {}})
|
||||
assert result == []
|
||||
|
||||
def test_last_message_not_user(self):
|
||||
"""Returns original messages when last message is not from user."""
|
||||
messages = [
|
||||
ChatMessage(role="user", contents=[Content.from_text("Hello")]),
|
||||
ChatMessage(role="assistant", contents=[Content.from_text("Hi")]),
|
||||
]
|
||||
state = {"key": "value"}
|
||||
schema = {"properties": {"key": {"type": "string"}}}
|
||||
|
||||
result = _inject_state_context(messages, state, schema)
|
||||
assert result == messages
|
||||
|
||||
def test_injects_before_last_user_message(self):
|
||||
"""Injects state context before last user message."""
|
||||
from agent_framework import Role
|
||||
|
||||
messages = [
|
||||
ChatMessage(role="system", contents=[Content.from_text("You are helpful")]),
|
||||
ChatMessage(role="user", contents=[Content.from_text("Hello")]),
|
||||
]
|
||||
state = {"document": "content"}
|
||||
schema = {"properties": {"document": {"type": "string"}}}
|
||||
|
||||
result = _inject_state_context(messages, state, schema)
|
||||
|
||||
assert len(result) == 3
|
||||
# System message first
|
||||
assert result[0].role == Role.SYSTEM
|
||||
assert "helpful" in result[0].contents[0].text
|
||||
# State context second
|
||||
assert result[1].role == Role.SYSTEM
|
||||
assert "Current state" in result[1].contents[0].text
|
||||
# User message last
|
||||
assert result[2].role == Role.USER
|
||||
assert "Hello" in result[2].contents[0].text
|
||||
|
||||
|
||||
# Additional tests for _run.py functions
|
||||
|
||||
|
||||
def test_emit_text_basic():
|
||||
"""Test _emit_text emits correct events."""
|
||||
from agent_framework_ag_ui._run import _emit_text
|
||||
|
||||
flow = FlowState()
|
||||
content = Content.from_text("Hello world")
|
||||
|
||||
events = _emit_text(content, flow)
|
||||
|
||||
assert len(events) == 2 # TextMessageStartEvent + TextMessageContentEvent
|
||||
assert flow.message_id is not None
|
||||
assert flow.accumulated_text == "Hello world"
|
||||
|
||||
|
||||
def test_emit_text_skip_empty():
|
||||
"""Test _emit_text skips empty text."""
|
||||
from agent_framework_ag_ui._run import _emit_text
|
||||
|
||||
flow = FlowState()
|
||||
content = Content.from_text("")
|
||||
|
||||
events = _emit_text(content, flow)
|
||||
|
||||
assert len(events) == 0
|
||||
|
||||
|
||||
def test_emit_text_continues_existing_message():
|
||||
"""Test _emit_text continues existing message."""
|
||||
from agent_framework_ag_ui._run import _emit_text
|
||||
|
||||
flow = FlowState()
|
||||
flow.message_id = "existing-id"
|
||||
content = Content.from_text("more text")
|
||||
|
||||
events = _emit_text(content, flow)
|
||||
|
||||
assert len(events) == 1 # Only TextMessageContentEvent, no new start
|
||||
assert flow.message_id == "existing-id"
|
||||
|
||||
|
||||
def test_emit_text_skips_when_waiting_for_approval():
|
||||
"""Test _emit_text skips when waiting for approval."""
|
||||
from agent_framework_ag_ui._run import _emit_text
|
||||
|
||||
flow = FlowState()
|
||||
flow.waiting_for_approval = True
|
||||
content = Content.from_text("should skip")
|
||||
|
||||
events = _emit_text(content, flow)
|
||||
|
||||
assert len(events) == 0
|
||||
|
||||
|
||||
def test_emit_text_skips_when_skip_text_flag():
|
||||
"""Test _emit_text skips with skip_text flag."""
|
||||
from agent_framework_ag_ui._run import _emit_text
|
||||
|
||||
flow = FlowState()
|
||||
content = Content.from_text("should skip")
|
||||
|
||||
events = _emit_text(content, flow, skip_text=True)
|
||||
|
||||
assert len(events) == 0
|
||||
|
||||
|
||||
def test_emit_tool_call_basic():
|
||||
"""Test _emit_tool_call emits correct events."""
|
||||
from agent_framework_ag_ui._run import _emit_tool_call
|
||||
|
||||
flow = FlowState()
|
||||
content = Content.from_function_call(
|
||||
call_id="call_123",
|
||||
name="get_weather",
|
||||
arguments='{"city": "NYC"}',
|
||||
)
|
||||
|
||||
events = _emit_tool_call(content, flow)
|
||||
|
||||
assert len(events) >= 1 # At least ToolCallStartEvent
|
||||
assert flow.tool_call_id == "call_123"
|
||||
assert flow.tool_call_name == "get_weather"
|
||||
|
||||
|
||||
def test_emit_tool_call_generates_id():
|
||||
"""Test _emit_tool_call generates ID when not provided."""
|
||||
from agent_framework_ag_ui._run import _emit_tool_call
|
||||
|
||||
flow = FlowState()
|
||||
# Create content without call_id
|
||||
content = Content(type="function_call", name="test_tool", arguments="{}")
|
||||
|
||||
events = _emit_tool_call(content, flow)
|
||||
|
||||
assert len(events) >= 1
|
||||
assert flow.tool_call_id is not None # ID should be generated
|
||||
|
||||
|
||||
def test_extract_approved_state_updates_no_handler():
|
||||
"""Test _extract_approved_state_updates returns empty with no handler."""
|
||||
from agent_framework_ag_ui._run import _extract_approved_state_updates
|
||||
|
||||
messages = [ChatMessage(role="user", contents=[Content.from_text("Hello")])]
|
||||
result = _extract_approved_state_updates(messages, None)
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_extract_approved_state_updates_no_approval():
|
||||
"""Test _extract_approved_state_updates returns empty when no approval content."""
|
||||
from agent_framework_ag_ui._orchestration._predictive_state import PredictiveStateHandler
|
||||
from agent_framework_ag_ui._run import _extract_approved_state_updates
|
||||
|
||||
handler = PredictiveStateHandler(predict_state_config={"doc": {"tool": "write", "tool_argument": "content"}})
|
||||
messages = [ChatMessage(role="user", contents=[Content.from_text("Hello")])]
|
||||
result = _extract_approved_state_updates(messages, handler)
|
||||
assert result == {}
|
||||
@@ -1,108 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for shared state management."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from ag_ui.core import StateSnapshotEvent
|
||||
from agent_framework import ChatAgent, ChatResponseUpdate, Content
|
||||
|
||||
from agent_framework_ag_ui._agent import AgentFrameworkAgent
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from utils_test_ag_ui import StreamingChatClientStub, stream_from_updates
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_agent() -> ChatAgent:
|
||||
"""Create a mock agent for testing."""
|
||||
updates = [ChatResponseUpdate(contents=[Content.from_text(text="Hello!")])]
|
||||
chat_client = StreamingChatClientStub(stream_from_updates(updates))
|
||||
return ChatAgent(name="test_agent", instructions="Test agent", chat_client=chat_client)
|
||||
|
||||
|
||||
def test_state_snapshot_event():
|
||||
"""Test creating state snapshot events."""
|
||||
bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread")
|
||||
|
||||
state = {
|
||||
"recipe": {
|
||||
"name": "Chocolate Chip Cookies",
|
||||
"ingredients": ["flour", "sugar", "chocolate chips"],
|
||||
"instructions": ["Mix ingredients", "Bake at 350°F"],
|
||||
"servings": 24,
|
||||
}
|
||||
}
|
||||
|
||||
event = bridge.create_state_snapshot_event(state)
|
||||
|
||||
assert isinstance(event, StateSnapshotEvent)
|
||||
assert event.snapshot == state
|
||||
assert event.snapshot["recipe"]["name"] == "Chocolate Chip Cookies"
|
||||
assert len(event.snapshot["recipe"]["ingredients"]) == 3
|
||||
|
||||
|
||||
def test_state_delta_event():
|
||||
"""Test creating state delta events using JSON Patch format."""
|
||||
bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread")
|
||||
|
||||
# JSON Patch operations (RFC 6902)
|
||||
delta = [
|
||||
{"op": "add", "path": "/recipe/ingredients/-", "value": "vanilla extract"},
|
||||
{"op": "replace", "path": "/recipe/servings", "value": 30},
|
||||
]
|
||||
|
||||
event = bridge.create_state_delta_event(delta)
|
||||
|
||||
assert event.delta == delta
|
||||
assert len(event.delta) == 2
|
||||
assert event.delta[0]["op"] == "add"
|
||||
assert event.delta[1]["op"] == "replace"
|
||||
|
||||
|
||||
async def test_agent_with_initial_state(mock_agent: ChatAgent) -> None:
|
||||
"""Test agent emits state snapshot when initial state provided."""
|
||||
state_schema: dict[str, Any] = {"recipe": {"type": "object", "properties": {"name": {"type": "string"}}}}
|
||||
|
||||
agent = AgentFrameworkAgent(
|
||||
agent=mock_agent,
|
||||
state_schema=state_schema,
|
||||
)
|
||||
|
||||
initial_state = {"recipe": {"name": "Test Recipe"}}
|
||||
|
||||
input_data: dict[str, Any] = {
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"state": initial_state,
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in agent.run_agent(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should have RunStartedEvent, StateSnapshotEvent, RunFinishedEvent at minimum
|
||||
snapshot_events = [e for e in events if isinstance(e, StateSnapshotEvent)]
|
||||
assert len(snapshot_events) == 1
|
||||
assert snapshot_events[0].snapshot == initial_state
|
||||
|
||||
|
||||
async def test_agent_without_state_schema(mock_agent: ChatAgent) -> None:
|
||||
"""Test agent doesn't emit state events without state schema."""
|
||||
agent = AgentFrameworkAgent(agent=mock_agent)
|
||||
|
||||
input_data: dict[str, Any] = {
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"state": {"some": "state"},
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in agent.run_agent(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should NOT have any StateSnapshotEvent
|
||||
snapshot_events = [e for e in events if isinstance(e, StateSnapshotEvent)]
|
||||
assert len(snapshot_events) == 0
|
||||
@@ -1,105 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from ag_ui.core import CustomEvent, EventType
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
from agent_framework_ag_ui._orchestration._state_manager import StateManager
|
||||
|
||||
|
||||
def test_state_manager_initializes_defaults_and_snapshot() -> None:
|
||||
state_manager = StateManager(
|
||||
state_schema={"items": {"type": "array"}, "metadata": {"type": "object"}},
|
||||
predict_state_config=None,
|
||||
require_confirmation=True,
|
||||
)
|
||||
current_state = state_manager.initialize({"metadata": {"a": 1}})
|
||||
bridge = AgentFrameworkEventBridge(run_id="run", thread_id="thread", current_state=current_state)
|
||||
|
||||
snapshot_event = state_manager.initial_snapshot_event(bridge)
|
||||
assert snapshot_event is not None
|
||||
assert snapshot_event.snapshot["items"] == []
|
||||
assert snapshot_event.snapshot["metadata"] == {"a": 1}
|
||||
|
||||
|
||||
def test_state_manager_predict_state_event_shape() -> None:
|
||||
state_manager = StateManager(
|
||||
state_schema=None,
|
||||
predict_state_config={"doc": {"tool": "write_document_local", "tool_argument": "document"}},
|
||||
require_confirmation=True,
|
||||
)
|
||||
predict_event = state_manager.predict_state_event()
|
||||
assert isinstance(predict_event, CustomEvent)
|
||||
assert predict_event.type == EventType.CUSTOM
|
||||
assert predict_event.name == "PredictState"
|
||||
assert predict_event.value[0]["state_key"] == "doc"
|
||||
|
||||
|
||||
def test_state_context_only_when_new_user_turn() -> None:
|
||||
state_manager = StateManager(
|
||||
state_schema={"items": {"type": "array"}},
|
||||
predict_state_config=None,
|
||||
require_confirmation=True,
|
||||
)
|
||||
state_manager.initialize({"items": [1]})
|
||||
|
||||
assert state_manager.state_context_message(is_new_user_turn=False, conversation_has_tool_calls=False) is None
|
||||
|
||||
message = state_manager.state_context_message(is_new_user_turn=True, conversation_has_tool_calls=False)
|
||||
assert isinstance(message, ChatMessage)
|
||||
assert message.contents[0].type == "text"
|
||||
assert "Current state of the application" in message.contents[0].text
|
||||
|
||||
|
||||
def test_state_manager_with_dataclass_in_state() -> None:
|
||||
"""Test that state containing dataclasses can be serialized without crashing.
|
||||
|
||||
This test ensures the fix for JSON serialization errors when state
|
||||
contains dataclass or other non-JSON-serializable objects.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class UserData:
|
||||
name: str
|
||||
age: int
|
||||
|
||||
state_manager = StateManager(
|
||||
state_schema={"user": {"type": "object"}},
|
||||
predict_state_config=None,
|
||||
require_confirmation=True,
|
||||
)
|
||||
# Initialize with a dataclass object in the state
|
||||
state_manager.initialize({"user": UserData(name="Alice", age=30)})
|
||||
|
||||
# This should NOT raise TypeError when generating the context message
|
||||
message = state_manager.state_context_message(is_new_user_turn=True, conversation_has_tool_calls=False)
|
||||
|
||||
assert message is not None
|
||||
assert isinstance(message, ChatMessage)
|
||||
# The dataclass should be serialized to JSON in the message
|
||||
assert "Alice" in message.contents[0].text
|
||||
assert "30" in message.contents[0].text
|
||||
|
||||
|
||||
def test_state_manager_with_pydantic_in_state() -> None:
|
||||
"""Test that state containing Pydantic models can be serialized without crashing."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
class UserModel(BaseModel):
|
||||
email: str
|
||||
active: bool
|
||||
|
||||
state_manager = StateManager(
|
||||
state_schema={"user": {"type": "object"}},
|
||||
predict_state_config=None,
|
||||
require_confirmation=True,
|
||||
)
|
||||
# Initialize with a Pydantic model in the state
|
||||
state_manager.initialize({"user": UserModel(email="test@example.com", active=True)})
|
||||
|
||||
# This should NOT raise TypeError
|
||||
message = state_manager.state_context_message(is_new_user_turn=True, conversation_has_tool_calls=False)
|
||||
|
||||
assert message is not None
|
||||
assert "test@example.com" in message.contents[0].text
|
||||
@@ -129,3 +129,95 @@ def test_collect_server_tools_with_mcp_tools_via_public_property() -> None:
|
||||
assert "regular_tool" in names
|
||||
assert "mcp_function" in names
|
||||
assert len(tools) == 2
|
||||
|
||||
|
||||
# Additional tests for tooling coverage
|
||||
|
||||
|
||||
def test_collect_server_tools_no_default_options() -> None:
|
||||
"""collect_server_tools returns empty list when agent has no default_options."""
|
||||
|
||||
class MockAgent:
|
||||
pass
|
||||
|
||||
agent = MockAgent()
|
||||
tools = collect_server_tools(agent)
|
||||
assert tools == []
|
||||
|
||||
|
||||
def test_register_additional_client_tools_no_tools() -> None:
|
||||
"""register_additional_client_tools does nothing with None tools."""
|
||||
mock_chat_client = MagicMock()
|
||||
agent = ChatAgent(chat_client=mock_chat_client)
|
||||
|
||||
# Should not raise
|
||||
register_additional_client_tools(agent, None)
|
||||
|
||||
|
||||
def test_register_additional_client_tools_no_chat_client() -> None:
|
||||
"""register_additional_client_tools does nothing when agent has no chat_client."""
|
||||
from agent_framework_ag_ui._orchestration._tooling import register_additional_client_tools
|
||||
|
||||
class MockAgent:
|
||||
pass
|
||||
|
||||
agent = MockAgent()
|
||||
tools = [DummyTool("x")]
|
||||
|
||||
# Should not raise
|
||||
register_additional_client_tools(agent, tools)
|
||||
|
||||
|
||||
def test_merge_tools_no_client_tools() -> None:
|
||||
"""merge_tools returns None when no client tools."""
|
||||
server = [DummyTool("a")]
|
||||
result = merge_tools(server, None)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_merge_tools_all_duplicates() -> None:
|
||||
"""merge_tools returns None when all client tools duplicate server tools."""
|
||||
server = [DummyTool("a"), DummyTool("b")]
|
||||
client = [DummyTool("a"), DummyTool("b")]
|
||||
result = merge_tools(server, client)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_merge_tools_empty_server() -> None:
|
||||
"""merge_tools works with empty server tools."""
|
||||
server: list = []
|
||||
client = [DummyTool("a"), DummyTool("b")]
|
||||
result = merge_tools(server, client)
|
||||
assert result is not None
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
def test_merge_tools_with_approval_tools_no_client() -> None:
|
||||
"""merge_tools returns server tools when they have approval mode even without client tools."""
|
||||
|
||||
class ApprovalTool:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.approval_mode = "always_require"
|
||||
|
||||
server = [ApprovalTool("write_doc")]
|
||||
result = merge_tools(server, None)
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "write_doc"
|
||||
|
||||
|
||||
def test_merge_tools_with_approval_tools_all_duplicates() -> None:
|
||||
"""merge_tools returns server tools with approval mode even when client duplicates."""
|
||||
|
||||
class ApprovalTool:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.approval_mode = "always_require"
|
||||
|
||||
server = [ApprovalTool("write_doc")]
|
||||
client = [DummyTool("write_doc")] # Same name as server
|
||||
result = merge_tools(server, client)
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert result[0].approval_mode == "always_require"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
"""Tests for type definitions in _types.py."""
|
||||
|
||||
from agent_framework_ag_ui._types import AgentState, PredictStateConfig, RunMetadata
|
||||
from agent_framework_ag_ui._types import AgentState, AGUIRequest, PredictStateConfig, RunMetadata
|
||||
|
||||
|
||||
class TestPredictStateConfig:
|
||||
@@ -143,3 +143,83 @@ class TestAgentState:
|
||||
assert len(state["messages"]) == 2
|
||||
assert "metadata" in state["messages"][0]
|
||||
assert "tool_calls" in state["messages"][1]
|
||||
|
||||
|
||||
class TestAGUIRequest:
|
||||
"""Test AGUIRequest Pydantic model."""
|
||||
|
||||
def test_agui_request_minimal(self) -> None:
|
||||
"""Test creating AGUIRequest with only required fields."""
|
||||
request = AGUIRequest(messages=[{"role": "user", "content": "Hello"}])
|
||||
|
||||
assert len(request.messages) == 1
|
||||
assert request.messages[0]["content"] == "Hello"
|
||||
assert request.run_id is None
|
||||
assert request.thread_id is None
|
||||
assert request.state is None
|
||||
assert request.tools is None
|
||||
assert request.context is None
|
||||
assert request.forwarded_props is None
|
||||
assert request.parent_run_id is None
|
||||
|
||||
def test_agui_request_all_fields(self) -> None:
|
||||
"""Test creating AGUIRequest with all fields populated."""
|
||||
request = AGUIRequest(
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
run_id="run-123",
|
||||
thread_id="thread-456",
|
||||
state={"counter": 0},
|
||||
tools=[{"name": "search", "description": "Search tool"}],
|
||||
context=[{"type": "document", "content": "Some context"}],
|
||||
forwarded_props={"custom_key": "custom_value"},
|
||||
parent_run_id="parent-run-789",
|
||||
)
|
||||
|
||||
assert request.run_id == "run-123"
|
||||
assert request.thread_id == "thread-456"
|
||||
assert request.state == {"counter": 0}
|
||||
assert request.tools == [{"name": "search", "description": "Search tool"}]
|
||||
assert request.context == [{"type": "document", "content": "Some context"}]
|
||||
assert request.forwarded_props == {"custom_key": "custom_value"}
|
||||
assert request.parent_run_id == "parent-run-789"
|
||||
|
||||
def test_agui_request_model_dump_excludes_none(self) -> None:
|
||||
"""Test that model_dump(exclude_none=True) excludes None fields."""
|
||||
request = AGUIRequest(
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
tools=[{"name": "my_tool"}],
|
||||
context=[{"id": "ctx1"}],
|
||||
)
|
||||
|
||||
dumped = request.model_dump(exclude_none=True)
|
||||
|
||||
assert "messages" in dumped
|
||||
assert "tools" in dumped
|
||||
assert "context" in dumped
|
||||
assert "run_id" not in dumped
|
||||
assert "thread_id" not in dumped
|
||||
assert "state" not in dumped
|
||||
assert "forwarded_props" not in dumped
|
||||
assert "parent_run_id" not in dumped
|
||||
|
||||
def test_agui_request_model_dump_includes_all_set_fields(self) -> None:
|
||||
"""Test that model_dump preserves all explicitly set fields.
|
||||
|
||||
This is critical for the fix - ensuring tools, context, forwarded_props,
|
||||
and parent_run_id are not stripped during request validation.
|
||||
"""
|
||||
request = AGUIRequest(
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
tools=[{"name": "client_tool", "parameters": {"type": "object"}}],
|
||||
context=[{"type": "snippet", "content": "code here"}],
|
||||
forwarded_props={"auth_token": "secret", "user_id": "user-1"},
|
||||
parent_run_id="parent-456",
|
||||
)
|
||||
|
||||
dumped = request.model_dump(exclude_none=True)
|
||||
|
||||
# Verify all fields are preserved (the main bug fix)
|
||||
assert dumped["tools"] == [{"name": "client_tool", "parameters": {"type": "object"}}]
|
||||
assert dumped["context"] == [{"type": "snippet", "content": "code here"}]
|
||||
assert dumped["forwarded_props"] == {"auth_token": "secret", "user_id": "user-1"}
|
||||
assert dumped["parent_run_id"] == "parent-456"
|
||||
|
||||
@@ -356,3 +356,173 @@ def test_convert_tools_to_agui_format_with_multiple_tools():
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "tool1"
|
||||
assert result[1]["name"] == "tool2"
|
||||
|
||||
|
||||
# Additional tests for utils coverage
|
||||
|
||||
|
||||
def test_safe_json_parse_with_dict():
|
||||
"""Test safe_json_parse with dict input."""
|
||||
from agent_framework_ag_ui._utils import safe_json_parse
|
||||
|
||||
input_dict = {"key": "value"}
|
||||
result = safe_json_parse(input_dict)
|
||||
assert result == input_dict
|
||||
|
||||
|
||||
def test_safe_json_parse_with_json_string():
|
||||
"""Test safe_json_parse with JSON string."""
|
||||
from agent_framework_ag_ui._utils import safe_json_parse
|
||||
|
||||
result = safe_json_parse('{"key": "value"}')
|
||||
assert result == {"key": "value"}
|
||||
|
||||
|
||||
def test_safe_json_parse_with_invalid_json():
|
||||
"""Test safe_json_parse with invalid JSON."""
|
||||
from agent_framework_ag_ui._utils import safe_json_parse
|
||||
|
||||
result = safe_json_parse("not json")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_safe_json_parse_with_non_dict_json():
|
||||
"""Test safe_json_parse with JSON that parses to non-dict."""
|
||||
from agent_framework_ag_ui._utils import safe_json_parse
|
||||
|
||||
result = safe_json_parse("[1, 2, 3]")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_safe_json_parse_with_none():
|
||||
"""Test safe_json_parse with None input."""
|
||||
from agent_framework_ag_ui._utils import safe_json_parse
|
||||
|
||||
result = safe_json_parse(None)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_role_value_with_enum():
|
||||
"""Test get_role_value with enum role."""
|
||||
from agent_framework import ChatMessage, Content, Role
|
||||
|
||||
from agent_framework_ag_ui._utils import get_role_value
|
||||
|
||||
message = ChatMessage(role=Role.USER, contents=[Content.from_text("test")])
|
||||
result = get_role_value(message)
|
||||
assert result == "user"
|
||||
|
||||
|
||||
def test_get_role_value_with_string():
|
||||
"""Test get_role_value with string role."""
|
||||
from agent_framework_ag_ui._utils import get_role_value
|
||||
|
||||
class MockMessage:
|
||||
role = "assistant"
|
||||
|
||||
result = get_role_value(MockMessage())
|
||||
assert result == "assistant"
|
||||
|
||||
|
||||
def test_get_role_value_with_none():
|
||||
"""Test get_role_value with no role."""
|
||||
from agent_framework_ag_ui._utils import get_role_value
|
||||
|
||||
class MockMessage:
|
||||
pass
|
||||
|
||||
result = get_role_value(MockMessage())
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_normalize_agui_role_developer():
|
||||
"""Test normalize_agui_role maps developer to system."""
|
||||
from agent_framework_ag_ui._utils import normalize_agui_role
|
||||
|
||||
assert normalize_agui_role("developer") == "system"
|
||||
|
||||
|
||||
def test_normalize_agui_role_valid():
|
||||
"""Test normalize_agui_role with valid roles."""
|
||||
from agent_framework_ag_ui._utils import normalize_agui_role
|
||||
|
||||
assert normalize_agui_role("user") == "user"
|
||||
assert normalize_agui_role("assistant") == "assistant"
|
||||
assert normalize_agui_role("system") == "system"
|
||||
assert normalize_agui_role("tool") == "tool"
|
||||
|
||||
|
||||
def test_normalize_agui_role_invalid():
|
||||
"""Test normalize_agui_role with invalid role defaults to user."""
|
||||
from agent_framework_ag_ui._utils import normalize_agui_role
|
||||
|
||||
assert normalize_agui_role("invalid") == "user"
|
||||
assert normalize_agui_role(123) == "user"
|
||||
|
||||
|
||||
def test_extract_state_from_tool_args():
|
||||
"""Test extract_state_from_tool_args."""
|
||||
from agent_framework_ag_ui._utils import extract_state_from_tool_args
|
||||
|
||||
# Specific key
|
||||
assert extract_state_from_tool_args({"key": "value"}, "key") == "value"
|
||||
|
||||
# Wildcard
|
||||
args = {"a": 1, "b": 2}
|
||||
assert extract_state_from_tool_args(args, "*") == args
|
||||
|
||||
# Missing key
|
||||
assert extract_state_from_tool_args({"other": "value"}, "key") is None
|
||||
|
||||
# None args
|
||||
assert extract_state_from_tool_args(None, "key") is None
|
||||
|
||||
|
||||
def test_convert_agui_tools_to_agent_framework():
|
||||
"""Test convert_agui_tools_to_agent_framework."""
|
||||
from agent_framework_ag_ui._utils import convert_agui_tools_to_agent_framework
|
||||
|
||||
agui_tools = [
|
||||
{
|
||||
"name": "test_tool",
|
||||
"description": "A test tool",
|
||||
"parameters": {"type": "object", "properties": {"arg": {"type": "string"}}},
|
||||
}
|
||||
]
|
||||
|
||||
result = convert_agui_tools_to_agent_framework(agui_tools)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "test_tool"
|
||||
assert result[0].description == "A test tool"
|
||||
assert result[0].declaration_only is True
|
||||
|
||||
|
||||
def test_convert_agui_tools_to_agent_framework_none():
|
||||
"""Test convert_agui_tools_to_agent_framework with None."""
|
||||
from agent_framework_ag_ui._utils import convert_agui_tools_to_agent_framework
|
||||
|
||||
result = convert_agui_tools_to_agent_framework(None)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_convert_agui_tools_to_agent_framework_empty():
|
||||
"""Test convert_agui_tools_to_agent_framework with empty list."""
|
||||
from agent_framework_ag_ui._utils import convert_agui_tools_to_agent_framework
|
||||
|
||||
result = convert_agui_tools_to_agent_framework([])
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_make_json_safe_unconvertible():
|
||||
"""Test make_json_safe with object that has no standard conversion."""
|
||||
|
||||
class NoConversion:
|
||||
__slots__ = () # No __dict__
|
||||
|
||||
from agent_framework_ag_ui._utils import make_json_safe
|
||||
|
||||
result = make_json_safe(NoConversion())
|
||||
# Falls back to str()
|
||||
assert isinstance(result, str)
|
||||
|
||||
@@ -20,9 +20,6 @@ from agent_framework import (
|
||||
)
|
||||
from agent_framework._clients import TOptions_co
|
||||
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages, _sanitize_tool_history
|
||||
from agent_framework_ag_ui._orchestrators import ExecutionContext
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
else:
|
||||
@@ -125,14 +122,3 @@ class StubAgent(AgentProtocol):
|
||||
|
||||
def get_new_thread(self, **kwargs: Any) -> AgentThread:
|
||||
return AgentThread()
|
||||
|
||||
|
||||
class TestExecutionContext(ExecutionContext):
|
||||
"""ExecutionContext helper that allows setting messages for tests."""
|
||||
|
||||
def set_messages(self, messages: list[ChatMessage], *, normalize: bool = True) -> None:
|
||||
if normalize:
|
||||
self._messages = _deduplicate_messages(_sanitize_tool_history(messages))
|
||||
else:
|
||||
self._messages = messages
|
||||
self._snapshot_messages = None
|
||||
|
||||
Reference in New Issue
Block a user