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

449 lines
15 KiB
Python

import json
from typing import Any, cast
import pytest
from openai.types.responses import ResponseFunctionToolCall, ResponseOutputMessage
import agents.run as run_module
from agents import Agent, Runner, function_tool
from agents.agent import ToolsToFinalOutputResult
from agents.items import (
MessageOutputItem,
ModelResponse,
ToolApprovalItem,
ToolCallItem,
ToolCallOutputItem,
)
from agents.lifecycle import RunHooks
from agents.run import RunConfig
from agents.run_context import RunContextWrapper
from agents.run_internal import run_loop, turn_resolution
from agents.run_internal.agent_bindings import bind_public_agent
from agents.run_internal.run_loop import (
NextStepFinalOutput,
NextStepInterruption,
NextStepRunAgain,
ProcessedResponse,
SingleStepResult,
)
from agents.run_state import RunState
from agents.usage import Usage
from tests.fake_model import FakeModel
from tests.test_responses import get_function_tool_call, get_text_message
from tests.utils.hitl import (
make_agent,
make_context_wrapper,
make_model_and_agent,
queue_function_call_and_text,
)
from tests.utils.simple_session import SimpleListSession
@pytest.mark.asyncio
async def test_resolve_interrupted_turn_final_output_short_circuit(monkeypatch) -> None:
agent: Agent[dict[str, str]] = make_agent(model=FakeModel())
context_wrapper = make_context_wrapper()
async def fake_execute_tool_plan(*_: object, **__: object):
return [], [], [], [], [], [], [], []
async def fake_check_for_final_output_from_tools(*_: object, **__: object):
return ToolsToFinalOutputResult(is_final_output=True, final_output="done")
async def fake_execute_final_output(
*,
original_input,
new_response,
pre_step_items,
new_step_items,
final_output,
tool_input_guardrail_results,
tool_output_guardrail_results,
**__: object,
) -> SingleStepResult:
return SingleStepResult(
original_input=original_input,
model_response=new_response,
pre_step_items=pre_step_items,
new_step_items=new_step_items,
next_step=NextStepFinalOutput(final_output),
tool_input_guardrail_results=tool_input_guardrail_results,
tool_output_guardrail_results=tool_output_guardrail_results,
)
monkeypatch.setattr(
turn_resolution, "check_for_final_output_from_tools", fake_check_for_final_output_from_tools
)
monkeypatch.setattr(turn_resolution, "execute_final_output", fake_execute_final_output)
monkeypatch.setattr(turn_resolution, "_execute_tool_plan", fake_execute_tool_plan)
processed_response = ProcessedResponse(
new_items=[],
handoffs=[],
functions=[],
computer_actions=[],
local_shell_calls=[],
shell_calls=[],
apply_patch_calls=[],
tools_used=[],
mcp_approval_requests=[],
interruptions=[],
)
result = await run_loop.resolve_interrupted_turn(
bindings=bind_public_agent(agent),
original_input="input",
original_pre_step_items=[],
new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"),
processed_response=processed_response,
hooks=RunHooks(),
context_wrapper=context_wrapper,
run_config=RunConfig(),
run_state=None,
)
assert isinstance(result, SingleStepResult)
assert isinstance(result.next_step, NextStepFinalOutput)
assert result.next_step.output == "done"
@pytest.mark.asyncio
async def test_resumed_session_persistence_uses_saved_count(monkeypatch) -> None:
agent = Agent(name="resume-agent")
context_wrapper: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={})
state = RunState(
context=context_wrapper,
original_input="input",
starting_agent=agent,
max_turns=1,
)
session = SimpleListSession()
raw_output = {"type": "function_call_output", "call_id": "call-1", "output": "ok"}
item_1 = ToolCallOutputItem(agent=agent, raw_item=raw_output, output="ok")
item_2 = ToolCallOutputItem(agent=agent, raw_item=dict(raw_output), output="ok")
step = SingleStepResult(
original_input="input",
model_response=ModelResponse(output=[], usage=Usage(), response_id="resp"),
pre_step_items=[],
new_step_items=[item_1, item_2],
next_step=NextStepFinalOutput("done"),
tool_input_guardrail_results=[],
tool_output_guardrail_results=[],
)
async def fake_run_single_turn(**_kwargs):
return step
monkeypatch.setattr(run_module, "run_single_turn", fake_run_single_turn)
runner = run_module.AgentRunner()
await runner.run(agent, state, session=session, run_config=RunConfig())
assert state._current_turn_persisted_item_count == 1
assert len(session.saved_items) == 1
@pytest.mark.asyncio
async def test_resumed_run_again_resets_persisted_count(monkeypatch) -> None:
agent = Agent(name="resume-agent")
context_wrapper: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={})
state = RunState(
context=context_wrapper,
original_input="input",
starting_agent=agent,
max_turns=2,
)
session = SimpleListSession()
state._current_step = NextStepInterruption(interruptions=[])
state._model_responses = [
ModelResponse(output=[], usage=Usage(), response_id="resp_1"),
]
state._last_processed_response = ProcessedResponse(
new_items=[],
handoffs=[],
functions=[],
computer_actions=[],
local_shell_calls=[],
shell_calls=[],
apply_patch_calls=[],
tools_used=[],
mcp_approval_requests=[],
interruptions=[],
)
state._current_turn_persisted_item_count = 1
async def fake_resolve_interrupted_turn(**_kwargs):
return SingleStepResult(
original_input="input",
model_response=ModelResponse(output=[], usage=Usage(), response_id="resp_resume"),
pre_step_items=[],
new_step_items=[],
next_step=NextStepRunAgain(),
tool_input_guardrail_results=[],
tool_output_guardrail_results=[],
)
async def fake_run_single_turn(**_kwargs):
tool_call = cast(
ResponseFunctionToolCall,
get_function_tool_call("test_tool", "{}", call_id="call-1"),
)
tool_call_item = ToolCallItem(agent=agent, raw_item=tool_call)
tool_output_item = ToolCallOutputItem(
agent=agent,
raw_item={
"type": "function_call_output",
"call_id": "call-1",
"output": "ok",
},
output="ok",
)
message_item = MessageOutputItem(
agent=agent,
raw_item=cast(ResponseOutputMessage, get_text_message("final")),
)
return SingleStepResult(
original_input="input",
model_response=ModelResponse(
output=[get_text_message("final")],
usage=Usage(),
response_id="resp_final",
),
pre_step_items=[],
new_step_items=[tool_call_item, tool_output_item, message_item],
next_step=NextStepFinalOutput("done"),
tool_input_guardrail_results=[],
tool_output_guardrail_results=[],
)
monkeypatch.setattr(run_module, "resolve_interrupted_turn", fake_resolve_interrupted_turn)
monkeypatch.setattr(run_module, "run_single_turn", fake_run_single_turn)
runner = run_module.AgentRunner()
result = await runner.run(agent, state, session=session, run_config=RunConfig())
assert result.final_output == "done"
saved_types = [
item.get("type") if isinstance(item, dict) else getattr(item, "type", None)
for item in session.saved_items
]
assert "function_call" in saved_types
@pytest.mark.parametrize(
("conversation_id", "previous_response_id", "auto_previous_response_id"),
[
("conv_1", None, False),
(None, "resp_prev", False),
(None, None, True),
],
)
@pytest.mark.asyncio
async def test_resumed_interruption_passes_server_managed_conversation_flag(
monkeypatch: pytest.MonkeyPatch,
conversation_id: str | None,
previous_response_id: str | None,
auto_previous_response_id: bool,
) -> None:
agent = Agent(name="resume-agent")
context_wrapper: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={})
state = RunState(
context=context_wrapper,
original_input="input",
starting_agent=agent,
max_turns=1,
conversation_id=conversation_id,
previous_response_id=previous_response_id,
auto_previous_response_id=auto_previous_response_id,
)
state._current_step = NextStepInterruption(interruptions=[])
state._model_responses = [
ModelResponse(output=[], usage=Usage(), response_id="resp_1"),
]
state._last_processed_response = ProcessedResponse(
new_items=[],
handoffs=[],
functions=[],
computer_actions=[],
local_shell_calls=[],
shell_calls=[],
apply_patch_calls=[],
tools_used=[],
mcp_approval_requests=[],
interruptions=[],
)
server_managed_values: list[bool] = []
async def fake_resolve_interrupted_turn(**kwargs: object) -> SingleStepResult:
server_managed_values.append(cast(bool, kwargs["server_manages_conversation"]))
return SingleStepResult(
original_input="input",
model_response=ModelResponse(output=[], usage=Usage(), response_id="resp_resume"),
pre_step_items=[],
new_step_items=[],
next_step=NextStepFinalOutput("done"),
tool_input_guardrail_results=[],
tool_output_guardrail_results=[],
)
monkeypatch.setattr(run_module, "resolve_interrupted_turn", fake_resolve_interrupted_turn)
runner = run_module.AgentRunner()
result = await runner.run(agent, state, run_config=RunConfig())
assert result.final_output == "done"
assert server_managed_values == [True]
@pytest.mark.asyncio
async def test_resumed_approval_does_not_duplicate_session_items() -> None:
async def test_tool() -> str:
return "tool_result"
tool = function_tool(test_tool, name_override="test_tool", needs_approval=True)
model, agent = make_model_and_agent(name="test", tools=[tool])
session = SimpleListSession()
queue_function_call_and_text(
model,
get_function_tool_call("test_tool", json.dumps({}), call_id="call-resume"),
followup=[get_text_message("done")],
)
first = await Runner.run(agent, input="Use test_tool", session=session)
assert first.interruptions
state = first.to_state()
state.approve(first.interruptions[0])
resumed = await Runner.run(agent, state, session=session)
assert resumed.final_output == "done"
saved_items = await session.get_items()
call_count = sum(
1
for item in saved_items
if isinstance(item, dict)
and item.get("type") == "function_call"
and item.get("call_id") == "call-resume"
)
output_count = sum(
1
for item in saved_items
if isinstance(item, dict)
and item.get("type") == "function_call_output"
and item.get("call_id") == "call-resume"
)
assert call_count == 1
assert output_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
("schema_version", "expect_execution"),
[("1.6", True), ("1.7", False)],
)
async def test_resolve_interrupted_turn_only_uses_name_fallback_for_legacy_approval_agents(
schema_version: str,
expect_execution: bool,
) -> None:
calls: list[str] = []
@function_tool(name_override="needs_ok", needs_approval=True)
async def needs_ok(text: str) -> str:
calls.append(text)
return text
base_duplicate = Agent(name="duplicate", instructions="alpha", tools=[needs_ok])
resumed_duplicate = Agent(name="duplicate", instructions="zeta", tools=[needs_ok])
root = Agent(name="triage", handoffs=[base_duplicate, resumed_duplicate])
base_duplicate.handoffs = [root]
resumed_duplicate.handoffs = [root]
state: RunState[dict[str, str], Agent[Any]] = RunState(
context=RunContextWrapper(context={}),
original_input="input",
starting_agent=root,
max_turns=2,
)
state._current_agent = resumed_duplicate
state._current_step = NextStepInterruption(
interruptions=[
ToolApprovalItem(
agent=resumed_duplicate,
raw_item=cast(
ResponseFunctionToolCall,
get_function_tool_call(
"needs_ok",
json.dumps({"text": "one"}),
call_id="legacy-call",
),
),
)
]
)
state._last_processed_response = ProcessedResponse(
new_items=[],
handoffs=[],
functions=[],
computer_actions=[],
local_shell_calls=[],
shell_calls=[],
apply_patch_calls=[],
tools_used=[],
mcp_approval_requests=[],
interruptions=[],
)
state._model_responses = [ModelResponse(output=[], usage=Usage(), response_id="resp")]
json_data = state.to_json()
current_agent_data = cast(dict[str, str], json_data["current_agent"])
assert current_agent_data["name"] == "duplicate"
assert "identity" in current_agent_data
interruption_data = cast(
dict[str, object],
json_data["current_step"]["data"]["interruptions"][0],
)
interruption_agent_data = cast(dict[str, str], interruption_data["agent"])
assert interruption_agent_data["identity"] == current_agent_data["identity"]
interruption_agent_data.pop("identity")
json_data["$schemaVersion"] = schema_version
restored = await RunState.from_json(root, json_data)
assert restored._schema_version == schema_version
assert restored._current_agent is resumed_duplicate
restored_approval = restored.get_interruptions()[0]
restored.approve(restored_approval)
assert restored._context is not None
assert restored._last_processed_response is not None
result = await turn_resolution.resolve_interrupted_turn(
bindings=bind_public_agent(cast(Agent[dict[str, str]], restored._current_agent)),
original_input=restored._original_input,
original_pre_step_items=restored._generated_items,
new_response=restored._model_responses[-1],
processed_response=restored._last_processed_response,
hooks=RunHooks(),
context_wrapper=restored._context,
run_config=RunConfig(),
run_state=restored,
)
if expect_execution:
assert isinstance(result.next_step, NextStepRunAgain)
assert calls == ["one"]
assert any(
isinstance(item, ToolCallOutputItem) and item.output == "one"
for item in result.new_step_items
)
else:
assert calls == []
assert not any(
isinstance(item, ToolCallOutputItem) and item.output == "one"
for item in result.new_step_items
)