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 )