|
5 | 5 | from typing import Any, Callable, Optional, cast |
6 | 6 |
|
7 | 7 | import pytest |
8 | | -from openai.types.responses import ResponseComputerToolCall |
| 8 | +from openai.types.responses import ResponseComputerToolCall, ResponseFunctionToolCall |
9 | 9 | from openai.types.responses.response_computer_tool_call import ActionScreenshot |
10 | 10 | from openai.types.responses.response_input_param import ( |
11 | 11 | ComputerCallOutput, |
@@ -842,6 +842,122 @@ def approve_me(reason: Optional[str] = None) -> str: # noqa: UP007 |
842 | 842 | assert "call-rebuild-1" in executed_call_ids, "Function should be rebuilt and executed" |
843 | 843 |
|
844 | 844 |
|
| 845 | +@pytest.mark.asyncio |
| 846 | +async def test_resume_rebuilds_function_runs_from_object_approvals() -> None: |
| 847 | + """Rebuild should handle ResponseFunctionToolCall approval items.""" |
| 848 | + |
| 849 | + @function_tool(needs_approval=True) |
| 850 | + def approve_me(reason: Optional[str] = None) -> str: # noqa: UP007 |
| 851 | + return f"approved:{reason}" if reason else "approved" |
| 852 | + |
| 853 | + model, agent = make_model_and_agent(tools=[approve_me]) |
| 854 | + tool_call = make_function_tool_call( |
| 855 | + approve_me.name, |
| 856 | + call_id="call-rebuild-obj", |
| 857 | + arguments='{"reason": "ok"}', |
| 858 | + ) |
| 859 | + assert isinstance(tool_call, ResponseFunctionToolCall) |
| 860 | + approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call) |
| 861 | + context_wrapper = make_context_wrapper() |
| 862 | + context_wrapper.approve_tool(approval_item) |
| 863 | + |
| 864 | + run_state = make_state_with_interruptions(agent, [approval_item]) |
| 865 | + processed_response = ProcessedResponse( |
| 866 | + new_items=[], |
| 867 | + handoffs=[], |
| 868 | + functions=[], |
| 869 | + computer_actions=[], |
| 870 | + local_shell_calls=[], |
| 871 | + shell_calls=[], |
| 872 | + apply_patch_calls=[], |
| 873 | + tools_used=[], |
| 874 | + mcp_approval_requests=[], |
| 875 | + interruptions=[], |
| 876 | + ) |
| 877 | + |
| 878 | + result = await run_loop.resolve_interrupted_turn( |
| 879 | + agent=agent, |
| 880 | + original_input="resume approvals", |
| 881 | + original_pre_step_items=[], |
| 882 | + new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"), |
| 883 | + processed_response=processed_response, |
| 884 | + hooks=RunHooks(), |
| 885 | + context_wrapper=context_wrapper, |
| 886 | + run_config=RunConfig(), |
| 887 | + run_state=run_state, |
| 888 | + ) |
| 889 | + |
| 890 | + assert not isinstance(result.next_step, NextStepInterruption) |
| 891 | + executed_call_ids = { |
| 892 | + extract_tool_call_id(item.raw_item) |
| 893 | + for item in result.new_step_items |
| 894 | + if isinstance(item, ToolCallOutputItem) |
| 895 | + } |
| 896 | + assert "call-rebuild-obj" in executed_call_ids, ( |
| 897 | + "Function should be rebuilt from ResponseFunctionToolCall approval" |
| 898 | + ) |
| 899 | + |
| 900 | + |
| 901 | +@pytest.mark.asyncio |
| 902 | +async def test_rebuild_function_runs_handles_object_pending_and_rejections() -> None: |
| 903 | + """Rebuild should surface pending approvals and emit rejections for object approvals.""" |
| 904 | + |
| 905 | + @function_tool(needs_approval=True) |
| 906 | + def reject_me(text: str = "nope") -> str: |
| 907 | + return text |
| 908 | + |
| 909 | + @function_tool(needs_approval=True) |
| 910 | + def pending_me(text: str = "wait") -> str: |
| 911 | + return text |
| 912 | + |
| 913 | + _model, agent = make_model_and_agent(tools=[reject_me, pending_me]) |
| 914 | + context_wrapper = make_context_wrapper() |
| 915 | + |
| 916 | + rejected_call = make_function_tool_call(reject_me.name, call_id="obj-reject") |
| 917 | + pending_call = make_function_tool_call(pending_me.name, call_id="obj-pending") |
| 918 | + assert isinstance(rejected_call, ResponseFunctionToolCall) |
| 919 | + assert isinstance(pending_call, ResponseFunctionToolCall) |
| 920 | + |
| 921 | + rejected_item = ToolApprovalItem(agent=agent, raw_item=rejected_call) |
| 922 | + pending_item = ToolApprovalItem(agent=agent, raw_item=pending_call) |
| 923 | + context_wrapper.reject_tool(rejected_item) |
| 924 | + |
| 925 | + run_state = make_state_with_interruptions(agent, [rejected_item, pending_item]) |
| 926 | + processed_response = ProcessedResponse( |
| 927 | + new_items=[], |
| 928 | + handoffs=[], |
| 929 | + functions=[], |
| 930 | + computer_actions=[], |
| 931 | + local_shell_calls=[], |
| 932 | + shell_calls=[], |
| 933 | + apply_patch_calls=[], |
| 934 | + tools_used=[], |
| 935 | + mcp_approval_requests=[], |
| 936 | + interruptions=[], |
| 937 | + ) |
| 938 | + |
| 939 | + result = await run_loop.resolve_interrupted_turn( |
| 940 | + agent=agent, |
| 941 | + original_input="resume approvals", |
| 942 | + original_pre_step_items=[], |
| 943 | + new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"), |
| 944 | + processed_response=processed_response, |
| 945 | + hooks=RunHooks(), |
| 946 | + context_wrapper=context_wrapper, |
| 947 | + run_config=RunConfig(), |
| 948 | + run_state=run_state, |
| 949 | + ) |
| 950 | + |
| 951 | + assert isinstance(result.next_step, NextStepInterruption) |
| 952 | + assert pending_item in result.next_step.interruptions |
| 953 | + rejection_outputs = [ |
| 954 | + item |
| 955 | + for item in result.new_step_items |
| 956 | + if isinstance(item, ToolCallOutputItem) and item.output == HITL_REJECTION_MSG |
| 957 | + ] |
| 958 | + assert rejection_outputs, "Rejected function call should emit rejection output" |
| 959 | + |
| 960 | + |
845 | 961 | @pytest.mark.asyncio |
846 | 962 | async def test_resume_skips_non_hitl_function_calls() -> None: |
847 | 963 | """Non-HITL function calls should not re-run when resuming unrelated approvals.""" |
|
0 commit comments