291 lines
9.2 KiB
Python
291 lines
9.2 KiB
Python
"""Tests for realtime handoff functionality."""
|
|
|
|
import asyncio
|
|
import inspect
|
|
from collections.abc import Awaitable, Coroutine
|
|
from typing import Any, cast
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
from pydantic import BaseModel
|
|
|
|
from agents import Agent
|
|
from agents.exceptions import ModelBehaviorError, UserError
|
|
from agents.realtime import RealtimeAgent, realtime_handoff
|
|
from agents.run_context import RunContextWrapper
|
|
|
|
|
|
def test_realtime_handoff_creation():
|
|
"""Test basic realtime handoff creation."""
|
|
realtime_agent = RealtimeAgent(name="test_agent")
|
|
handoff_obj = realtime_handoff(realtime_agent)
|
|
|
|
assert handoff_obj.agent_name == "test_agent"
|
|
assert handoff_obj.tool_name == "transfer_to_test_agent"
|
|
assert handoff_obj.input_filter is None # Should not support input filters
|
|
assert handoff_obj.is_enabled is True
|
|
|
|
|
|
def test_realtime_handoff_with_custom_params():
|
|
"""Test realtime handoff with custom parameters."""
|
|
realtime_agent = RealtimeAgent(
|
|
name="helper_agent",
|
|
handoff_description="Helps with general tasks",
|
|
)
|
|
|
|
handoff_obj = realtime_handoff(
|
|
realtime_agent,
|
|
tool_name_override="custom_handoff",
|
|
tool_description_override="Custom handoff description",
|
|
is_enabled=False,
|
|
)
|
|
|
|
assert handoff_obj.agent_name == "helper_agent"
|
|
assert handoff_obj.tool_name == "custom_handoff"
|
|
assert handoff_obj.tool_description == "Custom handoff description"
|
|
assert handoff_obj.is_enabled is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_realtime_handoff_execution():
|
|
"""Test that realtime handoff returns the correct agent."""
|
|
realtime_agent = RealtimeAgent(name="target_agent")
|
|
handoff_obj = realtime_handoff(realtime_agent)
|
|
|
|
# Mock context
|
|
mock_context = Mock()
|
|
|
|
# Execute handoff
|
|
result = await handoff_obj.on_invoke_handoff(mock_context, "")
|
|
|
|
assert result is realtime_agent
|
|
assert isinstance(result, RealtimeAgent)
|
|
|
|
|
|
def test_realtime_handoff_with_on_handoff_callback():
|
|
"""Test realtime handoff with custom on_handoff callback."""
|
|
realtime_agent = RealtimeAgent(name="callback_agent")
|
|
callback_called = []
|
|
|
|
def on_handoff_callback(ctx):
|
|
callback_called.append(True)
|
|
|
|
handoff_obj = realtime_handoff(
|
|
realtime_agent,
|
|
on_handoff=on_handoff_callback,
|
|
)
|
|
|
|
asyncio.run(
|
|
cast(
|
|
Coroutine[Any, Any, RealtimeAgent[Any]],
|
|
handoff_obj.on_invoke_handoff(RunContextWrapper(None), ""),
|
|
)
|
|
)
|
|
assert callback_called == [True]
|
|
assert handoff_obj.agent_name == "callback_agent"
|
|
|
|
|
|
def test_regular_agent_handoff_still_works():
|
|
"""Test that regular Agent handoffs still work with the new generic types."""
|
|
from agents import handoff
|
|
|
|
regular_agent = Agent(name="regular_agent")
|
|
handoff_obj = handoff(regular_agent)
|
|
|
|
assert handoff_obj.agent_name == "regular_agent"
|
|
assert handoff_obj.tool_name == "transfer_to_regular_agent"
|
|
# Regular agent handoffs should support input filters
|
|
assert hasattr(handoff_obj, "input_filter")
|
|
|
|
|
|
def test_type_annotations_work():
|
|
"""Test that type annotations work correctly."""
|
|
from agents.handoffs import Handoff
|
|
from agents.realtime.handoffs import realtime_handoff
|
|
|
|
realtime_agent = RealtimeAgent(name="typed_agent")
|
|
handoff_obj = realtime_handoff(realtime_agent)
|
|
|
|
# This should be typed as Handoff[Any, RealtimeAgent[Any]]
|
|
assert isinstance(handoff_obj, Handoff)
|
|
|
|
|
|
def test_realtime_handoff_invalid_param_counts_raise():
|
|
rt = RealtimeAgent(name="x")
|
|
|
|
# on_handoff with input_type but wrong param count
|
|
def bad2(a): # only one parameter
|
|
return None
|
|
|
|
assert bad2(None) is None
|
|
with pytest.raises(UserError):
|
|
realtime_handoff(rt, on_handoff=bad2, input_type=int) # type: ignore[arg-type]
|
|
|
|
# on_handoff without input but wrong param count
|
|
def bad1(a, b): # two parameters
|
|
return None
|
|
|
|
assert bad1(None, None) is None
|
|
with pytest.raises(UserError):
|
|
realtime_handoff(rt, on_handoff=bad1) # type: ignore[arg-type]
|
|
|
|
|
|
def test_realtime_handoff_input_type_requires_on_handoff():
|
|
"""input_type without on_handoff must raise UserError, not silently produce a broken handoff."""
|
|
rt = RealtimeAgent(name="x")
|
|
|
|
with pytest.raises(UserError):
|
|
realtime_handoff(rt, input_type=int) # type: ignore[call-overload]
|
|
|
|
|
|
def test_realtime_handoff_non_callable_on_handoff_raises_error():
|
|
"""Providing a non-callable on_handoff with input_type should raise UserError."""
|
|
rt = RealtimeAgent(name="x")
|
|
|
|
with pytest.raises(UserError, match="on_handoff must be callable"):
|
|
realtime_handoff(rt, on_handoff="not_a_function", input_type=int) # type: ignore[call-overload]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_realtime_handoff_missing_input_json_raises_model_error():
|
|
rt = RealtimeAgent(name="x")
|
|
|
|
async def with_input(ctx: RunContextWrapper[Any], data: int): # simple non-object type
|
|
return None
|
|
|
|
h = realtime_handoff(rt, on_handoff=with_input, input_type=int)
|
|
|
|
with pytest.raises(ModelBehaviorError):
|
|
await h.on_invoke_handoff(RunContextWrapper(None), "null")
|
|
|
|
await with_input(RunContextWrapper(None), 1)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_realtime_handoff_is_enabled_async(monkeypatch):
|
|
rt = RealtimeAgent(name="x")
|
|
|
|
async def is_enabled(ctx, agent):
|
|
return True
|
|
|
|
h = realtime_handoff(rt, is_enabled=is_enabled)
|
|
assert callable(h.is_enabled)
|
|
result = h.is_enabled(RunContextWrapper(None), rt)
|
|
assert isinstance(result, Awaitable)
|
|
assert await result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_realtime_handoff_rejects_none_input() -> None:
|
|
rt = RealtimeAgent(name="x")
|
|
|
|
async def with_input(ctx: RunContextWrapper[Any], data: int) -> None:
|
|
return None
|
|
|
|
handoff_obj = realtime_handoff(rt, on_handoff=with_input, input_type=int)
|
|
|
|
with pytest.raises(ModelBehaviorError):
|
|
await handoff_obj.on_invoke_handoff(RunContextWrapper(None), cast(str, None))
|
|
|
|
await with_input(RunContextWrapper(None), 2)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_realtime_handoff_sync_is_enabled_callable() -> None:
|
|
rt = RealtimeAgent(name="x")
|
|
calls: list[bool] = []
|
|
|
|
def is_enabled(ctx: RunContextWrapper[Any], agent: RealtimeAgent[Any]) -> bool:
|
|
calls.append(True)
|
|
assert agent is rt
|
|
return False
|
|
|
|
handoff_obj = realtime_handoff(rt, is_enabled=is_enabled)
|
|
assert callable(handoff_obj.is_enabled)
|
|
enabled_result = handoff_obj.is_enabled(RunContextWrapper(None), rt)
|
|
if inspect.isawaitable(enabled_result):
|
|
assert await enabled_result is False
|
|
else:
|
|
assert enabled_result is False
|
|
assert calls, "is_enabled callback should be invoked"
|
|
|
|
|
|
def test_realtime_handoff_sync_on_handoff_executes() -> None:
|
|
rt = RealtimeAgent(name="sync")
|
|
called: list[int] = []
|
|
|
|
def on_handoff(ctx: RunContextWrapper[Any], value: int) -> None:
|
|
called.append(value)
|
|
|
|
handoff_obj = realtime_handoff(rt, on_handoff=on_handoff, input_type=int)
|
|
result: RealtimeAgent[Any] = asyncio.run(
|
|
cast(
|
|
Coroutine[Any, Any, RealtimeAgent[Any]],
|
|
handoff_obj.on_invoke_handoff(RunContextWrapper(None), "5"),
|
|
)
|
|
)
|
|
|
|
assert result is rt
|
|
assert called == [5]
|
|
|
|
|
|
def test_realtime_handoff_on_handoff_without_input_runs() -> None:
|
|
rt = RealtimeAgent(name="no_input")
|
|
called: list[bool] = []
|
|
|
|
def on_handoff(ctx: RunContextWrapper[Any]) -> None:
|
|
called.append(True)
|
|
|
|
handoff_obj = realtime_handoff(rt, on_handoff=on_handoff)
|
|
result: RealtimeAgent[Any] = asyncio.run(
|
|
cast(
|
|
Coroutine[Any, Any, RealtimeAgent[Any]],
|
|
handoff_obj.on_invoke_handoff(RunContextWrapper(None), ""),
|
|
)
|
|
)
|
|
|
|
assert result is rt
|
|
assert called == [True]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_realtime_handoff_async_on_handoff_without_input_runs() -> None:
|
|
rt = RealtimeAgent(name="async_no_input")
|
|
called: list[bool] = []
|
|
|
|
async def on_handoff(ctx: RunContextWrapper[Any]) -> None:
|
|
called.append(True)
|
|
|
|
handoff_obj = realtime_handoff(rt, on_handoff=on_handoff)
|
|
result = await handoff_obj.on_invoke_handoff(RunContextWrapper(None), "")
|
|
|
|
assert result is rt
|
|
assert called == [True]
|
|
|
|
|
|
class StrictInput(BaseModel):
|
|
name: str
|
|
age: int
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_realtime_handoff_strict_json_rejects_type_coercion():
|
|
"""With strict_json_schema=True (always on for realtime handoffs), string input for an
|
|
int field must raise ModelBehaviorError instead of being silently coerced."""
|
|
rt = RealtimeAgent(name="strict_test")
|
|
|
|
async def _on_handoff(ctx: RunContextWrapper[Any], data: StrictInput) -> None:
|
|
pass # pragma: no cover
|
|
|
|
handoff_obj = realtime_handoff(rt, on_handoff=_on_handoff, input_type=StrictInput)
|
|
|
|
# age is a string "25" — strict mode should reject this
|
|
malformed_json = '{"name": "Alice", "age": "25"}'
|
|
with pytest.raises(ModelBehaviorError, match="Invalid JSON"):
|
|
await handoff_obj.on_invoke_handoff(RunContextWrapper(None), malformed_json)
|
|
|
|
# Correctly typed input should still be accepted
|
|
valid_json = '{"name": "Alice", "age": 25}'
|
|
result = await handoff_obj.on_invoke_handoff(RunContextWrapper(None), valid_json)
|
|
assert result is rt
|