Files
openai--openai-agents-python/tests/test_handoff_history_duplication.py
2026-07-13 12:39:17 +08:00

527 lines
19 KiB
Python

"""Tests for handoff history duplication fix (Issue #2171).
These tests verify that when nest_handoff_history is enabled,
function_call and function_call_output items are NOT duplicated
in the input sent to the next agent.
"""
import json
from typing import Any, cast
import pytest
from openai.types.responses import (
ResponseFunctionToolCall,
ResponseOutputMessage,
ResponseOutputText,
)
from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary
from agents import Agent, RunConfig, Runner, function_tool, handoff
from agents.handoffs import HandoffInputData, nest_handoff_history
from agents.items import (
HandoffCallItem,
HandoffOutputItem,
MessageOutputItem,
ReasoningItem,
ToolApprovalItem,
ToolCallItem,
ToolCallOutputItem,
)
from .fake_model import FakeModel
from .test_responses import get_function_tool_call, get_handoff_tool_call, get_text_message
def _create_mock_agent() -> Agent:
"""Create a mock agent for testing."""
return Agent(name="test_agent")
def _create_tool_call_item(agent: Agent) -> ToolCallItem:
"""Create a mock ToolCallItem."""
raw_item = ResponseFunctionToolCall(
id="call_tool_123",
call_id="call_tool_123",
name="get_weather",
arguments='{"city": "London"}',
type="function_call",
)
return ToolCallItem(agent=agent, raw_item=raw_item, type="tool_call_item")
def _create_tool_output_item(agent: Agent) -> ToolCallOutputItem:
"""Create a mock ToolCallOutputItem."""
raw_item = {
"type": "function_call_output",
"call_id": "call_tool_123",
"output": "Sunny, 22°C",
}
return ToolCallOutputItem(
agent=agent,
raw_item=raw_item,
output="Sunny, 22°C",
type="tool_call_output_item",
)
def _create_handoff_call_item(agent: Agent) -> HandoffCallItem:
"""Create a mock HandoffCallItem."""
raw_item = ResponseFunctionToolCall(
id="call_handoff_456",
call_id="call_handoff_456",
name="transfer_to_agent_b",
arguments="{}",
type="function_call",
)
return HandoffCallItem(agent=agent, raw_item=raw_item, type="handoff_call_item")
def _create_handoff_output_item(agent: Agent[Any]) -> HandoffOutputItem:
"""Create a mock HandoffOutputItem."""
raw_item: dict[str, str] = {
"type": "function_call_output",
"call_id": "call_handoff_456",
"output": '{"assistant": "agent_b"}',
}
return HandoffOutputItem(
agent=agent,
raw_item=cast(Any, raw_item),
source_agent=agent,
target_agent=agent,
type="handoff_output_item",
)
def _create_message_item(agent: Agent) -> MessageOutputItem:
"""Create a mock MessageOutputItem."""
raw_item = ResponseOutputMessage(
id="msg_123",
content=[ResponseOutputText(text="Hello!", type="output_text", annotations=[])],
role="assistant",
status="completed",
type="message",
)
return MessageOutputItem(agent=agent, raw_item=raw_item, type="message_output_item")
def _create_reasoning_item(agent: Agent) -> ReasoningItem:
"""Create a mock ReasoningItem."""
raw_item = ResponseReasoningItem(
id="reasoning_123",
type="reasoning",
summary=[Summary(text="Thinking about handoff", type="summary_text")],
)
return ReasoningItem(agent=agent, raw_item=raw_item, type="reasoning_item")
def _create_tool_approval_item(agent: Agent) -> ToolApprovalItem:
"""Create a mock ToolApprovalItem."""
raw_item = {
"type": "function_call",
"call_id": "call_tool_approve",
"name": "needs_approval",
"arguments": "{}",
}
return ToolApprovalItem(agent=agent, raw_item=raw_item)
class TestHandoffHistoryDuplicationFix:
"""Tests for Issue #2171: nest_handoff_history duplication fix."""
def test_pre_handoff_tool_items_are_filtered(self):
"""Verify ToolCallItem and ToolCallOutputItem in pre_handoff_items are filtered.
These items should NOT appear in the filtered output because they are
already included in the summary message.
"""
agent = _create_mock_agent()
handoff_data = HandoffInputData(
input_history=({"role": "user", "content": "Hello"},),
pre_handoff_items=(
_create_tool_call_item(agent),
_create_tool_output_item(agent),
),
new_items=(),
)
nested = nest_handoff_history(handoff_data)
# pre_handoff_items should be empty (tool items filtered)
assert len(nested.pre_handoff_items) == 0, (
"ToolCallItem and ToolCallOutputItem should be filtered from pre_handoff_items"
)
# Summary should contain the conversation
assert len(nested.input_history) == 1
first_item = nested.input_history[0]
assert isinstance(first_item, dict)
assert "<CONVERSATION HISTORY>" in str(first_item.get("content", ""))
def test_tool_approval_items_are_skipped(self):
"""Verify ToolApprovalItem does not break handoff history mapping."""
agent = _create_mock_agent()
handoff_data = HandoffInputData(
input_history=({"role": "user", "content": "Hello"},),
pre_handoff_items=(_create_tool_approval_item(agent),),
new_items=(),
)
nested = nest_handoff_history(handoff_data)
assert isinstance(nested.input_history, tuple)
assert len(nested.pre_handoff_items) == 0
assert nested.input_items == ()
def test_pre_handoff_reasoning_items_are_filtered(self):
"""Verify ReasoningItem in pre_handoff_items is filtered.
Reasoning is represented in the summary transcript and should not be
forwarded as a raw item.
"""
agent = _create_mock_agent()
handoff_data = HandoffInputData(
input_history=({"role": "user", "content": "Hello"},),
pre_handoff_items=(_create_reasoning_item(agent),),
new_items=(),
)
nested = nest_handoff_history(handoff_data)
assert len(nested.pre_handoff_items) == 0
first_item = nested.input_history[0]
assert isinstance(first_item, dict)
summary = str(first_item.get("content", ""))
assert "reasoning" in summary
def test_new_items_handoff_output_is_filtered_for_input(self):
"""Verify HandoffOutputItem in new_items is filtered from input_items.
The HandoffOutputItem is a function_call_output which would be duplicated.
It should be filtered from input_items but preserved in new_items.
"""
agent = _create_mock_agent()
handoff_data = HandoffInputData(
input_history=({"role": "user", "content": "Hello"},),
pre_handoff_items=(),
new_items=(
_create_handoff_call_item(agent),
_create_handoff_output_item(agent),
),
)
nested = nest_handoff_history(handoff_data)
# new_items should still have both items (for session history)
assert len(nested.new_items) == 2, "new_items should preserve all items for session history"
# input_items should be populated and filtered
assert nested.input_items is not None, "input_items should be populated"
# input_items should NOT contain HandoffOutputItem (it's function_call_output)
has_handoff_output = any(isinstance(item, HandoffOutputItem) for item in nested.input_items)
assert not has_handoff_output, "HandoffOutputItem should be filtered from input_items"
def test_message_items_are_preserved_in_new_items(self):
"""Verify MessageOutputItem in new_items is preserved.
Message items have a 'role' and should NOT be filtered from input_items.
Note: pre_handoff_items are converted to summary text regardless of type.
"""
agent = _create_mock_agent()
handoff_data = HandoffInputData(
input_history=({"role": "user", "content": "Hello"},),
pre_handoff_items=(), # pre_handoff items go into summary
new_items=(_create_message_item(agent),),
)
nested = nest_handoff_history(handoff_data)
# Message items should be preserved in new_items
assert len(nested.new_items) == 1, "MessageOutputItem should be preserved in new_items"
# And in input_items (since it has a role)
assert nested.input_items is not None
assert len(nested.input_items) == 1, "MessageOutputItem should be preserved in input_items"
assert isinstance(nested.input_items[0], MessageOutputItem)
def test_reasoning_items_are_filtered_from_input_items(self):
"""Verify ReasoningItem in new_items is filtered from input_items.
Reasoning is summarized in the conversation transcript and should not be
forwarded verbatim in nested handoff model input.
"""
agent = _create_mock_agent()
handoff_data = HandoffInputData(
input_history=({"role": "user", "content": "Hello"},),
pre_handoff_items=(),
new_items=(
_create_reasoning_item(agent),
_create_handoff_call_item(agent),
_create_handoff_output_item(agent),
),
)
nested = nest_handoff_history(handoff_data)
assert nested.input_items is not None
has_reasoning = any(isinstance(item, ReasoningItem) for item in nested.input_items)
assert not has_reasoning, "ReasoningItem should be filtered from input_items"
first_item = nested.input_history[0]
assert isinstance(first_item, dict)
summary = str(first_item.get("content", ""))
assert "reasoning" in summary
def test_summary_contains_filtered_items_as_text(self):
"""Verify the summary message contains the filtered tool items as text.
This ensures observability - the items are not lost, just converted to text.
"""
agent = _create_mock_agent()
handoff_data = HandoffInputData(
input_history=({"role": "user", "content": "Hello"},),
pre_handoff_items=(
_create_tool_call_item(agent),
_create_tool_output_item(agent),
),
new_items=(),
)
nested = nest_handoff_history(handoff_data)
first_item = nested.input_history[0]
assert isinstance(first_item, dict)
summary = str(first_item.get("content", ""))
# Summary should contain function_call reference
assert "function_call" in summary or "get_weather" in summary, (
"Summary should contain the tool call that was filtered"
)
def test_input_items_field_exists_after_nesting(self):
"""Verify the input_items field is populated after nest_handoff_history.
This is the key field that separates model input from session history.
"""
agent = _create_mock_agent()
handoff_data = HandoffInputData(
input_history=({"role": "user", "content": "Hello"},),
pre_handoff_items=(),
new_items=(_create_handoff_call_item(agent),),
)
nested = nest_handoff_history(handoff_data)
assert nested.input_items is not None, (
"input_items should be populated after nest_handoff_history"
)
def test_full_handoff_scenario_no_duplication(self):
"""Full end-to-end test of the handoff scenario from Issue #2171.
Simulates: User -> Agent does tool call -> Agent hands off to next agent
Verifies: Next agent receives summary only, no duplicate raw items.
"""
agent = _create_mock_agent()
# Full scenario: tool call in pre_handoff, handoff in new_items
handoff_data = HandoffInputData(
input_history=({"role": "user", "content": "What's the weather?"},),
pre_handoff_items=(
_create_tool_call_item(agent), # function_call
_create_tool_output_item(agent), # function_call_output
),
new_items=(
_create_message_item(agent), # assistant message
_create_handoff_call_item(agent), # function_call (handoff)
_create_handoff_output_item(agent), # function_call_output (handoff)
),
)
nested = nest_handoff_history(handoff_data)
# Count what would be sent to the model
total_model_items = (
len(nested.input_history) # Summary
+ len(nested.pre_handoff_items) # Filtered pre-handoff
+ len(nested.input_items or []) # Filtered new items
)
# Before fix: would have 6+ items (summary + raw tool items)
# After fix: should have ~2 items (summary + message)
assert total_model_items <= 3, (
f"Model should receive at most 3 items (summary + messages), got {total_model_items}"
)
# Verify no raw function_call_output items in model input
all_input_items = list(nested.pre_handoff_items) + list(nested.input_items or [])
function_call_outputs = [
item
for item in all_input_items
if isinstance(item, ToolCallOutputItem | HandoffOutputItem)
]
assert len(function_call_outputs) == 0, (
"No function_call_output items should be in model input"
)
@pytest.mark.asyncio
async def test_to_input_list_normalized_uses_filtered_continuation_after_nested_handoff() -> None:
triage_model = FakeModel()
delegate_model = FakeModel()
delegate = Agent(name="delegate", model=delegate_model)
triage = Agent(name="triage", model=triage_model, handoffs=[delegate])
triage_model.add_multiple_turn_outputs(
[[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
)
delegate_model.add_multiple_turn_outputs(
[
[get_text_message("resolution")],
[get_text_message("followup answer")],
]
)
result = await Runner.run(
triage,
input="user_question",
run_config=RunConfig(nest_handoff_history=True),
)
preserve_all_input = result.to_input_list()
normalized_input = result.to_input_list(mode="normalized")
preserve_all_types = [
item.get("type", "message") for item in preserve_all_input if isinstance(item, dict)
]
normalized_types = [
item.get("type", "message") for item in normalized_input if isinstance(item, dict)
]
assert len(preserve_all_input) == 5
assert "function_call" in preserve_all_types
assert "function_call_output" in preserve_all_types
assert len(normalized_input) == 3
assert "function_call" not in normalized_types
assert "function_call_output" not in normalized_types
follow_up_input = normalized_input + [{"role": "user", "content": "follow up?"}]
follow_up_result = await Runner.run(delegate, input=follow_up_input)
assert follow_up_result.final_output == "followup answer"
assert delegate_model.last_turn_args["input"] == follow_up_input
@pytest.mark.asyncio
async def test_to_input_list_normalized_keeps_delegate_tool_items_after_nested_handoff() -> None:
async def lookup_weather(city: str) -> str:
return f"weather:{city}"
triage_model = FakeModel()
delegate_model = FakeModel()
delegate = Agent(
name="delegate",
model=delegate_model,
tools=[function_tool(lookup_weather, name_override="lookup_weather")],
)
triage = Agent(name="triage", model=triage_model, handoffs=[delegate])
triage_model.add_multiple_turn_outputs(
[[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
)
delegate_model.add_multiple_turn_outputs(
[
[
get_text_message("delegate preamble"),
get_function_tool_call("lookup_weather", json.dumps({"city": "Tokyo"})),
],
[get_text_message("resolution")],
]
)
result = await Runner.run(
triage,
input="user_question",
run_config=RunConfig(nest_handoff_history=True),
)
preserve_all_input = result.to_input_list()
normalized_input = result.to_input_list(mode="normalized")
preserve_all_function_calls = [
cast(dict[str, Any], item)
for item in preserve_all_input
if isinstance(item, dict) and item.get("type") == "function_call"
]
preserve_all_function_outputs = [
cast(dict[str, Any], item)
for item in preserve_all_input
if isinstance(item, dict) and item.get("type") == "function_call_output"
]
function_calls = [
cast(dict[str, Any], item)
for item in normalized_input
if isinstance(item, dict) and item.get("type") == "function_call"
]
function_outputs = [
cast(dict[str, Any], item)
for item in normalized_input
if isinstance(item, dict) and item.get("type") == "function_call_output"
]
assert len(preserve_all_function_calls) == 2
assert len(preserve_all_function_outputs) == 2
assert len(function_calls) == 1
assert function_calls[0]["name"] == "lookup_weather"
assert len(function_outputs) == 1
assert function_outputs[0]["output"] == "weather:Tokyo"
@pytest.mark.asyncio
async def test_to_input_list_normalized_uses_custom_filter_input_items() -> None:
def keep_messages_only(data: HandoffInputData) -> HandoffInputData:
return data.clone(
input_items=tuple(
item for item in data.new_items if isinstance(item, MessageOutputItem)
)
)
triage_model = FakeModel()
delegate_model = FakeModel()
delegate = Agent(name="delegate", model=delegate_model)
triage = Agent(
name="triage",
model=triage_model,
handoffs=[handoff(delegate, input_filter=keep_messages_only)],
)
triage_model.add_multiple_turn_outputs(
[[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
)
delegate_model.add_multiple_turn_outputs([[get_text_message("resolution")]])
result = await Runner.run(triage, input="user_question")
preserve_all_input = result.to_input_list()
normalized_input = result.to_input_list(mode="normalized")
preserve_all_types = [
item.get("type", "message") for item in preserve_all_input if isinstance(item, dict)
]
normalized_types = [
item.get("type", "message") for item in normalized_input if isinstance(item, dict)
]
assert len(preserve_all_input) == 5
assert "function_call" in preserve_all_types
assert "function_call_output" in preserve_all_types
assert len(normalized_input) == 3
assert "function_call" not in normalized_types
assert "function_call_output" not in normalized_types