mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
fa8cfb7567
* Fix FoundryAgent stripping model from PromptAgent requests
Move run_options.pop('model', None) inside the _uses_foundry_agent_session()
conditional so that model is only stripped for hosted agent sessions (where
the server manages the model) and preserved for PromptAgent requests that
require it in the Responses API call.
Fixes #5525
* test: add coverage for resp_* continuation preserving model
Adds test_raw_foundry_agent_chat_client_prepare_options_preserves_model_for_resp_continuation
to explicitly verify that HostedAgent v1 / v2-no-session paths (where conversation_id
starts with resp_) preserve model and previous_response_id without triggering the
hosted-session gate.
---------
Co-authored-by: Benke Qu <bequ@microsoft.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
1042 lines
36 KiB
Python
1042 lines
36 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import os
|
|
import sys
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from agent_framework import (
|
|
AgentResponse,
|
|
AgentSession,
|
|
ChatContext,
|
|
ChatMiddleware,
|
|
ChatResponse,
|
|
ChatResponseUpdate,
|
|
Message,
|
|
tool,
|
|
)
|
|
from agent_framework_openai._chat_client import RawOpenAIChatClient
|
|
from azure.core.exceptions import ResourceNotFoundError
|
|
from azure.identity import AzureCliCredential
|
|
|
|
from agent_framework_foundry._agent import (
|
|
FoundryAgent,
|
|
RawFoundryAgent,
|
|
RawFoundryAgentChatClient,
|
|
_FoundryAgentChatClient,
|
|
)
|
|
|
|
skip_if_foundry_agent_integration_tests_disabled = pytest.mark.skipif(
|
|
os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.services.ai.azure.com/")
|
|
or os.getenv("FOUNDRY_AGENT_NAME", "") == "",
|
|
reason="No real FOUNDRY_PROJECT_ENDPOINT or FOUNDRY_AGENT_NAME provided; skipping integration tests.",
|
|
)
|
|
|
|
_FOUNDRY_AGENT_ENV_VARS = (
|
|
"FOUNDRY_PROJECT_ENDPOINT",
|
|
"FOUNDRY_AGENT_NAME",
|
|
"FOUNDRY_AGENT_VERSION",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_foundry_agent_settings_env(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None:
|
|
"""Prevent unit tests from inheriting Foundry agent settings from the shell."""
|
|
|
|
if request.node.get_closest_marker("integration") is not None:
|
|
return
|
|
|
|
for env_var in _FOUNDRY_AGENT_ENV_VARS:
|
|
monkeypatch.delenv(env_var, raising=False)
|
|
|
|
|
|
def test_raw_foundry_agent_chat_client_init_requires_agent_name() -> None:
|
|
"""Test that agent_name is required."""
|
|
|
|
with pytest.raises(ValueError, match="Agent name is required"):
|
|
RawFoundryAgentChatClient(
|
|
project_client=MagicMock(),
|
|
)
|
|
|
|
|
|
def test_raw_foundry_agent_chat_client_init_with_agent_name() -> None:
|
|
"""Test construction with agent_name and project_client without preview agent binding."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
agent_version="1.0",
|
|
)
|
|
|
|
assert client.agent_name == "test-agent"
|
|
assert client.agent_version == "1.0"
|
|
mock_project.get_openai_client.assert_called_once_with()
|
|
|
|
|
|
def test_raw_foundry_agent_chat_client_init_passes_agent_name_when_preview_enabled() -> None:
|
|
"""Test preview-enabled clients bind the OpenAI client to the agent endpoint."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="hosted-agent",
|
|
allow_preview=True,
|
|
default_headers={"x-test": "1"},
|
|
)
|
|
|
|
assert client.agent_name == "hosted-agent"
|
|
mock_project.get_openai_client.assert_called_once_with(
|
|
agent_name="hosted-agent",
|
|
default_headers={"x-test": "1"},
|
|
)
|
|
|
|
|
|
def test_raw_foundry_agent_chat_client_init_uses_explicit_parameters() -> None:
|
|
signature = inspect.signature(RawFoundryAgentChatClient.__init__)
|
|
|
|
assert "default_headers" in signature.parameters
|
|
assert "instruction_role" in signature.parameters
|
|
assert "compaction_strategy" in signature.parameters
|
|
assert "tokenizer" in signature.parameters
|
|
assert "additional_properties" in signature.parameters
|
|
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
|
|
|
|
|
def test_raw_foundry_agent_chat_client_as_agent_preserves_client_type() -> None:
|
|
"""Test that as_agent() wraps the client in FoundryAgent using the same client class."""
|
|
|
|
class CustomClient(RawFoundryAgentChatClient):
|
|
pass
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = CustomClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
agent_version="1.0",
|
|
)
|
|
|
|
agent = client.as_agent(instructions="You are helpful.")
|
|
|
|
assert isinstance(agent, FoundryAgent)
|
|
assert agent.name == "test-agent"
|
|
assert isinstance(agent.client, CustomClient)
|
|
assert agent.client.project_client is mock_project
|
|
assert agent.client.agent_name == "test-agent"
|
|
assert agent.client.agent_version == "1.0"
|
|
|
|
named_agent = client.as_agent(name="display-name", instructions="You are helpful.")
|
|
assert named_agent.name == "display-name"
|
|
assert named_agent.client.agent_name == "test-agent"
|
|
|
|
|
|
def test_raw_foundry_agent_chat_client_as_agent_uses_explicit_parameters() -> None:
|
|
signature = inspect.signature(RawFoundryAgentChatClient.as_agent)
|
|
|
|
assert "compaction_strategy" in signature.parameters
|
|
assert "tokenizer" in signature.parameters
|
|
assert "additional_properties" in signature.parameters
|
|
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
|
|
|
|
|
async def test_raw_foundry_agent_chat_client_prepare_options_validates_tools() -> None:
|
|
"""Test that _prepare_options rejects non-FunctionTool objects."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
)
|
|
|
|
with pytest.raises(TypeError, match="Only FunctionTool objects are accepted"):
|
|
await client._prepare_options(
|
|
messages=[Message(role="user", contents="hi")],
|
|
options={"tools": [{"type": "function", "function": {"name": "bad"}}]},
|
|
)
|
|
|
|
|
|
async def test_raw_foundry_agent_chat_client_prepare_options_accepts_function_tools() -> None:
|
|
"""Test that _prepare_options accepts FunctionTool objects."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_openai = MagicMock()
|
|
mock_project.get_openai_client.return_value = mock_openai
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
)
|
|
|
|
@tool(approval_mode="never_require")
|
|
def my_func() -> str:
|
|
"""A test function."""
|
|
|
|
return "ok"
|
|
|
|
with patch(
|
|
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
|
new_callable=AsyncMock,
|
|
return_value={},
|
|
):
|
|
result = await client._prepare_options(
|
|
messages=[Message(role="user", contents="hi")],
|
|
options={"tools": [my_func]},
|
|
)
|
|
|
|
# agent_reference is required so the Responses API can resolve model server-side; see #5582.
|
|
assert result == {
|
|
"extra_body": {"agent_reference": {"name": "test-agent", "type": "agent_reference"}},
|
|
}
|
|
|
|
|
|
async def test_raw_foundry_agent_chat_client_prepare_options_strips_client_side_fields() -> None:
|
|
"""Test that _prepare_options strips tool-loop fields but preserves model for non-session requests."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_openai = MagicMock()
|
|
mock_project.get_openai_client.return_value = mock_openai
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
)
|
|
|
|
@tool(approval_mode="never_require")
|
|
def my_func() -> str:
|
|
"""A test function."""
|
|
|
|
return "ok"
|
|
|
|
with patch(
|
|
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
|
new_callable=AsyncMock,
|
|
return_value={
|
|
"model": "gpt-4.1",
|
|
"tools": [{"type": "function", "function": {"name": "my_func"}}],
|
|
"tool_choice": "auto",
|
|
"parallel_tool_calls": True,
|
|
},
|
|
):
|
|
result = await client._prepare_options(
|
|
messages=[Message(role="user", contents="hi")],
|
|
options={"tools": [my_func]},
|
|
)
|
|
|
|
# model is preserved for non-session (PromptAgent) requests
|
|
assert result["model"] == "gpt-4.1"
|
|
assert "tools" not in result
|
|
assert "tool_choice" not in result
|
|
assert "parallel_tool_calls" not in result
|
|
# agent_reference is required so the Responses API can resolve model server-side; see #5582.
|
|
assert result == {
|
|
"model": "gpt-4.1",
|
|
"extra_body": {"agent_reference": {"name": "test-agent", "type": "agent_reference"}},
|
|
}
|
|
|
|
|
|
async def test_raw_foundry_agent_chat_client_prepare_options_strips_model_for_hosted_session() -> None:
|
|
"""Test that model is stripped when using a hosted agent session (not a PromptAgent)."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_openai = MagicMock()
|
|
mock_project.get_openai_client.return_value = mock_openai
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
)
|
|
|
|
with patch(
|
|
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
|
new_callable=AsyncMock,
|
|
return_value={
|
|
"model": "gpt-4.1",
|
|
"previous_response_id": "resp_abc",
|
|
},
|
|
):
|
|
result = await client._prepare_options(
|
|
messages=[Message(role="user", contents="hi")],
|
|
options={"conversation_id": "agent-session-123"},
|
|
)
|
|
|
|
assert "model" not in result
|
|
assert "previous_response_id" not in result
|
|
assert result["extra_body"]["agent_session_id"] == "agent-session-123"
|
|
assert result["extra_body"]["agent_reference"] == {"name": "test-agent", "type": "agent_reference"}
|
|
|
|
|
|
async def test_raw_foundry_agent_chat_client_prepare_options_injects_agent_reference_first_turn() -> None:
|
|
"""First-turn (no conversation_id) Prompt Agent calls must carry agent_reference in extra_body.
|
|
|
|
Regression test for https://github.com/microsoft/agent-framework/issues/5582 — without this
|
|
the Responses API rejects with "Missing required parameter: 'model'", because both ``model``
|
|
and ``agent_reference`` are absent from the request body.
|
|
"""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
agent_version="2",
|
|
)
|
|
|
|
with patch(
|
|
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
|
new_callable=AsyncMock,
|
|
return_value={"model": "gpt-4.1"},
|
|
):
|
|
result = await client._prepare_options(
|
|
messages=[Message(role="user", contents="hi")],
|
|
options={},
|
|
)
|
|
|
|
assert result["extra_body"] == {
|
|
"agent_reference": {"name": "test-agent", "type": "agent_reference", "version": "2"},
|
|
}
|
|
|
|
|
|
async def test_raw_foundry_agent_chat_client_prepare_options_agent_reference_omits_version_when_unset() -> None:
|
|
"""When agent_version is unset, agent_reference should omit the version key entirely."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="hosted-agent",
|
|
)
|
|
|
|
with patch(
|
|
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
|
new_callable=AsyncMock,
|
|
return_value={"model": "gpt-4.1"},
|
|
):
|
|
result = await client._prepare_options(
|
|
messages=[Message(role="user", contents="hi")],
|
|
options={},
|
|
)
|
|
|
|
assert result["extra_body"] == {
|
|
"agent_reference": {"name": "hosted-agent", "type": "agent_reference"},
|
|
}
|
|
|
|
|
|
async def test_raw_foundry_agent_chat_client_prepare_options_skips_agent_reference_when_allow_preview() -> None:
|
|
"""Hosted-agent (allow_preview=True) requests must NOT add agent_reference in the body.
|
|
|
|
The preview path injects the agent identity via ``project_client.get_openai_client(agent_name=...)``
|
|
at the SDK wrapper level. Adding it again in extra_body would either duplicate or conflict
|
|
with the wrapper's injection. Keep this gate aligned with the constructor branch in
|
|
``RawFoundryAgentChatClient.__init__``.
|
|
"""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="hosted-agent",
|
|
agent_version="3",
|
|
allow_preview=True,
|
|
)
|
|
|
|
with patch(
|
|
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
|
new_callable=AsyncMock,
|
|
return_value={"model": "gpt-4.1"},
|
|
):
|
|
result = await client._prepare_options(
|
|
messages=[Message(role="user", contents="hi")],
|
|
options={},
|
|
)
|
|
|
|
# model is preserved for non-session requests (platform tolerates it for hosted agents)
|
|
assert result["model"] == "gpt-4.1"
|
|
# No extra_body at all is the cleanest signal — agent_reference must not be injected here.
|
|
assert "extra_body" not in result
|
|
|
|
|
|
async def test_raw_foundry_agent_chat_client_prepare_options_respects_caller_agent_reference() -> None:
|
|
"""A caller-supplied extra_body['agent_reference'] should not be overwritten."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="default-agent",
|
|
)
|
|
|
|
caller_reference = {"name": "override-agent", "type": "agent_reference", "version": "5"}
|
|
with patch(
|
|
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
|
new_callable=AsyncMock,
|
|
return_value={"model": "gpt-4.1", "extra_body": {"agent_reference": caller_reference}},
|
|
):
|
|
result = await client._prepare_options(
|
|
messages=[Message(role="user", contents="hi")],
|
|
options={"extra_body": {"agent_reference": caller_reference}},
|
|
)
|
|
|
|
assert result["extra_body"]["agent_reference"] == caller_reference
|
|
|
|
|
|
async def test_raw_foundry_agent_chat_client_prepare_options_preserves_model_for_resp_continuation() -> None:
|
|
"""Test that model is preserved when conversation_id is a resp_* continuation (HostedAgent v1 / v2-no-session)."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_openai = MagicMock()
|
|
mock_project.get_openai_client.return_value = mock_openai
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
)
|
|
|
|
with patch(
|
|
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
|
new_callable=AsyncMock,
|
|
return_value={
|
|
"model": "gpt-4.1",
|
|
"previous_response_id": "resp_abc123",
|
|
},
|
|
):
|
|
result = await client._prepare_options(
|
|
messages=[Message(role="user", contents="hi")],
|
|
options={"conversation_id": "resp_abc123"},
|
|
)
|
|
|
|
# model preserved — resp_* is standard Responses API continuity, not a hosted session
|
|
assert result["model"] == "gpt-4.1"
|
|
# previous_response_id preserved — not stripped outside hosted session path
|
|
assert result["previous_response_id"] == "resp_abc123"
|
|
# no agent_session_id injected
|
|
assert "extra_body" not in result or "agent_session_id" not in result.get("extra_body", {})
|
|
|
|
|
|
async def test_raw_foundry_agent_chat_client_prepare_options_maps_agent_session_id_to_extra_body() -> None:
|
|
"""Test that service_session_id is forwarded as agent_session_id for hosted sessions."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_openai = MagicMock()
|
|
mock_project.get_openai_client.return_value = mock_openai
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
)
|
|
|
|
with patch(
|
|
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
|
new_callable=AsyncMock,
|
|
return_value={
|
|
"extra_body": {"custom": "value"},
|
|
"previous_response_id": "should-be-removed",
|
|
},
|
|
):
|
|
result = await client._prepare_options(
|
|
messages=[Message(role="user", contents="hi")],
|
|
options={"conversation_id": "agent-session-123", "isolation_key": "iso-key"},
|
|
)
|
|
|
|
assert result["extra_body"] == {
|
|
"custom": "value",
|
|
"agent_session_id": "agent-session-123",
|
|
"agent_reference": {"name": "test-agent", "type": "agent_reference"},
|
|
}
|
|
assert "previous_response_id" not in result
|
|
assert "conversation" not in result
|
|
assert "isolation_key" not in result
|
|
|
|
|
|
def test_raw_foundry_agent_chat_client_parse_response_suppresses_conversation_id_for_agent_sessions() -> None:
|
|
"""Test that agent-session continuations do not overwrite session.service_session_id."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
)
|
|
|
|
parsed = ChatResponse(conversation_id="resp_123")
|
|
with patch(
|
|
"agent_framework_openai._chat_client.RawOpenAIChatClient._parse_response_from_openai",
|
|
return_value=parsed,
|
|
):
|
|
result = client._parse_response_from_openai(
|
|
response=MagicMock(),
|
|
options={"conversation_id": "agent-session-123"},
|
|
)
|
|
|
|
assert result.conversation_id is None
|
|
|
|
|
|
def test_raw_foundry_agent_chat_client_parse_chunk_suppresses_conversation_id_for_agent_sessions() -> None:
|
|
"""Test that agent-session stream updates do not overwrite session.service_session_id."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
)
|
|
|
|
parsed = ChatResponseUpdate(conversation_id="resp_123")
|
|
with patch(
|
|
"agent_framework_openai._chat_client.RawOpenAIChatClient._parse_chunk_from_openai",
|
|
return_value=parsed,
|
|
):
|
|
result = client._parse_chunk_from_openai(
|
|
event=MagicMock(type="response.output_text.delta"),
|
|
options={"conversation_id": "agent-session-123"},
|
|
function_call_ids={},
|
|
)
|
|
|
|
assert result.conversation_id is None
|
|
|
|
|
|
def test_raw_foundry_agent_chat_client_check_model_presence_is_noop() -> None:
|
|
"""Test that _check_model_presence does nothing (model is on service)."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
)
|
|
|
|
options: dict[str, Any] = {}
|
|
client._check_model_presence(options)
|
|
assert "model" not in options
|
|
|
|
|
|
def test_foundry_agent_chat_client_init() -> None:
|
|
"""Test construction of the full-middleware client."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = _FoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
agent_version="1.0",
|
|
)
|
|
|
|
assert client.agent_name == "test-agent"
|
|
|
|
|
|
def test_foundry_agent_chat_client_init_uses_explicit_parameters() -> None:
|
|
signature = inspect.signature(_FoundryAgentChatClient.__init__)
|
|
|
|
assert "default_headers" in signature.parameters
|
|
assert "instruction_role" in signature.parameters
|
|
assert "compaction_strategy" in signature.parameters
|
|
assert "tokenizer" in signature.parameters
|
|
assert "additional_properties" in signature.parameters
|
|
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
|
|
|
|
|
def test_raw_foundry_agent_init_creates_client() -> None:
|
|
"""Test that RawFoundryAgent creates a client internally."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
agent = RawFoundryAgent(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
agent_version="1.0",
|
|
)
|
|
|
|
assert agent.client is not None
|
|
assert agent.client.agent_name == "test-agent"
|
|
|
|
|
|
def test_raw_foundry_agent_init_passes_default_headers_to_client() -> None:
|
|
"""Test that RawFoundryAgent passes default_headers to the underlying client."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
default_headers = {"x-ms-user-isolation-key": "user-1"}
|
|
|
|
RawFoundryAgent(
|
|
project_client=mock_project,
|
|
agent_name="hosted-agent",
|
|
default_headers=default_headers,
|
|
)
|
|
|
|
mock_project.get_openai_client.assert_called_once()
|
|
assert mock_project.get_openai_client.call_args.kwargs["default_headers"] == default_headers
|
|
|
|
|
|
def test_foundry_agent_init_passes_default_headers_to_client() -> None:
|
|
"""Test that FoundryAgent passes default_headers to the underlying client."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
default_headers = {"x-ms-user-isolation-key": "user-1"}
|
|
|
|
FoundryAgent(
|
|
project_client=mock_project,
|
|
agent_name="hosted-agent",
|
|
default_headers=default_headers,
|
|
)
|
|
|
|
mock_project.get_openai_client.assert_called_once()
|
|
assert mock_project.get_openai_client.call_args.kwargs["default_headers"] == default_headers
|
|
|
|
|
|
def test_raw_foundry_agent_init_with_custom_client_type() -> None:
|
|
"""Test that client_type parameter is respected."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
agent = RawFoundryAgent(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
client_type=RawFoundryAgentChatClient,
|
|
)
|
|
|
|
assert isinstance(agent.client, RawFoundryAgentChatClient)
|
|
|
|
|
|
def test_raw_foundry_agent_init_uses_explicit_parameters() -> None:
|
|
signature = inspect.signature(RawFoundryAgent.__init__)
|
|
|
|
assert "default_headers" in signature.parameters
|
|
assert "instructions" in signature.parameters
|
|
assert "default_options" in signature.parameters
|
|
assert "compaction_strategy" in signature.parameters
|
|
assert "tokenizer" in signature.parameters
|
|
assert "additional_properties" in signature.parameters
|
|
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
|
|
|
|
|
def test_foundry_agent_init_uses_explicit_parameters() -> None:
|
|
signature = inspect.signature(FoundryAgent.__init__)
|
|
|
|
assert "default_headers" in signature.parameters
|
|
assert "instructions" in signature.parameters
|
|
assert "default_options" in signature.parameters
|
|
assert "compaction_strategy" in signature.parameters
|
|
assert "tokenizer" in signature.parameters
|
|
assert "additional_properties" in signature.parameters
|
|
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
|
|
|
|
|
def test_raw_foundry_agent_init_rejects_invalid_client_type() -> None:
|
|
"""Test that invalid client_type raises TypeError."""
|
|
|
|
with pytest.raises(TypeError, match="must be a subclass of RawFoundryAgentChatClient"):
|
|
RawFoundryAgent(
|
|
project_client=MagicMock(),
|
|
agent_name="test-agent",
|
|
client_type=object, # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
def test_raw_foundry_agent_init_with_function_tools() -> None:
|
|
"""Test that FunctionTool and callables are accepted."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
@tool(approval_mode="never_require")
|
|
def my_func() -> str:
|
|
"""A test function."""
|
|
|
|
return "ok"
|
|
|
|
agent = RawFoundryAgent(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
tools=[my_func],
|
|
)
|
|
|
|
assert agent.default_options.get("tools") is not None
|
|
|
|
|
|
async def test_raw_foundry_agent_prepare_run_context_creates_service_session_from_isolation_key() -> None:
|
|
"""Test that RawFoundryAgent lazily creates a hosted session and stores it on service_session_id."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
mock_project.beta = SimpleNamespace(
|
|
agents=SimpleNamespace(
|
|
create_session=AsyncMock(return_value=SimpleNamespace(agent_session_id="agent-session-123"))
|
|
)
|
|
)
|
|
|
|
agent = RawFoundryAgent(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
agent_version="1.0",
|
|
allow_preview=True,
|
|
)
|
|
session = AgentSession()
|
|
|
|
with patch(
|
|
"agent_framework._agents.RawAgent._prepare_run_context",
|
|
new=AsyncMock(return_value={"ok": True}),
|
|
) as mock_prepare_run_context:
|
|
result = await agent._prepare_run_context(
|
|
messages="hi",
|
|
session=session,
|
|
tools=None,
|
|
options={"isolation_key": "iso-key"},
|
|
compaction_strategy=None,
|
|
tokenizer=None,
|
|
function_invocation_kwargs=None,
|
|
client_kwargs=None,
|
|
)
|
|
|
|
assert result == {"ok": True}
|
|
assert session.service_session_id == "agent-session-123"
|
|
mock_project.beta.agents.create_session.assert_awaited_once()
|
|
create_session_kwargs = mock_project.beta.agents.create_session.await_args.kwargs
|
|
assert create_session_kwargs["agent_name"] == "test-agent"
|
|
assert create_session_kwargs["isolation_key"] == "iso-key"
|
|
assert "version_indicator" in create_session_kwargs
|
|
mock_prepare_run_context.assert_awaited_once()
|
|
|
|
|
|
async def test_raw_foundry_agent_prepare_run_context_requires_preview_for_hosted_sessions() -> None:
|
|
"""Test that hosted-agent sessions require allow_preview=True."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
agent = RawFoundryAgent(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="allow_preview=True"):
|
|
await agent._prepare_run_context(
|
|
messages="hi",
|
|
session=AgentSession(),
|
|
tools=None,
|
|
options={"isolation_key": "iso-key"},
|
|
compaction_strategy=None,
|
|
tokenizer=None,
|
|
function_invocation_kwargs=None,
|
|
client_kwargs=None,
|
|
)
|
|
|
|
|
|
def test_foundry_agent_init() -> None:
|
|
"""Test construction of the full-middleware agent."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
agent = FoundryAgent(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
agent_version="1.0",
|
|
)
|
|
|
|
assert agent.client is not None
|
|
assert agent.client.agent_name == "test-agent"
|
|
|
|
|
|
def test_foundry_agent_init_with_middleware() -> None:
|
|
"""Test that agent-level middleware is accepted."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
class MyMiddleware(ChatMiddleware):
|
|
async def process(self, context: ChatContext) -> None:
|
|
pass
|
|
|
|
agent = FoundryAgent(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
middleware=[MyMiddleware()],
|
|
)
|
|
|
|
assert agent.client is not None
|
|
|
|
|
|
async def test_foundry_agent_configure_azure_monitor() -> None:
|
|
"""Test configure_azure_monitor delegates through the underlying client."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
mock_project.telemetry.get_application_insights_connection_string = AsyncMock(
|
|
return_value="InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint"
|
|
)
|
|
agent = FoundryAgent(project_client=mock_project, agent_name="test-agent")
|
|
|
|
mock_configure = MagicMock()
|
|
mock_views = MagicMock(return_value=[])
|
|
mock_resource = MagicMock()
|
|
mock_enable = MagicMock()
|
|
|
|
with (
|
|
patch.dict(
|
|
"sys.modules",
|
|
{"azure.monitor.opentelemetry": MagicMock(configure_azure_monitor=mock_configure)},
|
|
),
|
|
patch("agent_framework.observability.create_metric_views", mock_views),
|
|
patch("agent_framework.observability.create_resource", return_value=mock_resource),
|
|
patch("agent_framework.observability.enable_instrumentation", mock_enable),
|
|
):
|
|
await agent.configure_azure_monitor(enable_sensitive_data=True)
|
|
|
|
mock_project.telemetry.get_application_insights_connection_string.assert_called_once()
|
|
call_kwargs = mock_configure.call_args.kwargs
|
|
assert call_kwargs["connection_string"] == "InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint"
|
|
assert call_kwargs["views"] == []
|
|
assert call_kwargs["resource"] is mock_resource
|
|
mock_enable.assert_called_once_with(enable_sensitive_data=True)
|
|
|
|
|
|
async def test_foundry_agent_configure_azure_monitor_resource_not_found() -> None:
|
|
"""Test configure_azure_monitor handles ResourceNotFoundError gracefully."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
mock_project.telemetry.get_application_insights_connection_string = AsyncMock(
|
|
side_effect=ResourceNotFoundError("No Application Insights found")
|
|
)
|
|
agent = FoundryAgent(project_client=mock_project, agent_name="test-agent")
|
|
|
|
await agent.configure_azure_monitor()
|
|
|
|
mock_project.telemetry.get_application_insights_connection_string.assert_called_once()
|
|
|
|
|
|
async def test_foundry_agent_configure_azure_monitor_import_error() -> None:
|
|
"""Test configure_azure_monitor raises ImportError when Azure Monitor is unavailable."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
mock_project.telemetry.get_application_insights_connection_string = AsyncMock(
|
|
return_value="InstrumentationKey=test-key"
|
|
)
|
|
agent = FoundryAgent(project_client=mock_project, agent_name="test-agent")
|
|
original_import = __import__
|
|
|
|
def _import_with_missing_azure_monitor(
|
|
name: str,
|
|
globals: dict[str, Any] | None = None,
|
|
locals: dict[str, Any] | None = None,
|
|
fromlist: tuple[str, ...] = (),
|
|
level: int = 0,
|
|
) -> Any:
|
|
if name == "azure.monitor.opentelemetry":
|
|
raise ImportError("No module named 'azure.monitor.opentelemetry'")
|
|
return original_import(name, globals, locals, fromlist, level)
|
|
|
|
with (
|
|
patch.dict(sys.modules, {"azure.monitor.opentelemetry": None}),
|
|
patch("builtins.__import__", side_effect=_import_with_missing_azure_monitor),
|
|
pytest.raises(ImportError, match="azure-monitor-opentelemetry is required"),
|
|
):
|
|
await agent.configure_azure_monitor()
|
|
|
|
|
|
@pytest.mark.flaky
|
|
@pytest.mark.integration
|
|
@skip_if_foundry_agent_integration_tests_disabled
|
|
async def test_foundry_agent_basic_run() -> None:
|
|
"""Smoke-test FoundryAgent against a real configured agent."""
|
|
async with FoundryAgent(credential=AzureCliCredential(), allow_preview=True) as agent:
|
|
response = await agent.run("Please respond with exactly: 'This is a response test.'")
|
|
|
|
assert isinstance(response, AgentResponse)
|
|
assert response.text is not None
|
|
assert "response test" in response.text.lower()
|
|
|
|
|
|
@pytest.mark.flaky
|
|
@pytest.mark.integration
|
|
@skip_if_foundry_agent_integration_tests_disabled
|
|
async def test_foundry_agent_custom_client_run() -> None:
|
|
"""Smoke-test FoundryAgent against a real configured agent."""
|
|
async with FoundryAgent(
|
|
credential=AzureCliCredential(), client_type=RawFoundryAgentChatClient, allow_preview=True
|
|
) as agent:
|
|
response = await agent.run("Please respond with exactly: 'This is a response test.'")
|
|
|
|
assert isinstance(response, AgentResponse)
|
|
assert response.text is not None
|
|
assert "response test" in response.text.lower()
|
|
|
|
|
|
def test_parse_chunk_surfaces_oauth_consent_request() -> None:
|
|
"""An oauth_consent_request output item surfaces as Content with consent_link."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
)
|
|
|
|
mock_event = MagicMock()
|
|
mock_event.type = "response.output_item.added"
|
|
mock_item = MagicMock()
|
|
mock_item.type = "oauth_consent_request"
|
|
mock_item.consent_link = "https://consent-host.example.com/login?data=abc123"
|
|
mock_item.id = "oauth-item-1"
|
|
mock_event.item = mock_item
|
|
mock_event.output_index = 0
|
|
|
|
update = client._parse_chunk_from_openai(mock_event, {}, {})
|
|
|
|
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
|
|
assert len(consent_contents) == 1
|
|
assert consent_contents[0].consent_link == "https://consent-host.example.com/login?data=abc123"
|
|
assert update.role == "assistant"
|
|
assert update.raw_representation is mock_event
|
|
|
|
|
|
def test_parse_chunk_skips_non_https_oauth_consent() -> None:
|
|
"""An oauth_consent_request with a non-HTTPS link is rejected."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
)
|
|
|
|
mock_event = MagicMock()
|
|
mock_event.type = "response.output_item.added"
|
|
mock_item = MagicMock()
|
|
mock_item.type = "oauth_consent_request"
|
|
mock_item.consent_link = "http://insecure.example.com/login"
|
|
mock_item.id = "oauth-item-2"
|
|
mock_event.item = mock_item
|
|
mock_event.output_index = 0
|
|
|
|
update = client._parse_chunk_from_openai(mock_event, {}, {})
|
|
|
|
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
|
|
assert len(consent_contents) == 0
|
|
|
|
|
|
def test_parse_chunk_handles_missing_consent_link() -> None:
|
|
"""An oauth_consent_request without a consent_link produces no content."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
)
|
|
|
|
mock_event = MagicMock()
|
|
mock_event.type = "response.output_item.added"
|
|
mock_item = MagicMock()
|
|
mock_item.type = "oauth_consent_request"
|
|
mock_item.consent_link = None
|
|
mock_item.id = "oauth-item-3"
|
|
mock_event.item = mock_item
|
|
mock_event.output_index = 0
|
|
|
|
update = client._parse_chunk_from_openai(mock_event, {}, {})
|
|
|
|
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
|
|
assert len(consent_contents) == 0
|
|
|
|
|
|
def test_parse_chunk_handles_empty_string_consent_link() -> None:
|
|
"""An oauth_consent_request with empty-string consent_link produces no content."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
)
|
|
|
|
mock_event = MagicMock()
|
|
mock_event.type = "response.output_item.added"
|
|
mock_item = MagicMock()
|
|
mock_item.type = "oauth_consent_request"
|
|
mock_item.consent_link = ""
|
|
mock_item.id = "oauth-item-4"
|
|
mock_event.item = mock_item
|
|
mock_event.output_index = 0
|
|
|
|
update = client._parse_chunk_from_openai(mock_event, {}, {})
|
|
|
|
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
|
|
assert len(consent_contents) == 0
|
|
|
|
|
|
def test_parse_chunk_delegates_non_oauth_events_to_super() -> None:
|
|
"""Non-oauth events are delegated to super()._parse_chunk_from_openai()."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
)
|
|
|
|
mock_event = MagicMock()
|
|
mock_event.type = "response.output_text.delta"
|
|
|
|
with patch.object(
|
|
RawOpenAIChatClient,
|
|
"_parse_chunk_from_openai",
|
|
return_value=MagicMock(),
|
|
) as mock_super:
|
|
client._parse_chunk_from_openai(mock_event, {}, {})
|
|
mock_super.assert_called_once_with(mock_event, {}, {}, None)
|
|
|
|
|
|
def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None:
|
|
"""A top-level response.oauth_consent_requested event surfaces as Content."""
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.get_openai_client.return_value = MagicMock()
|
|
|
|
client = RawFoundryAgentChatClient(
|
|
project_client=mock_project,
|
|
agent_name="test-agent",
|
|
)
|
|
|
|
mock_event = MagicMock()
|
|
mock_event.type = "response.oauth_consent_requested"
|
|
mock_event.consent_link = "https://consent-host.example.com/authorize?code=xyz"
|
|
mock_event.id = "consent-event-1"
|
|
|
|
update = client._parse_chunk_from_openai(mock_event, {}, {})
|
|
|
|
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
|
|
assert len(consent_contents) == 1
|
|
assert consent_contents[0].consent_link == "https://consent-host.example.com/authorize?code=xyz"
|
|
assert update.role == "assistant"
|
|
assert update.raw_representation is mock_event
|