diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index 5f904cf217..d75628cbab 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -1039,7 +1039,7 @@ async def handle( yield event for event in self._open_function_call(content): yield event - args_str = _arguments_to_str(content.arguments) + args_str = _json_safe_to_str(content.arguments) self._accumulated.append(args_str) if self._fc_builder is not None: yield self._fc_builder.emit_arguments_delta(args_str) @@ -1049,7 +1049,7 @@ async def handle( yield event async for event in self._stream.output_item_function_call_output( content.call_id, # type: ignore[arg-type] - str(content.result or ""), + _json_safe_to_str(content.result), ): yield event if content.call_id is not None: @@ -1062,7 +1062,7 @@ async def handle( yield event for event in self._open_mcp_call(content): yield event - args_str = _arguments_to_str(content.arguments) + args_str = _json_safe_to_str(content.arguments) self._accumulated.append(args_str) if self._mcp_builder is not None: yield self._mcp_builder.emit_arguments_delta(args_str) @@ -1084,15 +1084,6 @@ async def handle( self._accumulated.clear() return - elif content.type == "function_result": - for event in self._close(): - yield event - async for event in self._stream.output_item_function_call_output( - content.call_id, # type: ignore[arg-type] - str(content.result or ""), - ): - yield event - elif content.type == "image_generation_tool_result" and content.outputs is not None: for event in self._close(): yield event @@ -1109,7 +1100,7 @@ async def handle( item_id=content.call_id, ) yield mcp_call.emit_added() - async for event in mcp_call.arguments(_arguments_to_str(content.arguments)): + async for event in mcp_call.arguments(_json_safe_to_str(content.arguments)): yield event yield mcp_call.emit_completed() yield mcp_call.emit_done() @@ -1118,20 +1109,18 @@ async def handle( # Reached when there's no correlated in-progress mcp_server_tool_call to close against. for event in self._close(): yield event - output = ( - content.output - if isinstance(content.output, str) - else str(content.output) - if content.output is not None - else "" - ) + output = _stringify_mcp_output(content.output) async for event in self._stream.output_item_custom_tool_call_output(content.call_id or "", output): yield event elif content.type == "shell_tool_call": for event in self._close(): yield event - action = FunctionShellAction(commands=content.commands or [], timeout_ms=0, max_output_length=0) + action = FunctionShellAction( + commands=content.commands or [], + timeout_ms=content.timeout_ms, + max_output_length=content.max_output_length, + ) async for event in self._stream.output_item_function_shell_call( content.call_id or "", action, @@ -1174,7 +1163,7 @@ async def handle( async for event in self._stream.output_item_mcp_approval_request( server_label, function_call.name, # type: ignore - _arguments_to_str(function_call.arguments), + _json_safe_to_str(function_call.arguments), ): if approval_storage is not None and not request_saved: # Extract the approval request ID generated by the infrastructure when the @@ -1383,13 +1372,19 @@ def _reasoning_item_to_contents(reasoning: ItemReasoningItem | OutputItemReasoni return [Content.from_text_reasoning(id=reasoning["id"], protected_data=encrypted_content)] -async def _item_to_message(item: Item, *, approval_storage: FunctionApprovalStore | None = None) -> Message: +async def _item_to_message( + item: Item, + *, + approval_storage: FunctionApprovalStore | None = None, + _item_type_name: Literal["Item", "OutputItem"] = "Item", +) -> Message: """Converts an Item to a Message. Args: item: The Item to convert. approval_storage: An optional ApprovalStorage instance used to look up approval requests when converting MCP approval response items. + _item_type_name: The item type name to include in unsupported-type errors. Returns: The converted Message. @@ -1418,13 +1413,12 @@ async def _item_to_message(item: Item, *, approval_storage: FunctionApprovalStor ) if item["type"] == "function_call_output": - output = item["output"] if isinstance(item["output"], str) else str(item["output"]) call_id = item.get("call_id") if call_id is None: raise ValueError("Function call output item is missing a call_id.") return Message( role="tool", - contents=[Content.from_function_result(call_id, result=output)], + contents=[Content.from_function_result(call_id, result=_json_safe_to_str(item["output"]))], ) if item["type"] == "reasoning": @@ -1487,6 +1481,8 @@ async def _item_to_message(item: Item, *, approval_storage: FunctionApprovalStor Content.from_shell_tool_call( call_id=item["call_id"], commands=item["action"]["commands"], + timeout_ms=item["action"].get("timeout_ms"), + max_output_length=item["action"].get("max_output_length"), status=str(item.get("status")), ) ], @@ -1520,6 +1516,7 @@ async def _item_to_message(item: Item, *, approval_storage: FunctionApprovalStor Content.from_shell_tool_call( call_id=item["call_id"], commands=commands, + timeout_ms=item["action"].get("timeout_ms"), status=str(item["status"]), ) ], @@ -1543,7 +1540,7 @@ async def _item_to_message(item: Item, *, approval_storage: FunctionApprovalStor Content.from_function_call( item["id"], "file_search", - arguments=json.dumps({"queries": item["queries"]}), + arguments=_json_safe_to_str({"queries": item["queries"]}), informational_only=True, ) ], @@ -1562,7 +1559,7 @@ async def _item_to_message(item: Item, *, approval_storage: FunctionApprovalStor Content.from_function_call( item["call_id"], "computer_use", - arguments=str(item.get("action")), + arguments=_json_safe_to_str(item.get("action")), informational_only=True, ) ], @@ -1571,7 +1568,7 @@ async def _item_to_message(item: Item, *, approval_storage: FunctionApprovalStor if item["type"] == "computer_call_output": return Message( role="tool", - contents=[Content.from_function_result(item["call_id"], result=str(item["output"]))], + contents=[Content.from_function_result(item["call_id"], result=_json_safe_to_str(item["output"]))], ) if item["type"] == "custom_tool_call": @@ -1588,7 +1585,7 @@ async def _item_to_message(item: Item, *, approval_storage: FunctionApprovalStor ) if item["type"] == "custom_tool_call_output": - output = item["output"] if isinstance(item["output"], str) else str(item["output"]) + output = _json_safe_to_str(item["output"]) # Hosted-MCP results land here because the host writes them via # `aoutput_item_custom_tool_call_output` (see `_OutputItemTracker.handle` for # `mcp_server_tool_result`). The persisted `call_id` keeps its @@ -1613,7 +1610,7 @@ async def _item_to_message(item: Item, *, approval_storage: FunctionApprovalStor Content.from_function_call( item["call_id"], "apply_patch", - arguments=str(item["operation"]), + arguments=_json_safe_to_str(item["operation"]), informational_only=True, ) ], @@ -1622,10 +1619,10 @@ async def _item_to_message(item: Item, *, approval_storage: FunctionApprovalStor if item["type"] == "apply_patch_call_output": return Message( role="tool", - contents=[Content.from_function_result(item["call_id"], result=item.get("output") or "")], + contents=[Content.from_function_result(item["call_id"], result=_json_safe_to_str(item.get("output")))], ) - raise ValueError(f"Unsupported Item type: {item['type']}") + raise ValueError(f"Unsupported {_item_type_name} type: {item['type']}") async def _output_items_to_messages( @@ -1665,231 +1662,6 @@ async def _output_item_to_message( Raises: ValueError: If the OutputItem type is not supported. """ - if item["type"] == "output_message": - return Message(role=item["role"], contents=[_convert_output_message_content(part) for part in item["content"]]) - - if item["type"] == "message": - return Message(role=item["role"], contents=[_convert_message_content(part) for part in item["content"]]) - - if item["type"] == "function_call": - return Message( - role="assistant", - contents=[ - Content.from_function_call( - item["call_id"], - item["name"], - arguments=item["arguments"], - ) - ], - ) - - if item["type"] == "function_call_output": - output = item["output"] if isinstance(item["output"], str) else str(item["output"]) - call_id = item.get("call_id") - if call_id is None: - raise ValueError("Function call output item is missing a call_id.") - return Message( - role="tool", - contents=[Content.from_function_result(call_id, result=output)], - ) - - if item["type"] == "reasoning": - return Message(role="assistant", contents=_reasoning_item_to_contents(item)) - - if item["type"] == "mcp_call": - contents = [ - Content.from_mcp_server_tool_call( - item["id"], - item["name"], - server_name=item["server_label"], - arguments=item["arguments"], - ) - ] - if (output := item.get("output")) is not None: - contents.append(Content.from_mcp_server_tool_result(call_id=item["id"], output=output)) - return Message( - role="assistant", - contents=contents, - ) - - if item["type"] == "mcp_approval_request": - if approval_storage is not None: - function_approval_request_content = await approval_storage.load_approval_request(item["id"]) - else: - raise ValueError("ApprovalStorage is required to load approval request.") - return Message( - role="assistant", - contents=[function_approval_request_content], - ) - - if item["type"] == "mcp_approval_response": - if approval_storage is not None: - function_approval_request_content = await approval_storage.load_approval_request( - item["approval_request_id"] - ) - else: - raise ValueError("ApprovalStorage is required to load approval request.") - - return Message( - role="user", - contents=[function_approval_request_content.to_function_approval_response(item["approve"])], - ) - - if item["type"] == "code_interpreter_call": - return Message( - role="assistant", - contents=[Content.from_code_interpreter_tool_call(call_id=item["id"])], - ) - - if item["type"] == "image_generation_call": - return Message( - role="assistant", - contents=[Content.from_image_generation_tool_call(image_id=item["id"])], - ) - - if item["type"] == "shell_call": - return Message( - role="assistant", - contents=[ - Content.from_shell_tool_call( - call_id=item["call_id"], - commands=item["action"]["commands"], - status=str(item.get("status")), - ) - ], - ) - - if item["type"] == "shell_call_output": - outputs = [ - Content.from_shell_command_output( - stdout=out["stdout"] or "", - stderr=out["stderr"] or "", - exit_code=out["outcome"].get("exit_code"), - ) - for out in (item.get("output") or []) - ] - return Message( - role="tool", - contents=[ - Content.from_shell_tool_result( - call_id=item["call_id"], - outputs=outputs, - max_output_length=item.get("max_output_length"), - ) - ], - ) - - if item["type"] == "local_shell_call": - commands = item["action"].get("command") or [] - return Message( - role="assistant", - contents=[ - Content.from_shell_tool_call( - call_id=item["call_id"], - commands=commands, - status=str(item["status"]), - ) - ], - ) - - if item["type"] == "local_shell_call_output": - return Message( - role="tool", - contents=[ - Content.from_shell_tool_result( - call_id=item["id"], - outputs=[Content.from_shell_command_output(stdout=item["output"])], - ) - ], - ) - - if item["type"] == "file_search_call": - return Message( - role="assistant", - contents=[ - Content.from_function_call( - item["id"], - "file_search", - arguments=json.dumps({"queries": item["queries"]}), - informational_only=True, - ) - ], - ) - - if item["type"] == "web_search_call": - return Message( - role="assistant", - contents=[Content.from_function_call(item["id"], "web_search", informational_only=True)], - ) - - if item["type"] == "computer_call": - return Message( - role="assistant", - contents=[ - Content.from_function_call( - item["call_id"], - "computer_use", - arguments=str(item.get("action")), - informational_only=True, - ) - ], - ) - - if item["type"] == "computer_call_output": - return Message( - role="tool", - contents=[Content.from_function_result(item["call_id"], result=str(item["output"]))], - ) - - if item["type"] == "custom_tool_call": - return Message( - role="assistant", - contents=[ - Content.from_function_call( - item["call_id"], - item["name"], - arguments=item["input"], - informational_only=True, - ) - ], - ) - - if item["type"] == "custom_tool_call_output": - output = item["output"] if isinstance(item["output"], str) else str(item["output"]) - # Hosted-MCP results land here because the host writes them via - # `aoutput_item_custom_tool_call_output`. Route `mcp_*` call_ids - # back to a hosted-MCP result Content so the chat-client serialize - # layer can coalesce onto the matching `mcp_call` input item. - # Issue #5546. - if item["call_id"] and item["call_id"].startswith("mcp_"): - return Message( - role="tool", - contents=[Content.from_mcp_server_tool_result(call_id=item["call_id"], output=output)], - ) - return Message( - role="tool", - contents=[Content.from_function_result(item["call_id"], result=output)], - ) - - if item["type"] == "apply_patch_call": - return Message( - role="assistant", - contents=[ - Content.from_function_call( - item["call_id"], - "apply_patch", - arguments=str(item["operation"]), - informational_only=True, - ) - ], - ) - - if item["type"] == "apply_patch_call_output": - return Message( - role="tool", - contents=[Content.from_function_result(item["call_id"], result=item.get("output") or "")], - ) - if item["type"] == "oauth_consent_request": return Message( role="assistant", @@ -1897,10 +1669,13 @@ async def _output_item_to_message( ) if item["type"] == "structured_outputs": - text = json.dumps(item["output"]) if not isinstance(item["output"], str) else item["output"] - return Message(role="assistant", contents=[Content.from_text(text)]) + return Message(role="assistant", contents=[Content.from_text(_json_safe_to_str(item["output"]))]) - raise ValueError(f"Unsupported OutputItem type: {item['type']}") + return await _item_to_message( + cast(Item, item), + approval_storage=approval_storage, + _item_type_name="OutputItem", + ) def _convert_output_message_content(content: OutputMessageContent) -> Content: @@ -2002,29 +1777,32 @@ def _convert_message_content(content: MessageContent) -> Content: # region Output Item Conversion -def _argument_json_default(value: Any) -> Any: +def _json_default(value: Any) -> Any: if is_dataclass(value) and not isinstance(value, type): return asdict(value) to_dict = getattr(value, "to_dict", None) if callable(to_dict): return to_dict() - raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") + return str(value) -def _arguments_to_str(arguments: Any | None) -> str: - """Convert arguments to a JSON string. +def _json_safe_to_str(value: Any | None) -> str: + """Convert an argument or result value to a JSON-safe string. Args: - arguments: The arguments to convert, can be a string, JSON-like object, or None. + value: The value to convert, which can be a string, JSON-like object, or None. Returns: - The arguments as a JSON string. + The value as a JSON string. """ - if arguments is None: + if value is None: return "" - if isinstance(arguments, str): - return arguments - return json.dumps(arguments, default=_argument_json_default) + if isinstance(value, str): + return value + try: + return json.dumps(value, default=_json_default) + except (TypeError, ValueError): + return json.dumps(str(value)) def _reasoning_encrypted_content(content: Content) -> str | None: @@ -2050,6 +1828,16 @@ def _reasoning_output_item( }) +def _mcp_mapping_text(output: Mapping[Any, Any]) -> str | None: + """Extract text only from a recognized MCP text-content mapping.""" + text = output.get("text") + if not isinstance(text, str): + return None + if output.get("type") == "text" or set(output) == {"text"}: + return text + return None + + def _stringify_mcp_output(output: Any) -> str: """Convert hosted MCP output payloads into the string shape expected by mcp_call.output.""" if output is None: @@ -2057,20 +1845,26 @@ def _stringify_mcp_output(output: Any) -> str: if isinstance(output, str): return output if isinstance(output, Mapping): - text = cast(Any, output).get("text") - if isinstance(text, str): + mapping = cast(Mapping[Any, Any], output) + if (text := _mcp_mapping_text(mapping)) is not None: return text - return json.dumps(output, default=str) + return _json_safe_to_str(mapping) if isinstance(output, Sequence) and not isinstance(output, (str, bytes, bytearray)): parts: list[str] = [] - entries = cast(Sequence[object], output) + entries = cast(Sequence[Any], output) for entry in entries: + if isinstance(entry, str): + parts.append(entry) + continue if isinstance(entry, Content) and entry.type == "text": parts.append(entry.text or "") continue - parts.append(_stringify_mcp_output(entry)) + if isinstance(entry, Mapping) and (text := _mcp_mapping_text(cast(Mapping[Any, Any], entry))) is not None: + parts.append(text) + continue + return _json_safe_to_str(entries) return "".join(parts) - return str(output) + return _json_safe_to_str(output) # endregion diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 258ffce8a8..50f0c0ff50 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -69,8 +69,10 @@ CONSENT_ERROR_CODE, ConsentError, _item_to_message, # pyright: ignore[reportPrivateUsage] + _json_safe_to_str, # pyright: ignore[reportPrivateUsage] _output_item_to_message, # pyright: ignore[reportPrivateUsage] _OutputItemTracker, # pyright: ignore[reportPrivateUsage] + _stringify_mcp_output, # pyright: ignore[reportPrivateUsage] consent_url_from_error, ) from agent_framework_foundry_hosting._state_store import ( @@ -618,6 +620,51 @@ def _sse_event_types(events: list[dict[str, Any]]) -> list[str]: # endregion +# region Serialization Helpers + + +class TestSerializationHelpers: + def test_json_safe_to_str_preserves_structured_conversion_and_falls_back_to_string(self) -> None: + @dataclass + class DataclassValue: + count: int + + class ToDictValue: + def to_dict(self) -> dict[str, bool]: + return {"ok": False} + + class SerializationErrorValue: + def to_dict(self) -> dict[str, Any]: + raise ValueError("unsupported structure") + + def __str__(self) -> str: + return "serialization-error" + + class UnexpectedErrorValue: + def to_dict(self) -> dict[str, Any]: + raise RuntimeError("unexpected conversion failure") + + cyclic: list[Any] = [] + cyclic.append(cyclic) + + assert json.loads(_json_safe_to_str(DataclassValue(count=0))) == {"count": 0} + assert json.loads(_json_safe_to_str(ToDictValue())) == {"ok": False} + assert json.loads(_json_safe_to_str(Path("result.txt"))) == "result.txt" + for value in (cyclic, {("kind",): "value"}, SerializationErrorValue()): + assert json.loads(_json_safe_to_str(value)) == str(value) + with pytest.raises(RuntimeError, match="unexpected conversion failure"): + _json_safe_to_str(UnexpectedErrorValue()) + + def test_stringify_mcp_output_extracts_only_text_content_mappings(self) -> None: + assert _stringify_mcp_output({"text": "ok"}) == "ok" + assert _stringify_mcp_output({"type": "text", "text": "ok", "annotations": {"priority": 0}}) == "ok" + assert json.loads(_stringify_mcp_output({"text": "ok", "count": 0})) == {"text": "ok", "count": 0} + assert _stringify_mcp_output([{"type": "text", "text": "first"}, {"text": "second"}]) == "firstsecond" + + +# endregion + + # region Initialization @@ -1224,6 +1271,90 @@ async def test_function_call_and_result(self) -> None: assert "function_call_output" in types assert "message" in types + @pytest.mark.parametrize( + ("result", "expected_output"), + [ + (0, "0"), + (False, "false"), + ({"count": 0, "ok": False}, '{"count": 0, "ok": false}'), + ], + ) + async def test_function_result_serializes_json_safely(self, result: Any, expected_output: str) -> None: + agent = _make_agent( + response=AgentResponse( + messages=[ + Message( + role="assistant", + contents=[Content.from_function_call("call_1", "get_value", arguments="{}")], + ), + Message(role="tool", contents=[Content.from_function_result("call_1", result=result)]), + ] + ) + ) + server = _make_server(agent) + + resp = await _post(server, stream=False) + + assert resp.status_code == 200 + result_item = next(item for item in resp.json()["output"] if item["type"] == "function_call_output") + assert result_item["output"] == expected_output + + async def test_function_result_serialization_failure_falls_back_to_json_string(self) -> None: + cyclic: dict[str, Any] = {} + cyclic["self"] = cyclic + agent = _make_agent( + response=AgentResponse( + messages=[ + Message( + role="assistant", + contents=[Content.from_function_call("call_1", "get_value", arguments="{}")], + ), + Message( + role="tool", + contents=[Content("function_result", call_id="call_1", result=cyclic)], + ), + ] + ) + ) + + resp = await _post(_make_server(agent), stream=False) + + assert resp.status_code == 200 + assert resp.json()["status"] == "completed" + result_item = next(item for item in resp.json()["output"] if item["type"] == "function_call_output") + assert json.loads(result_item["output"]) == str(cyclic) + + async def test_shell_call_preserves_execution_limits(self) -> None: + agent = _make_agent( + response=AgentResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_shell_tool_call( + call_id="shell_1", + commands=["python --version"], + timeout_ms=30_000, + max_output_length=4096, + status="completed", + ) + ], + ) + ] + ) + ) + server = _make_server(agent) + + resp = await _post(server, stream=False) + + assert resp.status_code == 200 + shell_item = next(item for item in resp.json()["output"] if item["type"] == "shell_call") + assert shell_item["action"] == { + "commands": ["python --version"], + "timeout_ms": 30_000, + "max_output_length": 4096, + } + async def test_hosted_mcp_call_and_result_persist_as_single_mcp_call(self) -> None: agent = _make_agent( response=AgentResponse( @@ -1268,6 +1399,62 @@ async def test_hosted_mcp_call_and_result_persist_as_single_mcp_call(self) -> No assert mcp_items[0]["id"] == "mcp_abc123" assert mcp_items[0]["output"] == "found 10 cats" + @pytest.mark.parametrize( + ("output", "expected_output"), + [ + ({"count": 0, "ok": False}, {"count": 0, "ok": False}), + ({"text": "ok", "count": 0}, {"text": "ok", "count": 0}), + (Path("result.txt"), "result.txt"), + ({("kind",): "value"}, "{('kind',): 'value'}"), + ], + ) + async def test_mcp_result_serialization_matches_with_and_without_correlated_call( + self, output: Any, expected_output: Any + ) -> None: + correlated_agent = _make_agent( + response=AgentResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_mcp_server_tool_call( + call_id="mcp_correlated", + tool_name="search", + server_name="api_specs", + arguments="{}", + ) + ], + ), + Message( + role="tool", + contents=[Content.from_mcp_server_tool_result(call_id="mcp_correlated", output=output)], + ), + ] + ) + ) + uncorrelated_agent = _make_agent( + response=AgentResponse( + messages=[ + Message( + role="tool", + contents=[Content.from_mcp_server_tool_result(call_id="mcp_uncorrelated", output=output)], + ) + ] + ) + ) + + correlated_response = await _post(_make_server(correlated_agent), stream=False) + uncorrelated_response = await _post(_make_server(uncorrelated_agent), stream=False) + + assert correlated_response.status_code == 200 + assert uncorrelated_response.status_code == 200 + correlated_item = next(item for item in correlated_response.json()["output"] if item["type"] == "mcp_call") + uncorrelated_item = next( + item for item in uncorrelated_response.json()["output"] if item["type"] == "custom_tool_call_output" + ) + assert correlated_item["output"] == uncorrelated_item["output"] + assert json.loads(correlated_item["output"]) == expected_output + async def test_reasoning_content(self) -> None: reasoning_id = "rs_576d207b35d96b3200pkcXkMwXAij920Wcv7WhRXiMPiLdOA63" agent = _make_agent( @@ -1901,6 +2088,20 @@ async def test_function_call_output(self) -> None: assert msg.contents[0].call_id == "call_1" assert msg.contents[0].result == "sunny" + async def test_function_call_output_structured_result_is_json(self) -> None: + item = cast( + OutputItem, + { + "type": "function_call_output", + "call_id": "call_2", + "output": {"count": 0, "ok": False}, + }, + ) + + msg = await _output_item_to_message(item) + + assert json.loads(msg.contents[0].result) == {"count": 0, "ok": False} + async def test_function_call_output_without_call_id_raises(self) -> None: from azure.ai.agentserver.responses.models import FunctionCallOutputItemParam @@ -2047,6 +2248,8 @@ async def test_shell_call(self) -> None: assert msg.contents[0].type == "shell_tool_call" assert msg.contents[0].commands == ["ls", "-la"] assert msg.contents[0].call_id == "call_sc" + assert msg.contents[0].timeout_ms == 5000 + assert msg.contents[0].max_output_length == 1024 async def test_shell_call_output(self) -> None: from azure.ai.agentserver.responses.models import ( @@ -2081,13 +2284,19 @@ async def test_local_shell_call(self) -> None: "type": "local_shell_call", "id": "lsc-1", "call_id": "call_lsc", - "action": LocalShellExecAction({"type": "exec", "command": ["echo", "hello"], "env": {}}), + "action": LocalShellExecAction({ + "type": "exec", + "command": ["echo", "hello"], + "timeout_ms": 5000, + "env": {}, + }), "status": "completed", }) msg = await _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "shell_tool_call" assert msg.contents[0].commands == ["echo", "hello"] + assert msg.contents[0].timeout_ms == 5000 async def test_local_shell_call_output(self) -> None: from azure.ai.agentserver.responses.models import OutputItemLocalShellToolCallOutput @@ -2148,6 +2357,9 @@ async def test_computer_call(self) -> None: assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "computer_use" + arguments = msg.contents[0].arguments + assert isinstance(arguments, str) + assert json.loads(arguments) == {"type": "click"} assert msg.contents[0].informational_only is True async def test_computer_call_output(self) -> None: @@ -2166,6 +2378,10 @@ async def test_computer_call_output(self) -> None: assert msg.role == "tool" assert msg.contents[0].type == "function_result" assert msg.contents[0].call_id == "call_cc" + assert json.loads(msg.contents[0].result) == { + "type": "computer_screenshot", + "image_url": "data:image/png;base64,abc", + } async def test_custom_tool_call(self) -> None: item = cast( @@ -2241,6 +2457,13 @@ async def test_apply_patch_call(self) -> None: assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "apply_patch" + arguments = msg.contents[0].arguments + assert isinstance(arguments, str) + assert json.loads(arguments) == { + "type": "update_file", + "path": "file.py", + "diff": "+ new line", + } assert msg.contents[0].informational_only is True async def test_apply_patch_call_output(self) -> None: @@ -2400,17 +2623,18 @@ async def test_function_call_output(self) -> None: assert msg.contents[0].call_id == "call_1" assert msg.contents[0].result == "sunny" - async def test_function_call_output_non_string(self) -> None: + @pytest.mark.parametrize("output", [0, False, {"count": 0, "ok": False}]) + async def test_function_call_output_non_string(self, output: Any) -> None: from azure.ai.agentserver.responses.models import FunctionCallOutputItemParam item = cast( FunctionCallOutputItemParam, - {"type": "function_call_output", "call_id": "call_2", "output": 42}, + {"type": "function_call_output", "call_id": "call_2", "output": output}, ) msg = await _item_to_message(item) assert msg is not None assert msg.role == "tool" - assert msg.contents[0].result == "42" + assert json.loads(msg.contents[0].result) == output async def test_function_call_output_without_call_id_raises(self) -> None: from azure.ai.agentserver.responses.models import FunctionCallOutputItemParam @@ -2570,6 +2794,8 @@ async def test_shell_call(self) -> None: assert msg.contents[0].type == "shell_tool_call" assert msg.contents[0].commands == ["ls", "-la"] assert msg.contents[0].call_id == "call_sc" + assert msg.contents[0].timeout_ms == 5000 + assert msg.contents[0].max_output_length == 1024 async def test_shell_call_output(self) -> None: from azure.ai.agentserver.responses.models import ( @@ -2603,7 +2829,12 @@ async def test_local_shell_call(self) -> None: "type": "local_shell_call", "id": "lsc-1", "call_id": "call_lsc", - "action": LocalShellExecAction({"type": "exec", "command": ["echo", "hello"], "env": {}}), + "action": LocalShellExecAction({ + "type": "exec", + "command": ["echo", "hello"], + "timeout_ms": 5000, + "env": {}, + }), "status": "completed", }) msg = await _item_to_message(item) @@ -2611,6 +2842,7 @@ async def test_local_shell_call(self) -> None: assert msg.role == "assistant" assert msg.contents[0].type == "shell_tool_call" assert msg.contents[0].commands == ["echo", "hello"] + assert msg.contents[0].timeout_ms == 5000 async def test_local_shell_call_output(self) -> None: from azure.ai.agentserver.responses.models import ItemLocalShellToolCallOutput @@ -2677,6 +2909,9 @@ async def test_computer_call(self) -> None: assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "computer_use" + arguments = msg.contents[0].arguments + assert isinstance(arguments, str) + assert json.loads(arguments) == {"type": "click"} assert msg.contents[0].informational_only is True async def test_computer_call_output(self) -> None: @@ -2695,6 +2930,10 @@ async def test_computer_call_output(self) -> None: assert msg.role == "tool" assert msg.contents[0].type == "function_result" assert msg.contents[0].call_id == "call_cc" + assert json.loads(msg.contents[0].result) == { + "type": "computer_screenshot", + "image_url": "data:image/png;base64,abc", + } async def test_custom_tool_call(self) -> None: from azure.ai.agentserver.responses.models import ItemCustomToolCall @@ -2791,6 +3030,13 @@ async def test_apply_patch_call(self) -> None: assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "apply_patch" + arguments = msg.contents[0].arguments + assert isinstance(arguments, str) + assert json.loads(arguments) == { + "type": "update_file", + "path": "file.py", + "diff": "+ new line", + } assert msg.contents[0].informational_only is True async def test_apply_patch_call_output(self) -> None: