diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index e1686b071a4..d7b7e0735cb 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -2483,7 +2483,15 @@ def _shell_item_to_contents(self, item: Any, local_shell_tool_name: str | None) ) shell_timeout_ms = getattr(action, "timeout_ms", None) shell_max_output = getattr(action, "max_output_length", None) - is_local_environment = getattr(getattr(item, "environment", None), "type", None) == "local" + item_environment: object = getattr(item, "environment", None) + env_type: object = ( + cast("Mapping[str, object]", item_environment).get("type") + if isinstance(item_environment, Mapping) + else getattr(item_environment, "type", None) + ) + is_local_environment: bool = bool( + env_type == "local" or (item_environment is None and local_shell_tool_name is not None) + ) if ( local_shell_tool_name and is_local_environment diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 3a26012111b..fcb31c961b3 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -2828,11 +2828,14 @@ def local_exec(command: str) -> str: mock_action.timeout_ms = 60000 mock_action.max_output_length = 4096 + mock_environment = MagicMock() + mock_environment.type = "container_reference" + mock_shell_call = MagicMock() mock_shell_call.type = "shell_call" mock_shell_call.call_id = "shell-call-1" mock_shell_call.action = mock_action - mock_shell_call.environment = None + mock_shell_call.environment = mock_environment mock_shell_call.status = "completed" mock_response.output = [mock_shell_call] @@ -2849,6 +2852,89 @@ def local_exec(command: str) -> str: assert call_content.status == "completed" +def test_foundry_shell_call_without_environment_uses_registered_local_executor() -> None: + """An unmarked shell call executes locally when a local shell executor is registered.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + def local_exec(command: str) -> str: + return command + + local_shell_tool = OpenAIChatClient.get_shell_tool(func=local_exec) + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-id" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.status = "completed" + mock_response.incomplete = None + + mock_action = MagicMock() + mock_action.commands = ["ls -la", "pwd"] + mock_action.timeout_ms = 60000 + mock_action.max_output_length = 4096 + + mock_shell_call = MagicMock() + mock_shell_call.type = "shell_call" + mock_shell_call.id = "shell-item-1" + mock_shell_call.call_id = "shell-call-1" + mock_shell_call.action = mock_action + mock_shell_call.environment = None + mock_shell_call.status = "completed" + + mock_response.output = [mock_shell_call] + + response = client._parse_response_from_openai(mock_response, options={"tools": [local_shell_tool]}) # type: ignore[arg-type] + + assert len(response.messages[0].contents) == 1 + call_content = response.messages[0].contents[0] + assert call_content.type == "function_call" + assert call_content.call_id == "shell-call-1" + assert call_content.name == local_shell_tool.name + assert call_content.parse_arguments() == {"command": "ls -la\npwd"} + assert call_content.informational_only is False + + +def test_foundry_shell_call_without_environment_without_local_executor_remains_hosted() -> None: + """An unmarked shell call remains hosted informational content when no local executor is registered.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-id" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.status = "completed" + mock_response.incomplete = None + + mock_action = MagicMock() + mock_action.commands = ["ls -la", "pwd"] + mock_action.timeout_ms = 60000 + mock_action.max_output_length = 4096 + + mock_shell_call = MagicMock() + mock_shell_call.type = "shell_call" + mock_shell_call.id = "shell-item-1" + mock_shell_call.call_id = "shell-call-1" + mock_shell_call.action = mock_action + mock_shell_call.environment = None + mock_shell_call.status = "completed" + + mock_response.output = [mock_shell_call] + + response = client._parse_response_from_openai(mock_response, options={}) # type: ignore[arg-type] + + assert len(response.messages[0].contents) == 1 + call_content = response.messages[0].contents[0] + assert call_content.type == "shell_tool_call" + assert call_content.call_id == "shell-call-1" + assert call_content.commands == ["ls -la", "pwd"] + + def test_response_content_creation_with_shell_call_output() -> None: """Test _parse_response_from_openai with shell_call_output output.""" client = OpenAIChatClient(model="test-model", api_key="test-key") @@ -4243,6 +4329,45 @@ def local_exec(command: str) -> str: assert call_content.additional_properties["openai.responses.shell.output_type"] == "shell_call_output" +def test_parse_chunk_from_openai_shell_call_without_environment_emits_command() -> None: + """An unmarked completed shell call emits an executable function call when local executor is registered.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + def local_exec(command: str) -> str: + return command + + local_shell_tool = OpenAIChatClient.get_shell_tool(func=local_exec, approval_mode="never_require") + function_call_ids: dict[int, tuple[str, str]] = {} + + mock_action = MagicMock() + mock_action.commands = ["python --version"] + mock_action.timeout_ms = 30000 + + mock_item = MagicMock() + mock_item.type = "shell_call" + mock_item.id = "local-shell-item-1" + mock_item.call_id = "local-shell-call-1" + mock_item.action = mock_action + mock_item.environment = None + mock_item.status = "completed" + + mock_event = MagicMock() + mock_event.type = "response.output_item.done" + mock_event.item = mock_item + + update = client._parse_chunk_from_openai( + mock_event, options={"tools": [local_shell_tool]}, function_call_ids=function_call_ids + ) + + assert len(update.contents) == 1 + call_content = update.contents[0] + assert call_content.type == "function_call" + assert call_content.call_id == "local-shell-call-1" + assert call_content.name == local_shell_tool.name + assert call_content.parse_arguments() == {"command": "python --version"} + assert call_content.additional_properties["openai.responses.shell.output_type"] == "shell_call_output" + + def test_parse_chunk_from_openai_shell_call_output_added_defers_result() -> None: """An in-progress shell_call_output on output_item.added must emit nothing.