Skip to content

Commit fc6afeb

Browse files
authored
test: improve coverage for tracing and runtime helpers (#2635)
1 parent da1ad76 commit fc6afeb

File tree

4 files changed

+411
-1
lines changed

4 files changed

+411
-1
lines changed

tests/test_agent_runner.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,11 @@
6262
from agents.run_internal.run_loop import get_new_response
6363
from agents.run_internal.run_steps import NextStepFinalOutput, SingleStepResult
6464
from agents.run_internal.session_persistence import (
65+
persist_session_items_for_guardrail_trip,
6566
prepare_input_with_session,
6667
rewind_session_items,
6768
save_result_to_session,
69+
wait_for_session_cleanup,
6870
)
6971
from agents.run_internal.tool_execution import execute_approved_tools
7072
from agents.run_internal.tool_use_tracker import AgentToolUseTracker
@@ -1889,6 +1891,107 @@ def callback(history: list[TResponseInputItem], new_input: list[TResponseInputIt
18891891
await prepare_input_with_session("hello", session, cast(Any, callback))
18901892

18911893

1894+
@pytest.mark.asyncio
1895+
async def test_prepare_input_with_session_matches_copied_items_by_content() -> None:
1896+
history_item = cast(TResponseInputItem, {"role": "user", "content": "history"})
1897+
session = SimpleListSession(history=[history_item])
1898+
1899+
def callback(
1900+
history: list[TResponseInputItem], new_input: list[TResponseInputItem]
1901+
) -> list[TResponseInputItem]:
1902+
return [
1903+
cast(TResponseInputItem, dict(cast(dict[str, Any], history[0]))),
1904+
cast(TResponseInputItem, dict(cast(dict[str, Any], new_input[0]))),
1905+
]
1906+
1907+
prepared, session_items = await prepare_input_with_session("new", session, callback)
1908+
1909+
assert [cast(dict[str, Any], item).get("content") for item in prepared] == [
1910+
"history",
1911+
"new",
1912+
]
1913+
assert [cast(dict[str, Any], item).get("content") for item in session_items] == ["new"]
1914+
1915+
1916+
@pytest.mark.asyncio
1917+
async def test_persist_session_items_for_guardrail_trip_uses_original_input_when_missing() -> None:
1918+
session = SimpleListSession()
1919+
agent = Agent(name="agent", model=FakeModel())
1920+
run_state: RunState[Any] = RunState(
1921+
context=RunContextWrapper(context={}),
1922+
original_input="input",
1923+
starting_agent=agent,
1924+
max_turns=1,
1925+
)
1926+
1927+
persisted = await persist_session_items_for_guardrail_trip(
1928+
session,
1929+
None,
1930+
None,
1931+
"guardrail input",
1932+
run_state,
1933+
)
1934+
1935+
assert persisted == [{"role": "user", "content": "guardrail input"}]
1936+
assert await session.get_items() == persisted
1937+
1938+
1939+
@pytest.mark.asyncio
1940+
async def test_wait_for_session_cleanup_retries_after_get_items_error(
1941+
monkeypatch: pytest.MonkeyPatch,
1942+
) -> None:
1943+
target = cast(TResponseInputItem, {"id": "msg-1", "type": "message", "content": "hello"})
1944+
serialized_target = fingerprint_input_item(target)
1945+
1946+
class FlakyCleanupSession(SimpleListSession):
1947+
def __init__(self) -> None:
1948+
super().__init__()
1949+
self.get_items_calls = 0
1950+
1951+
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
1952+
self.get_items_calls += 1
1953+
if self.get_items_calls == 1:
1954+
raise RuntimeError("temporary failure")
1955+
return []
1956+
1957+
session = FlakyCleanupSession()
1958+
sleeps: list[float] = []
1959+
1960+
async def fake_sleep(delay: float) -> None:
1961+
sleeps.append(delay)
1962+
1963+
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
1964+
1965+
assert serialized_target is not None
1966+
await wait_for_session_cleanup(session, [serialized_target])
1967+
1968+
assert session.get_items_calls == 2
1969+
assert sleeps == [0.1]
1970+
1971+
1972+
@pytest.mark.asyncio
1973+
async def test_wait_for_session_cleanup_logs_when_targets_linger(
1974+
monkeypatch: pytest.MonkeyPatch,
1975+
caplog: pytest.LogCaptureFixture,
1976+
) -> None:
1977+
target = cast(TResponseInputItem, {"id": "msg-1", "type": "message", "content": "hello"})
1978+
session = SimpleListSession(history=[target])
1979+
serialized_target = fingerprint_input_item(target)
1980+
sleeps: list[float] = []
1981+
1982+
async def fake_sleep(delay: float) -> None:
1983+
sleeps.append(delay)
1984+
1985+
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
1986+
1987+
assert serialized_target is not None
1988+
with caplog.at_level("DEBUG", logger="openai.agents"):
1989+
await wait_for_session_cleanup(session, [serialized_target], max_attempts=2)
1990+
1991+
assert sleeps == [0.1, 0.2]
1992+
assert "Session cleanup verification exhausted attempts" in caplog.text
1993+
1994+
18921995
@pytest.mark.asyncio
18931996
async def test_conversation_lock_rewind_skips_when_no_snapshot() -> None:
18941997
history_item = cast(TResponseInputItem, {"id": "old", "type": "message"})

tests/test_agent_tool_state.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
11
from __future__ import annotations
22

3+
import gc
4+
import weakref
5+
from types import SimpleNamespace
6+
from typing import Any, cast
7+
38
import pytest
9+
from openai.types.responses import ResponseFunctionToolCall
410

511
import agents.agent_tool_state as tool_state
612

13+
from .test_responses import get_function_tool_call
14+
15+
16+
@pytest.fixture(autouse=True)
17+
def reset_tool_state_globals(monkeypatch: pytest.MonkeyPatch) -> None:
18+
monkeypatch.setattr(tool_state, "_agent_tool_run_results_by_obj", {})
19+
monkeypatch.setattr(tool_state, "_agent_tool_run_results_by_signature", {})
20+
monkeypatch.setattr(tool_state, "_agent_tool_run_result_signature_by_obj", {})
21+
monkeypatch.setattr(tool_state, "_agent_tool_call_refs_by_obj", {})
22+
723

824
def test_drop_agent_tool_run_result_handles_cleared_globals(
925
monkeypatch: pytest.MonkeyPatch,
@@ -14,3 +30,73 @@ def test_drop_agent_tool_run_result_handles_cleared_globals(
1430

1531
# Should not raise even if globals are cleared during interpreter shutdown.
1632
tool_state._drop_agent_tool_run_result(123)
33+
34+
35+
def test_agent_tool_state_scope_helpers_tolerate_missing_or_readonly_contexts() -> None:
36+
context = SimpleNamespace()
37+
38+
tool_state.set_agent_tool_state_scope(None, "ignored")
39+
tool_state.set_agent_tool_state_scope(context, "scope-1")
40+
assert tool_state.get_agent_tool_state_scope(context) == "scope-1"
41+
42+
tool_state.set_agent_tool_state_scope(context, None)
43+
assert tool_state.get_agent_tool_state_scope(context) is None
44+
45+
readonly_context = object()
46+
tool_state.set_agent_tool_state_scope(readonly_context, "scope-2")
47+
assert tool_state.get_agent_tool_state_scope(readonly_context) is None
48+
49+
50+
def _function_tool_call(name: str, arguments: str, *, call_id: str) -> ResponseFunctionToolCall:
51+
tool_call = get_function_tool_call(name, arguments, call_id=call_id)
52+
assert isinstance(tool_call, ResponseFunctionToolCall)
53+
return tool_call
54+
55+
56+
def test_agent_tool_run_result_supports_signature_fallback_across_instances() -> None:
57+
original_call = _function_tool_call("lookup_account", "{}", call_id="call-1")
58+
restored_call = _function_tool_call("lookup_account", "{}", call_id="call-1")
59+
run_result = cast(Any, object())
60+
61+
tool_state.record_agent_tool_run_result(original_call, run_result, scope_id="scope-1")
62+
63+
assert tool_state.peek_agent_tool_run_result(restored_call, scope_id="scope-1") is run_result
64+
assert tool_state.consume_agent_tool_run_result(restored_call, scope_id="scope-1") is run_result
65+
assert tool_state.peek_agent_tool_run_result(original_call, scope_id="scope-1") is None
66+
assert tool_state._agent_tool_run_results_by_signature == {}
67+
68+
69+
def test_agent_tool_run_result_returns_none_for_ambiguous_signature_matches() -> None:
70+
first_call = _function_tool_call("lookup_account", "{}", call_id="call-1")
71+
second_call = _function_tool_call("lookup_account", "{}", call_id="call-1")
72+
restored_call = _function_tool_call("lookup_account", "{}", call_id="call-1")
73+
first_result = cast(Any, object())
74+
second_result = cast(Any, object())
75+
76+
tool_state.record_agent_tool_run_result(first_call, first_result, scope_id="scope-1")
77+
tool_state.record_agent_tool_run_result(second_call, second_result, scope_id="scope-1")
78+
79+
assert tool_state.peek_agent_tool_run_result(restored_call, scope_id="scope-1") is None
80+
assert tool_state.consume_agent_tool_run_result(restored_call, scope_id="scope-1") is None
81+
82+
tool_state.drop_agent_tool_run_result(restored_call, scope_id="scope-1")
83+
84+
assert tool_state.peek_agent_tool_run_result(first_call, scope_id="scope-1") is first_result
85+
assert tool_state.peek_agent_tool_run_result(second_call, scope_id="scope-1") is second_result
86+
assert tool_state.peek_agent_tool_run_result(restored_call, scope_id="other-scope") is None
87+
88+
89+
def test_agent_tool_run_result_is_dropped_when_tool_call_is_collected() -> None:
90+
tool_call = _function_tool_call("lookup_account", "{}", call_id="call-1")
91+
tool_call_ref = weakref.ref(tool_call)
92+
tool_call_obj_id = id(tool_call)
93+
94+
tool_state.record_agent_tool_run_result(tool_call, cast(Any, object()), scope_id="scope-1")
95+
96+
del tool_call
97+
gc.collect()
98+
99+
assert tool_call_ref() is None
100+
assert tool_call_obj_id not in tool_state._agent_tool_run_results_by_obj
101+
assert tool_call_obj_id not in tool_state._agent_tool_run_result_signature_by_obj
102+
assert tool_call_obj_id not in tool_state._agent_tool_call_refs_by_obj

tests/test_stream_events.py

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import asyncio
22
import time
3-
from typing import cast
3+
from typing import Any, cast
44

55
import pytest
66
from mcp import Tool as MCPTool
@@ -22,20 +22,33 @@
2222
ResponseReasoningSummaryTextDoneEvent,
2323
ResponseTextDeltaEvent,
2424
ResponseTextDoneEvent,
25+
ResponseToolSearchCall,
26+
ResponseToolSearchOutputItem,
27+
)
28+
from openai.types.responses.response_output_item import (
29+
McpApprovalRequest,
30+
McpListTools,
31+
McpListToolsTool,
2532
)
2633
from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary
2734

2835
from agents import Agent, HandoffCallItem, Runner, function_tool
2936
from agents.extensions.handoff_filters import remove_all_tools
3037
from agents.handoffs import handoff
3138
from agents.items import (
39+
MCPApprovalRequestItem,
40+
MCPApprovalResponseItem,
41+
MCPListToolsItem,
3242
MessageOutputItem,
3343
ReasoningItem,
44+
RunItem,
45+
ToolApprovalItem,
3446
ToolCallItem,
3547
ToolCallOutputItem,
3648
ToolSearchCallItem,
3749
ToolSearchOutputItem,
3850
)
51+
from agents.run_internal.streaming import stream_step_items_to_queue, stream_step_result_to_queue
3952

4053
from .fake_model import FakeModel
4154
from .mcp.helpers import FakeMCPServer
@@ -48,6 +61,22 @@ def get_reasoning_item() -> ResponseReasoningItem:
4861
)
4962

5063

64+
def _make_hosted_mcp_list_tools(server_label: str, tool_name: str) -> McpListTools:
65+
return McpListTools(
66+
id=f"list_{server_label}",
67+
server_label=server_label,
68+
tools=[
69+
McpListToolsTool(
70+
name=tool_name,
71+
input_schema={},
72+
description="Search the docs.",
73+
annotations={"title": "Search Docs"},
74+
)
75+
],
76+
type="mcp_list_tools",
77+
)
78+
79+
5180
@function_tool
5281
async def foo() -> str:
5382
await asyncio.sleep(0)
@@ -130,6 +159,100 @@ async def test_stream_events_tool_called_includes_local_mcp_title() -> None:
130159
assert seen_tool_item.title == "Search Docs"
131160

132161

162+
def test_stream_step_items_to_queue_emits_helper_events_and_skips_approvals(
163+
caplog: pytest.LogCaptureFixture,
164+
) -> None:
165+
agent = Agent(name="StreamHelper")
166+
queue: asyncio.Queue[Any] = asyncio.Queue()
167+
request_item = McpApprovalRequest(
168+
id="mcp-approval-1",
169+
type="mcp_approval_request",
170+
server_label="test-mcp-server",
171+
arguments="{}",
172+
name="search_docs",
173+
)
174+
175+
items: list[RunItem] = [
176+
ToolSearchCallItem(
177+
agent=agent,
178+
raw_item=ResponseToolSearchCall(
179+
id="tsc_123",
180+
type="tool_search_call",
181+
arguments={"query": "docs"},
182+
execution="client",
183+
status="completed",
184+
),
185+
),
186+
ToolSearchOutputItem(
187+
agent=agent,
188+
raw_item=ResponseToolSearchOutputItem(
189+
id="tso_123",
190+
type="tool_search_output",
191+
execution="client",
192+
status="completed",
193+
tools=[],
194+
),
195+
),
196+
MCPApprovalRequestItem(agent=agent, raw_item=request_item),
197+
MCPApprovalResponseItem(
198+
agent=agent,
199+
raw_item=cast(
200+
Any,
201+
{
202+
"type": "mcp_approval_response",
203+
"approval_request_id": "mcp-approval-1",
204+
"approve": True,
205+
},
206+
),
207+
),
208+
MCPListToolsItem(
209+
agent=agent,
210+
raw_item=_make_hosted_mcp_list_tools("test-mcp-server", "search_docs"),
211+
),
212+
ToolApprovalItem(
213+
agent=agent,
214+
raw_item={"type": "function_call", "call_id": "call-1", "name": "tool"},
215+
),
216+
cast(Any, object()),
217+
]
218+
219+
with caplog.at_level("WARNING", logger="openai.agents"):
220+
stream_step_items_to_queue(items, queue)
221+
222+
names = []
223+
while not queue.empty():
224+
event = queue.get_nowait()
225+
names.append(event.name)
226+
227+
assert names == [
228+
"tool_search_called",
229+
"tool_search_output_created",
230+
"mcp_approval_requested",
231+
"mcp_approval_response",
232+
"mcp_list_tools",
233+
]
234+
assert "Unexpected item type" in caplog.text
235+
236+
237+
def test_stream_step_result_to_queue_uses_new_step_items() -> None:
238+
agent = Agent(name="StreamHelper")
239+
queue: asyncio.Queue[Any] = asyncio.Queue()
240+
241+
tool_search_item = ToolSearchCallItem(
242+
agent=agent,
243+
raw_item={
244+
"type": "tool_search_call",
245+
"queries": [{"search_term": "docs"}],
246+
},
247+
)
248+
step_result = cast(Any, type("StepResult", (), {"new_step_items": [tool_search_item]})())
249+
250+
stream_step_result_to_queue(step_result, queue)
251+
252+
event = queue.get_nowait()
253+
assert event.name == "tool_search_called"
254+
255+
133256
@pytest.mark.asyncio
134257
async def test_stream_events_main_with_handoff():
135258
@function_tool

0 commit comments

Comments
 (0)