1029 lines
33 KiB
Python
1029 lines
33 KiB
Python
from types import SimpleNamespace
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
from openai.types.responses import ResponseFunctionToolCall
|
|
from openai.types.responses.response_output_item import McpCall, McpListTools, McpListToolsTool
|
|
|
|
from agents import Agent, HostedMCPTool
|
|
from agents.items import (
|
|
MCPListToolsItem,
|
|
ModelResponse,
|
|
RunItem,
|
|
ToolApprovalItem,
|
|
ToolCallItem,
|
|
ToolCallOutputItem,
|
|
TResponseInputItem,
|
|
)
|
|
from agents.lifecycle import RunHooks
|
|
from agents.models.fake_id import FAKE_RESPONSES_ID
|
|
from agents.result import RunResultStreaming
|
|
from agents.run_config import ModelInputData, RunConfig
|
|
from agents.run_context import RunContextWrapper
|
|
from agents.run_internal.agent_bindings import bind_public_agent
|
|
from agents.run_internal.agent_runner_helpers import get_unsent_tool_call_ids_for_interrupted_state
|
|
from agents.run_internal.oai_conversation import OpenAIServerConversationTracker
|
|
from agents.run_internal.run_loop import get_new_response, run_single_turn_streamed
|
|
from agents.run_internal.run_steps import NextStepInterruption
|
|
from agents.run_internal.tool_use_tracker import AgentToolUseTracker
|
|
from agents.stream_events import RunItemStreamEvent
|
|
from agents.usage import Usage
|
|
|
|
from .fake_model import FakeModel
|
|
from .test_responses import get_text_message
|
|
|
|
|
|
class DummyRunItem:
|
|
"""Minimal stand-in for RunItem with the attributes used by OpenAIServerConversationTracker."""
|
|
|
|
def __init__(self, raw_item: dict[str, Any], type: str = "message") -> None:
|
|
self.raw_item = raw_item
|
|
self.type = type
|
|
|
|
|
|
def _make_hosted_mcp_list_tools(server_label: str, tool_name: str) -> McpListTools:
|
|
return McpListTools(
|
|
id=f"list_{server_label}",
|
|
server_label=server_label,
|
|
tools=[
|
|
McpListToolsTool(
|
|
name=tool_name,
|
|
input_schema={},
|
|
description="Search the docs.",
|
|
annotations={"title": "Search Docs"},
|
|
)
|
|
],
|
|
type="mcp_list_tools",
|
|
)
|
|
|
|
|
|
def test_prepare_input_filters_items_seen_by_server_and_tool_calls() -> None:
|
|
tracker = OpenAIServerConversationTracker(conversation_id="conv", previous_response_id=None)
|
|
|
|
original_input: list[TResponseInputItem] = [
|
|
cast(TResponseInputItem, {"id": "input-1", "type": "message"}),
|
|
cast(TResponseInputItem, {"id": "input-2", "type": "message"}),
|
|
]
|
|
new_raw_item = {"type": "message", "content": "hello"}
|
|
generated_items = [
|
|
DummyRunItem({"id": "server-echo", "type": "message"}),
|
|
DummyRunItem(new_raw_item),
|
|
DummyRunItem({"call_id": "call-1", "output": "done"}, type="function_call_output_item"),
|
|
]
|
|
model_response = object.__new__(ModelResponse)
|
|
model_response.output = [
|
|
cast(Any, {"call_id": "call-1", "output": "prior", "type": "function_call_output"})
|
|
]
|
|
model_response.usage = Usage()
|
|
model_response.response_id = "resp-1"
|
|
session_items: list[TResponseInputItem] = [
|
|
cast(TResponseInputItem, {"id": "session-1", "type": "message"})
|
|
]
|
|
|
|
tracker.hydrate_from_state(
|
|
original_input=original_input,
|
|
generated_items=cast(list[Any], generated_items),
|
|
model_responses=[model_response],
|
|
session_items=session_items,
|
|
)
|
|
|
|
prepared = tracker.prepare_input(
|
|
original_input=original_input,
|
|
generated_items=cast(list[Any], generated_items),
|
|
)
|
|
|
|
assert prepared == [new_raw_item]
|
|
assert tracker.sent_initial_input is True
|
|
assert tracker.remaining_initial_input is None
|
|
|
|
|
|
def test_hydrate_from_state_preserves_unsent_outputs_from_interrupted_turn() -> None:
|
|
agent = Agent(name="test")
|
|
cleanup1_call = ResponseFunctionToolCall(
|
|
id="fc_001",
|
|
type="function_call",
|
|
call_id="call_CLEANUP1",
|
|
name="run_cleanup",
|
|
arguments='{"target": "temp_files"}',
|
|
status="completed",
|
|
)
|
|
diagnostic_call = ResponseFunctionToolCall(
|
|
id="fc_002",
|
|
type="function_call",
|
|
call_id="call_DIAG",
|
|
name="run_diagnostic",
|
|
arguments='{"check_name": "thermal"}',
|
|
status="completed",
|
|
)
|
|
cleanup2_call = ResponseFunctionToolCall(
|
|
id="fc_003",
|
|
type="function_call",
|
|
call_id="call_CLEANUP2",
|
|
name="run_cleanup",
|
|
arguments='{"target": "winsxs_cache"}',
|
|
status="completed",
|
|
)
|
|
model_response = ModelResponse(
|
|
output=[cleanup1_call, diagnostic_call, cleanup2_call],
|
|
usage=Usage(),
|
|
response_id="resp_002",
|
|
)
|
|
diagnostic_output = ToolCallOutputItem(
|
|
agent=agent,
|
|
raw_item={
|
|
"type": "function_call_output",
|
|
"call_id": "call_DIAG",
|
|
"output": "Diagnostic completed.",
|
|
},
|
|
output="Diagnostic completed.",
|
|
)
|
|
generated_items: list[RunItem] = [
|
|
ToolCallItem(agent=agent, raw_item=cleanup1_call),
|
|
ToolCallItem(agent=agent, raw_item=diagnostic_call),
|
|
ToolCallItem(agent=agent, raw_item=cleanup2_call),
|
|
diagnostic_output,
|
|
ToolApprovalItem(agent=agent, raw_item=cleanup1_call, tool_name="run_cleanup"),
|
|
ToolApprovalItem(agent=agent, raw_item=cleanup2_call, tool_name="run_cleanup"),
|
|
]
|
|
interrupted_state = SimpleNamespace(
|
|
_current_step=NextStepInterruption(interruptions=[]),
|
|
_last_processed_response=SimpleNamespace(
|
|
handoffs=[],
|
|
functions=[
|
|
SimpleNamespace(tool_call=cleanup1_call),
|
|
SimpleNamespace(tool_call=diagnostic_call),
|
|
SimpleNamespace(tool_call=cleanup2_call),
|
|
],
|
|
computer_actions=[],
|
|
custom_tool_calls=[],
|
|
local_shell_calls=[],
|
|
shell_calls=[],
|
|
apply_patch_calls=[],
|
|
),
|
|
)
|
|
|
|
tracker = OpenAIServerConversationTracker(previous_response_id="resp_002")
|
|
tracker.hydrate_from_state(
|
|
original_input="Run cleanup, diagnostics, and cleanup.",
|
|
generated_items=generated_items,
|
|
model_responses=[model_response],
|
|
unsent_tool_call_ids=get_unsent_tool_call_ids_for_interrupted_state(
|
|
cast(Any, interrupted_state)
|
|
),
|
|
)
|
|
|
|
assert "call_DIAG" not in tracker.server_tool_call_ids
|
|
|
|
prepared = tracker.prepare_input(
|
|
"Run cleanup, diagnostics, and cleanup.",
|
|
[
|
|
ToolCallItem(agent=agent, raw_item=cleanup1_call),
|
|
ToolCallItem(agent=agent, raw_item=diagnostic_call),
|
|
ToolCallItem(agent=agent, raw_item=cleanup2_call),
|
|
diagnostic_output,
|
|
ToolCallOutputItem(
|
|
agent=agent,
|
|
raw_item={
|
|
"type": "function_call_output",
|
|
"call_id": "call_CLEANUP1",
|
|
"output": "Tool call not approved.",
|
|
},
|
|
output="Tool call not approved.",
|
|
),
|
|
ToolCallOutputItem(
|
|
agent=agent,
|
|
raw_item={
|
|
"type": "function_call_output",
|
|
"call_id": "call_CLEANUP2",
|
|
"output": "Tool call not approved.",
|
|
},
|
|
output="Tool call not approved.",
|
|
),
|
|
],
|
|
)
|
|
|
|
assert [
|
|
item.get("call_id")
|
|
for item in prepared
|
|
if isinstance(item, dict) and item.get("type") == "function_call_output"
|
|
] == ["call_DIAG", "call_CLEANUP1", "call_CLEANUP2"]
|
|
|
|
|
|
def test_hydrate_from_state_does_not_track_string_initial_input_by_object_identity() -> None:
|
|
tracker = OpenAIServerConversationTracker(
|
|
conversation_id="conv-init-string", previous_response_id=None
|
|
)
|
|
|
|
tracker.hydrate_from_state(
|
|
original_input="hello",
|
|
generated_items=[],
|
|
model_responses=[],
|
|
)
|
|
|
|
assert tracker.sent_items == []
|
|
assert tracker.sent_initial_input is True
|
|
assert tracker.remaining_initial_input is None
|
|
assert len(tracker.sent_item_fingerprints) == 1
|
|
|
|
|
|
def test_hydrate_from_state_does_not_track_list_initial_input_by_object_identity() -> None:
|
|
tracker = OpenAIServerConversationTracker(
|
|
conversation_id="conv-init-list", previous_response_id=None
|
|
)
|
|
original_input = [cast(TResponseInputItem, {"role": "user", "content": "hello"})]
|
|
|
|
tracker.hydrate_from_state(
|
|
original_input=original_input,
|
|
generated_items=[],
|
|
model_responses=[],
|
|
)
|
|
|
|
assert tracker.sent_items == []
|
|
assert tracker.sent_initial_input is True
|
|
assert tracker.remaining_initial_input is None
|
|
assert len(tracker.sent_item_fingerprints) == 1
|
|
|
|
|
|
def test_mark_input_as_sent_and_rewind_input_respects_remaining_initial_input() -> None:
|
|
tracker = OpenAIServerConversationTracker(conversation_id="conv2", previous_response_id=None)
|
|
pending_1: TResponseInputItem = cast(TResponseInputItem, {"id": "p-1", "type": "message"})
|
|
pending_2: TResponseInputItem = cast(TResponseInputItem, {"id": "p-2", "type": "message"})
|
|
tracker.remaining_initial_input = [pending_1, pending_2]
|
|
|
|
tracker.mark_input_as_sent(
|
|
[pending_1, cast(TResponseInputItem, {"id": "p-2", "type": "message"})]
|
|
)
|
|
assert tracker.remaining_initial_input is None
|
|
|
|
tracker.rewind_input([pending_1])
|
|
assert tracker.remaining_initial_input == [pending_1]
|
|
|
|
|
|
def test_mark_input_as_sent_uses_raw_generated_source_for_rebuilt_filtered_item() -> None:
|
|
tracker = OpenAIServerConversationTracker(conversation_id="conv2b", previous_response_id=None)
|
|
raw_generated_item = {
|
|
"type": "function_call_output",
|
|
"call_id": "call-2b",
|
|
"output": "done",
|
|
}
|
|
generated_items = [
|
|
DummyRunItem(raw_generated_item, type="function_call_output_item"),
|
|
]
|
|
|
|
prepared = tracker.prepare_input(
|
|
original_input=[],
|
|
generated_items=cast(list[Any], generated_items),
|
|
)
|
|
rebuilt_filtered_item = cast(TResponseInputItem, dict(cast(dict[str, Any], prepared[0])))
|
|
|
|
tracker.mark_input_as_sent([rebuilt_filtered_item])
|
|
|
|
assert any(item is raw_generated_item for item in tracker.sent_items)
|
|
assert all(item is not rebuilt_filtered_item for item in tracker.sent_items)
|
|
|
|
prepared_again = tracker.prepare_input(
|
|
original_input=[],
|
|
generated_items=cast(list[Any], generated_items),
|
|
)
|
|
assert prepared_again == []
|
|
|
|
|
|
def test_hydrate_from_state_skips_restored_tool_search_items_by_object_identity() -> None:
|
|
tracker = OpenAIServerConversationTracker(conversation_id="conv2c", previous_response_id=None)
|
|
tool_search_call = {
|
|
"type": "tool_search_call",
|
|
"queries": [{"search_term": "account balance"}],
|
|
}
|
|
tool_search_result = {
|
|
"type": "tool_search_output",
|
|
"results": [{"text": "Balance lookup docs"}],
|
|
}
|
|
hydrated_items = [
|
|
DummyRunItem(tool_search_call, type="tool_search_call_item"),
|
|
DummyRunItem(tool_search_result, type="tool_search_output_item"),
|
|
]
|
|
|
|
tracker.hydrate_from_state(
|
|
original_input=[],
|
|
generated_items=cast(list[Any], hydrated_items),
|
|
model_responses=[],
|
|
)
|
|
|
|
prepared = tracker.prepare_input(
|
|
original_input=[],
|
|
generated_items=cast(list[Any], hydrated_items),
|
|
)
|
|
|
|
assert prepared == []
|
|
|
|
|
|
def test_hydrate_from_state_skips_restored_tool_search_items_by_fingerprint() -> None:
|
|
tracker = OpenAIServerConversationTracker(conversation_id="conv2d", previous_response_id=None)
|
|
tool_search_call = {
|
|
"type": "tool_search_call",
|
|
"queries": [{"search_term": "account balance"}],
|
|
}
|
|
tool_search_result = {
|
|
"type": "tool_search_output",
|
|
"results": [{"text": "Balance lookup docs"}],
|
|
}
|
|
hydrated_items = [
|
|
DummyRunItem(tool_search_call, type="tool_search_call_item"),
|
|
DummyRunItem(tool_search_result, type="tool_search_output_item"),
|
|
]
|
|
rebuilt_items = [
|
|
DummyRunItem(dict(tool_search_call), type="tool_search_call_item"),
|
|
DummyRunItem(dict(tool_search_result), type="tool_search_output_item"),
|
|
]
|
|
|
|
tracker.hydrate_from_state(
|
|
original_input=[],
|
|
generated_items=cast(list[Any], hydrated_items),
|
|
model_responses=[],
|
|
)
|
|
|
|
prepared = tracker.prepare_input(
|
|
original_input=[],
|
|
generated_items=cast(list[Any], rebuilt_items),
|
|
)
|
|
|
|
assert prepared == []
|
|
|
|
|
|
def test_hydrate_from_state_skips_restored_tool_search_items_when_created_by_is_stripped() -> None:
|
|
tracker = OpenAIServerConversationTracker(
|
|
conversation_id="conv2d-created-by", previous_response_id=None
|
|
)
|
|
session_items = [
|
|
cast(
|
|
TResponseInputItem,
|
|
{
|
|
"type": "tool_search_call",
|
|
"call_id": "tool_search_call_1",
|
|
"arguments": {"query": "account balance"},
|
|
"execution": "server",
|
|
"status": "completed",
|
|
"created_by": "server",
|
|
},
|
|
),
|
|
cast(
|
|
TResponseInputItem,
|
|
{
|
|
"type": "tool_search_output",
|
|
"call_id": "tool_search_call_1",
|
|
"execution": "server",
|
|
"status": "completed",
|
|
"tools": [],
|
|
"created_by": "server",
|
|
},
|
|
),
|
|
]
|
|
|
|
tracker.hydrate_from_state(
|
|
original_input=[],
|
|
generated_items=[],
|
|
model_responses=[],
|
|
session_items=session_items,
|
|
)
|
|
|
|
prepared = tracker.prepare_input(
|
|
original_input=[],
|
|
generated_items=cast(
|
|
list[RunItem],
|
|
[
|
|
DummyRunItem(
|
|
{
|
|
"type": "tool_search_call",
|
|
"call_id": "tool_search_call_1",
|
|
"arguments": {"query": "account balance"},
|
|
"execution": "server",
|
|
"status": "completed",
|
|
},
|
|
type="tool_search_call_item",
|
|
),
|
|
DummyRunItem(
|
|
{
|
|
"type": "tool_search_output",
|
|
"call_id": "tool_search_call_1",
|
|
"execution": "server",
|
|
"status": "completed",
|
|
"tools": [],
|
|
},
|
|
type="tool_search_output_item",
|
|
),
|
|
],
|
|
),
|
|
)
|
|
|
|
assert prepared == []
|
|
|
|
|
|
def test_hydrate_from_state_skips_restored_tool_search_items_when_only_ids_differ() -> None:
|
|
tracker = OpenAIServerConversationTracker(
|
|
conversation_id="conv2d-ids-only", previous_response_id=None
|
|
)
|
|
session_items = [
|
|
cast(
|
|
TResponseInputItem,
|
|
{
|
|
"type": "tool_search_call",
|
|
"id": "tool_search_call_saved",
|
|
"arguments": {"query": "account balance"},
|
|
"execution": "server",
|
|
"status": "completed",
|
|
},
|
|
),
|
|
cast(
|
|
TResponseInputItem,
|
|
{
|
|
"type": "tool_search_output",
|
|
"id": "tool_search_output_saved",
|
|
"execution": "server",
|
|
"status": "completed",
|
|
"tools": [],
|
|
},
|
|
),
|
|
]
|
|
|
|
tracker.hydrate_from_state(
|
|
original_input=[],
|
|
generated_items=[],
|
|
model_responses=[],
|
|
session_items=session_items,
|
|
)
|
|
|
|
prepared = tracker.prepare_input(
|
|
original_input=[],
|
|
generated_items=cast(
|
|
list[RunItem],
|
|
[
|
|
DummyRunItem(
|
|
{
|
|
"type": "tool_search_call",
|
|
"arguments": {"query": "account balance"},
|
|
"execution": "server",
|
|
"status": "completed",
|
|
},
|
|
type="tool_search_call_item",
|
|
),
|
|
DummyRunItem(
|
|
{
|
|
"type": "tool_search_output",
|
|
"execution": "server",
|
|
"status": "completed",
|
|
"tools": [],
|
|
},
|
|
type="tool_search_output_item",
|
|
),
|
|
],
|
|
),
|
|
)
|
|
|
|
assert prepared == []
|
|
|
|
|
|
def test_prepare_input_keeps_repeated_tool_search_items_with_new_ids() -> None:
|
|
tracker = OpenAIServerConversationTracker(
|
|
conversation_id="conv2d-repeated-search", previous_response_id=None
|
|
)
|
|
|
|
prior_response = object.__new__(ModelResponse)
|
|
prior_response.output = [
|
|
cast(
|
|
Any,
|
|
{
|
|
"type": "tool_search_call",
|
|
"id": "tool_search_call_saved",
|
|
"arguments": {"query": "account balance"},
|
|
"execution": "server",
|
|
"status": "completed",
|
|
"created_by": "server",
|
|
},
|
|
),
|
|
cast(
|
|
Any,
|
|
{
|
|
"type": "tool_search_output",
|
|
"id": "tool_search_output_saved",
|
|
"execution": "server",
|
|
"status": "completed",
|
|
"tools": [],
|
|
"created_by": "server",
|
|
},
|
|
),
|
|
]
|
|
prior_response.usage = Usage()
|
|
prior_response.response_id = "resp-tool-search-repeat-1"
|
|
|
|
tracker.track_server_items(prior_response)
|
|
|
|
repeated_items = [
|
|
DummyRunItem(
|
|
{
|
|
"type": "tool_search_call",
|
|
"id": "tool_search_call_repeat",
|
|
"arguments": {"query": "account balance"},
|
|
"execution": "server",
|
|
"status": "completed",
|
|
},
|
|
type="tool_search_call_item",
|
|
),
|
|
DummyRunItem(
|
|
{
|
|
"type": "tool_search_output",
|
|
"id": "tool_search_output_repeat",
|
|
"execution": "server",
|
|
"status": "completed",
|
|
"tools": [],
|
|
},
|
|
type="tool_search_output_item",
|
|
),
|
|
]
|
|
|
|
prepared = tracker.prepare_input(
|
|
original_input=[],
|
|
generated_items=cast(list[Any], repeated_items),
|
|
)
|
|
|
|
assert prepared == [
|
|
cast(
|
|
TResponseInputItem,
|
|
{
|
|
"type": "tool_search_call",
|
|
"id": "tool_search_call_repeat",
|
|
"arguments": {"query": "account balance"},
|
|
"execution": "server",
|
|
"status": "completed",
|
|
},
|
|
),
|
|
cast(
|
|
TResponseInputItem,
|
|
{
|
|
"type": "tool_search_output",
|
|
"id": "tool_search_output_repeat",
|
|
"execution": "server",
|
|
"status": "completed",
|
|
"tools": [],
|
|
},
|
|
),
|
|
]
|
|
|
|
|
|
def test_track_server_items_skips_live_tool_search_items_on_next_prepare() -> None:
|
|
tracker = OpenAIServerConversationTracker(conversation_id="conv2e", previous_response_id=None)
|
|
tool_search_call = cast(
|
|
Any,
|
|
{
|
|
"type": "tool_search_call",
|
|
"call_id": "tool_search_call_live",
|
|
"arguments": {"query": "account balance"},
|
|
"execution": "server",
|
|
"status": "completed",
|
|
"created_by": "server",
|
|
},
|
|
)
|
|
tool_search_result = cast(
|
|
Any,
|
|
{
|
|
"type": "tool_search_output",
|
|
"call_id": "tool_search_call_live",
|
|
"execution": "server",
|
|
"status": "completed",
|
|
"tools": [],
|
|
"created_by": "server",
|
|
},
|
|
)
|
|
model_response = object.__new__(ModelResponse)
|
|
model_response.output = [tool_search_call, tool_search_result]
|
|
model_response.usage = Usage()
|
|
model_response.response_id = "resp-tool-search"
|
|
|
|
tracker.track_server_items(model_response)
|
|
|
|
prepared = tracker.prepare_input(
|
|
original_input=[],
|
|
generated_items=cast(
|
|
list[RunItem],
|
|
[
|
|
DummyRunItem(
|
|
{
|
|
"type": "tool_search_call",
|
|
"call_id": "tool_search_call_live",
|
|
"arguments": {"query": "account balance"},
|
|
"execution": "server",
|
|
"status": "completed",
|
|
},
|
|
type="tool_search_call_item",
|
|
),
|
|
DummyRunItem(
|
|
{
|
|
"type": "tool_search_output",
|
|
"call_id": "tool_search_call_live",
|
|
"execution": "server",
|
|
"status": "completed",
|
|
"tools": [],
|
|
},
|
|
type="tool_search_output_item",
|
|
),
|
|
],
|
|
),
|
|
)
|
|
|
|
assert prepared == []
|
|
|
|
|
|
def test_track_server_items_filters_pending_tool_search_by_sanitized_fingerprint() -> None:
|
|
tracker = OpenAIServerConversationTracker(
|
|
conversation_id="conv2e-pending", previous_response_id=None
|
|
)
|
|
tracker.remaining_initial_input = [
|
|
cast(
|
|
TResponseInputItem,
|
|
{
|
|
"type": "tool_search_call",
|
|
"call_id": "tool_search_pending",
|
|
"arguments": {"query": "account balance"},
|
|
"execution": "server",
|
|
"status": "completed",
|
|
},
|
|
),
|
|
cast(TResponseInputItem, {"id": "keep-me", "type": "message"}),
|
|
]
|
|
|
|
model_response = object.__new__(ModelResponse)
|
|
model_response.output = [
|
|
cast(
|
|
Any,
|
|
{
|
|
"type": "tool_search_call",
|
|
"call_id": "tool_search_pending",
|
|
"arguments": {"query": "account balance"},
|
|
"execution": "server",
|
|
"status": "completed",
|
|
"created_by": "server",
|
|
},
|
|
)
|
|
]
|
|
model_response.usage = Usage()
|
|
model_response.response_id = "resp-tool-search-pending"
|
|
|
|
tracker.track_server_items(model_response)
|
|
|
|
assert tracker.remaining_initial_input == [
|
|
cast(TResponseInputItem, {"id": "keep-me", "type": "message"})
|
|
]
|
|
|
|
|
|
def test_track_server_items_filters_remaining_initial_input_by_fingerprint() -> None:
|
|
tracker = OpenAIServerConversationTracker(conversation_id="conv3", previous_response_id=None)
|
|
pending_kept: TResponseInputItem = cast(
|
|
TResponseInputItem, {"id": "keep-me", "type": "message"}
|
|
)
|
|
pending_filtered: TResponseInputItem = cast(
|
|
TResponseInputItem,
|
|
{"type": "function_call_output", "call_id": "call-2", "output": "x"},
|
|
)
|
|
tracker.remaining_initial_input = [pending_kept, pending_filtered]
|
|
|
|
model_response = object.__new__(ModelResponse)
|
|
model_response.output = [
|
|
cast(Any, {"type": "function_call_output", "call_id": "call-2", "output": "x"})
|
|
]
|
|
model_response.usage = Usage()
|
|
model_response.response_id = "resp-2"
|
|
|
|
tracker.track_server_items(model_response)
|
|
|
|
assert tracker.remaining_initial_input == [pending_kept]
|
|
|
|
|
|
def test_prepare_input_does_not_skip_fake_response_ids() -> None:
|
|
tracker = OpenAIServerConversationTracker(conversation_id="conv5", previous_response_id=None)
|
|
|
|
model_response = object.__new__(ModelResponse)
|
|
model_response.output = [cast(Any, {"id": FAKE_RESPONSES_ID, "type": "message"})]
|
|
model_response.usage = Usage()
|
|
model_response.response_id = "resp-3"
|
|
|
|
tracker.track_server_items(model_response)
|
|
|
|
raw_item = {"id": FAKE_RESPONSES_ID, "type": "message", "content": "hello"}
|
|
generated_items = [DummyRunItem(raw_item)]
|
|
|
|
prepared = tracker.prepare_input(
|
|
original_input=[],
|
|
generated_items=cast(list[Any], generated_items),
|
|
)
|
|
|
|
assert prepared == [raw_item]
|
|
|
|
|
|
def test_prepare_input_applies_reasoning_item_id_policy_for_generated_items() -> None:
|
|
tracker = OpenAIServerConversationTracker(
|
|
conversation_id="conv7",
|
|
previous_response_id=None,
|
|
reasoning_item_id_policy="omit",
|
|
)
|
|
generated_items = [
|
|
DummyRunItem(
|
|
{
|
|
"type": "reasoning",
|
|
"id": "rs_turn_input",
|
|
"content": [{"type": "input_text", "text": "reasoning trace"}],
|
|
},
|
|
type="reasoning_item",
|
|
)
|
|
]
|
|
|
|
prepared = tracker.prepare_input(
|
|
original_input=[],
|
|
generated_items=cast(list[Any], generated_items),
|
|
)
|
|
|
|
assert prepared == [
|
|
cast(
|
|
TResponseInputItem,
|
|
{"type": "reasoning", "content": [{"type": "input_text", "text": "reasoning trace"}]},
|
|
)
|
|
]
|
|
|
|
|
|
def test_prepare_input_does_not_resend_reasoning_item_after_marking_omitted_id_as_sent() -> None:
|
|
tracker = OpenAIServerConversationTracker(
|
|
conversation_id="conv8",
|
|
previous_response_id=None,
|
|
reasoning_item_id_policy="omit",
|
|
)
|
|
generated_items = [
|
|
DummyRunItem(
|
|
{
|
|
"type": "reasoning",
|
|
"id": "rs_turn_input",
|
|
"content": [{"type": "input_text", "text": "reasoning trace"}],
|
|
},
|
|
type="reasoning_item",
|
|
)
|
|
]
|
|
|
|
first_prepared = tracker.prepare_input(
|
|
original_input=[],
|
|
generated_items=cast(list[Any], generated_items),
|
|
)
|
|
assert first_prepared == [
|
|
cast(
|
|
TResponseInputItem,
|
|
{"type": "reasoning", "content": [{"type": "input_text", "text": "reasoning trace"}]},
|
|
)
|
|
]
|
|
|
|
tracker.mark_input_as_sent(first_prepared)
|
|
|
|
second_prepared = tracker.prepare_input(
|
|
original_input=[],
|
|
generated_items=cast(list[Any], generated_items),
|
|
)
|
|
assert second_prepared == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_new_response_marks_filtered_input_as_sent() -> None:
|
|
model = FakeModel()
|
|
model.set_next_output([get_text_message("ok")])
|
|
agent = Agent(name="test", model=model)
|
|
tracker = OpenAIServerConversationTracker(conversation_id="conv4", previous_response_id=None)
|
|
context_wrapper: RunContextWrapper[dict[str, Any]] = RunContextWrapper(context={})
|
|
tool_use_tracker = AgentToolUseTracker()
|
|
|
|
item_1: TResponseInputItem = cast(TResponseInputItem, {"role": "user", "content": "first"})
|
|
item_2: TResponseInputItem = cast(TResponseInputItem, {"role": "user", "content": "second"})
|
|
|
|
def _filter_input(payload: Any) -> ModelInputData:
|
|
return ModelInputData(
|
|
input=[payload.model_data.input[0]],
|
|
instructions=payload.model_data.instructions,
|
|
)
|
|
|
|
run_config = RunConfig(call_model_input_filter=_filter_input)
|
|
|
|
await get_new_response(
|
|
bind_public_agent(agent),
|
|
None,
|
|
[item_1, item_2],
|
|
None,
|
|
[],
|
|
[],
|
|
RunHooks(),
|
|
context_wrapper,
|
|
run_config,
|
|
tool_use_tracker,
|
|
tracker,
|
|
None,
|
|
)
|
|
|
|
assert model.last_turn_args["input"] == [item_1]
|
|
assert any(item is item_1 for item in tracker.sent_items)
|
|
assert all(item is not item_2 for item in tracker.sent_items)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_single_turn_streamed_marks_filtered_input_as_sent() -> None:
|
|
model = FakeModel()
|
|
model.set_next_output([get_text_message("ok")])
|
|
agent = Agent(name="test", model=model)
|
|
tracker = OpenAIServerConversationTracker(conversation_id="conv6", previous_response_id=None)
|
|
context_wrapper: RunContextWrapper[dict[str, Any]] = RunContextWrapper(context={})
|
|
tool_use_tracker = AgentToolUseTracker()
|
|
|
|
item_1: TResponseInputItem = cast(TResponseInputItem, {"role": "user", "content": "first"})
|
|
item_2: TResponseInputItem = cast(TResponseInputItem, {"role": "user", "content": "second"})
|
|
|
|
def _filter_input(payload: Any) -> ModelInputData:
|
|
return ModelInputData(
|
|
input=[payload.model_data.input[0]],
|
|
instructions=payload.model_data.instructions,
|
|
)
|
|
|
|
run_config = RunConfig(call_model_input_filter=_filter_input)
|
|
|
|
streamed_result = RunResultStreaming(
|
|
input=[item_1, item_2],
|
|
new_items=[],
|
|
raw_responses=[],
|
|
final_output=None,
|
|
input_guardrail_results=[],
|
|
output_guardrail_results=[],
|
|
tool_input_guardrail_results=[],
|
|
tool_output_guardrail_results=[],
|
|
context_wrapper=context_wrapper,
|
|
current_agent=agent,
|
|
current_turn=0,
|
|
max_turns=1,
|
|
_current_agent_output_schema=None,
|
|
trace=None,
|
|
interruptions=[],
|
|
)
|
|
|
|
await run_single_turn_streamed(
|
|
streamed_result,
|
|
bind_public_agent(agent),
|
|
RunHooks(),
|
|
context_wrapper,
|
|
run_config,
|
|
should_run_agent_start_hooks=False,
|
|
tool_use_tracker=tool_use_tracker,
|
|
all_tools=[],
|
|
server_conversation_tracker=tracker,
|
|
)
|
|
|
|
assert model.last_turn_args["input"] == [item_1]
|
|
assert tracker.remaining_initial_input == [item_2]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_single_turn_streamed_seeds_hosted_mcp_metadata_from_pre_step_items() -> None:
|
|
model = FakeModel()
|
|
mcp_call = McpCall(
|
|
id="mcp_call_1",
|
|
arguments="{}",
|
|
name="search_docs",
|
|
server_label="docs_server",
|
|
type="mcp_call",
|
|
status="completed",
|
|
)
|
|
model.set_next_output([mcp_call])
|
|
agent = Agent(name="test", model=model)
|
|
hosted_tool = HostedMCPTool(
|
|
tool_config=cast(
|
|
Any,
|
|
{
|
|
"type": "mcp",
|
|
"server_label": "docs_server",
|
|
"server_url": "https://example.com/mcp",
|
|
},
|
|
)
|
|
)
|
|
context_wrapper: RunContextWrapper[dict[str, Any]] = RunContextWrapper(context={})
|
|
tool_use_tracker = AgentToolUseTracker()
|
|
|
|
item_1: TResponseInputItem = cast(TResponseInputItem, {"role": "user", "content": "first"})
|
|
pre_step_item = MCPListToolsItem(
|
|
agent=agent,
|
|
raw_item=_make_hosted_mcp_list_tools("docs_server", "search_docs"),
|
|
)
|
|
|
|
def _filter_input(payload: Any) -> ModelInputData:
|
|
return ModelInputData(
|
|
input=[payload.model_data.input[0]],
|
|
instructions=payload.model_data.instructions,
|
|
)
|
|
|
|
run_config = RunConfig(call_model_input_filter=_filter_input)
|
|
|
|
streamed_result = RunResultStreaming(
|
|
input=[item_1],
|
|
new_items=[],
|
|
raw_responses=[],
|
|
final_output=None,
|
|
input_guardrail_results=[],
|
|
output_guardrail_results=[],
|
|
tool_input_guardrail_results=[],
|
|
tool_output_guardrail_results=[],
|
|
context_wrapper=context_wrapper,
|
|
current_agent=agent,
|
|
current_turn=1,
|
|
max_turns=2,
|
|
_current_agent_output_schema=None,
|
|
trace=None,
|
|
interruptions=[],
|
|
)
|
|
streamed_result._model_input_items = [pre_step_item]
|
|
|
|
await run_single_turn_streamed(
|
|
streamed_result,
|
|
bind_public_agent(agent),
|
|
RunHooks(),
|
|
context_wrapper,
|
|
run_config,
|
|
should_run_agent_start_hooks=False,
|
|
tool_use_tracker=tool_use_tracker,
|
|
all_tools=[hosted_tool],
|
|
)
|
|
|
|
assert model.last_turn_args["input"] == [item_1]
|
|
|
|
tool_call_events: list[ToolCallItem] = []
|
|
while not streamed_result._event_queue.empty():
|
|
queued_event = streamed_result._event_queue.get_nowait()
|
|
streamed_result._event_queue.task_done()
|
|
if (
|
|
isinstance(queued_event, RunItemStreamEvent)
|
|
and queued_event.name == "tool_called"
|
|
and isinstance(queued_event.item, ToolCallItem)
|
|
):
|
|
tool_call_events.append(queued_event.item)
|
|
|
|
assert len(tool_call_events) == 1
|
|
assert tool_call_events[0].description == "Search the docs."
|
|
assert tool_call_events[0].title == "Search Docs"
|
|
|
|
|
|
@pytest.mark.parametrize("stale_collection_name", ["sent_items", "server_items"])
|
|
def test_prepare_input_keeps_fresh_tool_output_when_stale_identity_matches(
|
|
stale_collection_name: str,
|
|
) -> None:
|
|
"""Tracked object identity must not become a stale address-based dedupe key."""
|
|
tracker = OpenAIServerConversationTracker(previous_response_id="resp-1")
|
|
|
|
output_raw_item: dict[str, Any] = {
|
|
"type": "function_call_output",
|
|
"call_id": "call_FRESH",
|
|
"output": "42",
|
|
}
|
|
tracked_items = getattr(tracker, stale_collection_name)
|
|
if isinstance(tracked_items, set):
|
|
tracked_items.add(id(output_raw_item))
|
|
else:
|
|
old_item = {"type": "message", "content": "already tracked"}
|
|
tracked_items.append(old_item)
|
|
|
|
generated_items = [DummyRunItem(output_raw_item, type="function_call_output_item")]
|
|
|
|
prepared = tracker.prepare_input(
|
|
original_input=[],
|
|
generated_items=cast(list[Any], generated_items),
|
|
)
|
|
|
|
prepared_output_call_ids = [
|
|
item.get("call_id")
|
|
for item in prepared
|
|
if isinstance(item, dict) and item.get("type") == "function_call_output"
|
|
]
|
|
assert "call_FRESH" in prepared_output_call_ids
|
|
|
|
|
|
def test_prepare_input_dedupes_same_delivered_tool_output_object() -> None:
|
|
"""Identity dedupe still skips the exact source object after it is delivered."""
|
|
tracker = OpenAIServerConversationTracker(previous_response_id="resp-1")
|
|
|
|
output_raw_item: dict[str, Any] = {
|
|
"type": "function_call_output",
|
|
"call_id": "call_X",
|
|
"output": "42",
|
|
}
|
|
generated_items = [DummyRunItem(output_raw_item, type="function_call_output_item")]
|
|
|
|
first = tracker.prepare_input(
|
|
original_input=[],
|
|
generated_items=cast(list[Any], generated_items),
|
|
)
|
|
assert any(isinstance(item, dict) and item.get("call_id") == "call_X" for item in first)
|
|
|
|
tracker.mark_input_as_sent(first)
|
|
assert any(item is output_raw_item for item in tracker.sent_items)
|
|
|
|
second = tracker.prepare_input(
|
|
original_input=[],
|
|
generated_items=cast(list[Any], generated_items),
|
|
)
|
|
assert all(not (isinstance(item, dict) and item.get("call_id") == "call_X") for item in second)
|