Files
2026-07-13 12:39:17 +08:00

679 lines
22 KiB
Python

from __future__ import annotations
from typing import Any, cast
import pytest
from openai.types.responses import (
ResponseFunctionToolCall,
ResponseToolSearchCall,
ResponseToolSearchOutputItem,
)
from openai.types.responses.response_reasoning_item import ResponseReasoningItem
from agents import Agent
from agents.exceptions import AgentsException
from agents.items import (
ReasoningItem,
ToolCallItem,
ToolSearchCallItem,
ToolSearchOutputItem,
TResponseInputItem,
coerce_tool_search_output_raw_item,
)
from agents.models.fake_id import FAKE_RESPONSES_ID
from agents.result import RunResult
from agents.run_context import RunContextWrapper
from agents.run_internal import items as run_items
def test_drop_orphan_function_calls_preserves_non_mapping_entries() -> None:
payload: list[Any] = [
cast(TResponseInputItem, "plain-text-input"),
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "hello"}),
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "orphan_call",
"name": "orphan",
"arguments": "{}",
},
),
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "paired_call",
"name": "paired",
"arguments": "{}",
},
),
cast(
TResponseInputItem,
{"type": "function_call_output", "call_id": "paired_call", "output": "ok"},
),
cast(TResponseInputItem, {"call_id": "not-a-tool-call"}),
]
filtered = run_items.drop_orphan_function_calls(cast(list[TResponseInputItem], payload))
filtered_values = cast(list[Any], filtered)
assert "plain-text-input" in filtered_values
assert cast(dict[str, Any], filtered[1])["type"] == "message"
assert any(
isinstance(entry, dict)
and entry.get("type") == "function_call"
and entry.get("call_id") == "paired_call"
for entry in filtered
)
assert not any(
isinstance(entry, dict)
and entry.get("type") == "function_call"
and entry.get("call_id") == "orphan_call"
for entry in filtered
)
def test_drop_orphan_function_calls_drops_reasoning_preceding_dropped_tool_call() -> None:
# Regression: reasoning items tied to a now-dropped orphan tool call would otherwise be
# forwarded to the API and trigger
# ``Item 'rs_...' of type 'reasoning' was provided without its required following item``.
payload: list[Any] = [
cast(TResponseInputItem, {"role": "user", "content": "hi"}),
cast(TResponseInputItem, {"type": "reasoning", "id": "rs_orphan_a", "summary": []}),
cast(TResponseInputItem, {"type": "reasoning", "id": "rs_orphan_b", "summary": []}),
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "orphan_call",
"name": "orphan",
"arguments": "{}",
},
),
cast(TResponseInputItem, {"type": "reasoning", "id": "rs_paired", "summary": []}),
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "paired_call",
"name": "paired",
"arguments": "{}",
},
),
cast(
TResponseInputItem,
{"type": "function_call_output", "call_id": "paired_call", "output": "ok"},
),
]
filtered = run_items.drop_orphan_function_calls(cast(list[TResponseInputItem], payload))
reasoning_ids = [
entry.get("id")
for entry in filtered
if isinstance(entry, dict) and entry.get("type") == "reasoning"
]
assert reasoning_ids == ["rs_paired"]
assert not any(
isinstance(entry, dict)
and entry.get("type") == "function_call"
and entry.get("call_id") == "orphan_call"
for entry in filtered
)
def test_drop_orphan_function_calls_keeps_lone_reasoning_when_no_tool_calls_dropped() -> None:
# Server-managed conversations (or compaction) may forward standalone reasoning items whose
# required following item lives in the server-side conversation. We must not drop those.
payload: list[Any] = [
cast(TResponseInputItem, {"type": "reasoning", "id": "rs_lone", "summary": []}),
]
filtered = run_items.drop_orphan_function_calls(cast(list[TResponseInputItem], payload))
assert filtered == payload
def test_drop_orphan_function_calls_handles_tool_search_calls() -> None:
payload: list[Any] = [
cast(
TResponseInputItem,
{
"type": "tool_search_call",
"call_id": "tool_search_orphan",
"arguments": {"query": "orphan"},
"execution": "server",
"status": "completed",
},
),
cast(
TResponseInputItem,
{
"type": "tool_search_call",
"call_id": "tool_search_keep",
"arguments": {"query": "keep"},
"execution": "server",
"status": "completed",
},
),
cast(
TResponseInputItem,
{
"type": "tool_search_output",
"call_id": "tool_search_keep",
"execution": "server",
"status": "completed",
"tools": [],
},
),
]
filtered = run_items.drop_orphan_function_calls(cast(list[TResponseInputItem], payload))
assert any(
isinstance(entry, dict)
and entry.get("type") == "tool_search_call"
and entry.get("call_id") == "tool_search_keep"
for entry in filtered
)
assert not any(
isinstance(entry, dict)
and entry.get("type") == "tool_search_call"
and entry.get("call_id") == "tool_search_orphan"
for entry in filtered
)
def test_drop_orphan_function_calls_preserves_hosted_tool_search_pairs_without_call_ids() -> None:
payload: list[Any] = [
cast(
TResponseInputItem,
{
"type": "tool_search_call",
"call_id": None,
"arguments": {"query": "keep"},
"execution": "server",
"status": "completed",
},
),
cast(
TResponseInputItem,
{
"type": "tool_search_output",
"call_id": None,
"execution": "server",
"status": "completed",
"tools": [],
},
),
]
filtered = run_items.drop_orphan_function_calls(cast(list[TResponseInputItem], payload))
assert len(filtered) == 2
assert cast(dict[str, Any], filtered[0])["type"] == "tool_search_call"
assert cast(dict[str, Any], filtered[1])["type"] == "tool_search_output"
def test_drop_orphan_function_calls_matches_latest_anonymous_tool_search_call() -> None:
payload: list[Any] = [
cast(
TResponseInputItem,
{
"type": "tool_search_call",
"call_id": None,
"arguments": {"query": "orphan"},
"execution": "server",
"status": "completed",
},
),
cast(
TResponseInputItem,
{
"type": "tool_search_call",
"call_id": None,
"arguments": {"query": "paired"},
"execution": "server",
"status": "completed",
},
),
cast(
TResponseInputItem,
{
"type": "tool_search_output",
"call_id": None,
"execution": "server",
"status": "completed",
"tools": [],
},
),
]
filtered = run_items.drop_orphan_function_calls(cast(list[TResponseInputItem], payload))
assert [cast(dict[str, Any], item)["type"] for item in filtered] == [
"tool_search_call",
"tool_search_output",
]
assert cast(dict[str, Any], filtered[0])["arguments"] == {"query": "paired"}
def test_drop_orphan_function_calls_does_not_pair_named_tool_search_with_anonymous_output() -> None:
payload: list[Any] = [
cast(
TResponseInputItem,
{
"type": "tool_search_call",
"call_id": "orphan_search",
"arguments": {"query": "keep"},
"execution": "server",
"status": "completed",
},
),
cast(
TResponseInputItem,
{
"type": "tool_search_output",
"call_id": None,
"execution": "server",
"status": "completed",
"tools": [],
},
),
]
filtered = run_items.drop_orphan_function_calls(cast(list[TResponseInputItem], payload))
assert [cast(dict[str, Any], item)["type"] for item in filtered] == ["tool_search_output"]
def test_drop_orphan_function_calls_keeps_reasoning_chain_before_non_dropped_item() -> None:
payload: list[Any] = [
cast(TResponseInputItem, {"type": "reasoning", "id": "rs_1", "summary": []}),
cast(TResponseInputItem, {"type": "reasoning", "id": "rs_2", "summary": []}),
cast(TResponseInputItem, {"type": "message", "role": "assistant", "content": []}),
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "orphan_call",
"name": "orphan",
"arguments": "{}",
},
),
]
filtered = run_items.drop_orphan_function_calls(cast(list[TResponseInputItem], payload))
assert [cast(dict[str, Any], item)["id"] for item in filtered[:2]] == ["rs_1", "rs_2"]
assert [cast(dict[str, Any], item)["type"] for item in filtered] == [
"reasoning",
"reasoning",
"message",
]
def test_normalize_and_ensure_input_item_format_keep_non_dict_entries() -> None:
item = cast(TResponseInputItem, "raw-item")
assert run_items.ensure_input_item_format(item) == item
assert run_items.normalize_input_items_for_api([item]) == [item]
def test_fingerprint_input_item_handles_edge_cases(monkeypatch: pytest.MonkeyPatch) -> None:
assert run_items.fingerprint_input_item(None) is None
fingerprint = run_items.fingerprint_input_item(
cast(
TResponseInputItem, {"id": "id-1", "type": "message", "role": "user", "content": "hi"}
),
ignore_ids_for_matching=True,
)
assert fingerprint is not None
assert '"id"' not in fingerprint
class _BrokenModelDump:
def model_dump(self, *_args: Any, **kwargs: Any) -> dict[str, Any]:
if "warnings" in kwargs:
raise TypeError("warnings arg unsupported")
raise RuntimeError("still broken")
assert run_items.fingerprint_input_item(_BrokenModelDump()) is None
assert run_items._model_dump_without_warnings(object()) is None
class _Opaque:
pass
monkeypatch.setattr(
run_items,
"ensure_input_item_format",
lambda _item: {"id": "internal-id", "type": "message", "role": "user", "content": "x"},
)
opaque_fingerprint = run_items.fingerprint_input_item(_Opaque(), ignore_ids_for_matching=True)
assert opaque_fingerprint is not None
assert '"id"' not in opaque_fingerprint
def test_fingerprint_input_item_returns_none_when_serialization_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def _raise_json_error(*_args: Any, **_kwargs: Any) -> str:
raise RuntimeError("boom")
monkeypatch.setattr(cast(Any, run_items).json, "dumps", _raise_json_error)
assert run_items.fingerprint_input_item({"type": "message", "role": "user"}) is None
def test_strip_metadata_and_reasoning_id_helpers_keep_non_matching_items() -> None:
raw = cast(TResponseInputItem, "raw-item")
non_reasoning = cast(TResponseInputItem, {"type": "message", "id": "msg_1"})
reasoning_without_id = cast(TResponseInputItem, {"type": "reasoning", "summary": []})
assert run_items.strip_internal_input_item_metadata(raw) == raw
assert run_items._without_reasoning_item_id(raw) == raw
assert run_items._without_reasoning_item_id(non_reasoning) == non_reasoning
assert run_items._without_reasoning_item_id(reasoning_without_id) == reasoning_without_id
def test_deduplicate_input_items_handles_fake_ids_and_approval_request_ids() -> None:
items: list[Any] = [
cast(
TResponseInputItem,
{
"type": "function_call_output",
"id": FAKE_RESPONSES_ID,
"call_id": "call-1",
"output": "first",
},
),
cast(
TResponseInputItem,
{
"type": "function_call_output",
"id": FAKE_RESPONSES_ID,
"call_id": "call-1",
"output": "latest",
},
),
cast(
TResponseInputItem,
{
"type": "mcp_approval_response",
"approval_request_id": "req-1",
"approve": True,
},
),
cast(
TResponseInputItem,
{
"type": "mcp_approval_response",
"approval_request_id": "req-1",
"approve": False,
},
),
cast(TResponseInputItem, "plain"),
]
deduplicated = run_items.deduplicate_input_items(cast(list[TResponseInputItem], items))
assert len(deduplicated) == 3
assert cast(list[Any], deduplicated)[-1] == "plain"
latest = run_items.deduplicate_input_items_preferring_latest(
cast(list[TResponseInputItem], items[:2])
)
assert len(latest) == 1
latest_output = cast(dict[str, Any], latest[0])
assert latest_output["output"] == "latest"
def test_extract_mcp_request_id_supports_dicts_and_objects() -> None:
assert (
run_items.extract_mcp_request_id(
{"provider_data": {"id": "provider-id"}, "id": "fallback-id"}
)
== "provider-id"
)
assert run_items.extract_mcp_request_id({"call_id": "call-id"}) == "call-id"
class _WithProviderData:
provider_data = {"id": "from-provider"}
assert run_items.extract_mcp_request_id(_WithProviderData()) == "from-provider"
class _BrokenObject:
@property
def provider_data(self) -> dict[str, Any]:
raise RuntimeError("boom")
def __getattr__(self, _name: str) -> Any:
raise RuntimeError("boom")
assert run_items.extract_mcp_request_id(_BrokenObject()) is None
def test_extract_mcp_request_id_from_run_variants() -> None:
class _Run:
def __init__(self, request_item: Any = None, requestItem: Any = None) -> None:
self.request_item = request_item
self.requestItem = requestItem
class _RequestObject:
provider_data = {"id": "provider-object"}
id = "object-id"
call_id = "object-call-id"
assert (
run_items.extract_mcp_request_id_from_run(
_Run(request_item={"provider_data": {"id": "provider-dict"}, "id": "fallback"})
)
== "provider-dict"
)
assert (
run_items.extract_mcp_request_id_from_run(_Run(request_item={"id": "dict-id"})) == "dict-id"
)
assert (
run_items.extract_mcp_request_id_from_run(_Run(request_item=_RequestObject()))
== "provider-object"
)
assert (
run_items.extract_mcp_request_id_from_run(_Run(requestItem={"call_id": "camel-call"}))
== "camel-call"
)
def test_run_item_to_input_item_preserves_reasoning_item_ids_by_default() -> None:
agent = Agent(name="A")
reasoning = ReasoningItem(
agent=agent,
raw_item=ResponseReasoningItem(
type="reasoning",
id="rs_123",
summary=[],
),
)
result = run_items.run_item_to_input_item(reasoning)
assert isinstance(result, dict)
assert result.get("type") == "reasoning"
assert result.get("id") == "rs_123"
def test_run_item_to_input_item_omits_reasoning_item_ids_when_configured() -> None:
agent = Agent(name="A")
reasoning = ReasoningItem(
agent=agent,
raw_item=ResponseReasoningItem(
type="reasoning",
id="rs_456",
summary=[],
),
)
result = run_items.run_item_to_input_item(reasoning, "omit")
assert isinstance(result, dict)
assert result.get("type") == "reasoning"
assert "id" not in result
def test_run_item_to_input_item_preserves_tool_search_items() -> None:
agent = Agent(name="A")
tool_search_call = ToolSearchCallItem(
agent=agent,
raw_item={"type": "tool_search_call", "queries": [{"search_term": "profile"}]},
)
tool_search_output = ToolSearchOutputItem(
agent=agent,
raw_item={"type": "tool_search_output", "results": [{"text": "Customer profile"}]},
)
converted_call = run_items.run_item_to_input_item(tool_search_call)
converted_output = run_items.run_item_to_input_item(tool_search_output)
assert isinstance(converted_call, dict)
assert converted_call["type"] == "tool_search_call"
assert isinstance(converted_output, dict)
assert converted_output["type"] == "tool_search_output"
def test_run_item_to_input_item_strips_tool_search_created_by() -> None:
agent = Agent(name="A")
tool_search_call = ToolSearchCallItem(
agent=agent,
raw_item=ResponseToolSearchCall(
id="tsc_123",
type="tool_search_call",
arguments={"query": "profile"},
execution="client",
status="completed",
created_by="server",
),
)
tool_search_output = ToolSearchOutputItem(
agent=agent,
raw_item=ResponseToolSearchOutputItem(
id="tso_123",
type="tool_search_output",
execution="client",
status="completed",
tools=[],
created_by="server",
),
)
converted_call = run_items.run_item_to_input_item(tool_search_call)
converted_output = run_items.run_item_to_input_item(tool_search_output)
assert isinstance(converted_call, dict)
assert converted_call["type"] == "tool_search_call"
assert "created_by" not in converted_call
assert isinstance(converted_output, dict)
assert converted_output["type"] == "tool_search_output"
assert "created_by" not in converted_output
def test_run_item_to_input_item_omits_tool_call_metadata() -> None:
agent = Agent(name="A")
tool_call = ToolCallItem(
agent=agent,
raw_item=ResponseFunctionToolCall(
id="fc_123",
call_id="call_123",
name="lookup_account",
arguments="{}",
type="function_call",
status="completed",
),
description="Lookup customer records.",
title="Lookup Account",
)
result = run_items.run_item_to_input_item(tool_call)
result_dict = cast(dict[str, Any], result)
assert isinstance(result, dict)
assert result_dict["type"] == "function_call"
assert "description" not in result_dict
assert "title" not in result_dict
def test_normalize_input_items_for_api_strips_internal_tool_call_metadata() -> None:
item = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call_123",
"name": "lookup_account",
"arguments": "{}",
run_items.TOOL_CALL_SESSION_DESCRIPTION_KEY: "Lookup customer records.",
run_items.TOOL_CALL_SESSION_TITLE_KEY: "Lookup Account",
},
)
normalized = run_items.normalize_input_items_for_api([item])
normalized_item = cast(dict[str, Any], normalized[0])
assert run_items.TOOL_CALL_SESSION_DESCRIPTION_KEY not in normalized_item
assert run_items.TOOL_CALL_SESSION_TITLE_KEY not in normalized_item
def test_fingerprint_input_item_ignores_internal_tool_call_metadata() -> None:
base_item = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call_123",
"name": "lookup_account",
"arguments": "{}",
},
)
with_metadata = cast(
TResponseInputItem,
{
**cast(dict[str, Any], base_item),
run_items.TOOL_CALL_SESSION_DESCRIPTION_KEY: "Lookup customer records.",
run_items.TOOL_CALL_SESSION_TITLE_KEY: "Lookup Account",
},
)
assert run_items.fingerprint_input_item(base_item) == run_items.fingerprint_input_item(
with_metadata
)
def test_run_result_to_input_list_preserves_tool_search_items() -> None:
agent = Agent(name="A")
result = RunResult(
input="Find CRM tools",
new_items=[
ToolSearchCallItem(
agent=agent,
raw_item={"type": "tool_search_call", "queries": [{"search_term": "profile"}]},
),
ToolSearchOutputItem(
agent=agent,
raw_item={"type": "tool_search_output", "results": [{"text": "Customer profile"}]},
),
],
raw_responses=[],
final_output="done",
input_guardrail_results=[],
output_guardrail_results=[],
tool_input_guardrail_results=[],
tool_output_guardrail_results=[],
context_wrapper=RunContextWrapper(context=None),
_last_agent=agent,
)
input_items = result.to_input_list()
assert len(input_items) == 3
assert cast(dict[str, Any], input_items[1])["type"] == "tool_search_call"
assert cast(dict[str, Any], input_items[2])["type"] == "tool_search_output"
def test_coerce_tool_search_output_raw_item_rejects_legacy_type() -> None:
with pytest.raises(AgentsException, match="Unexpected tool search output item type"):
coerce_tool_search_output_raw_item({"type": "tool_search_result", "results": []})