Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions python/packages/foundry/tests/foundry/test_foundry_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,35 @@
from __future__ import annotations

import inspect
import json
import os
import sys
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4

import httpx
import pytest
from agent_framework import (
Agent,
AgentExecutor,
AgentResponse,
AgentSession,
ChatContext,
ChatMiddleware,
ChatResponse,
ChatResponseUpdate,
Message,
WorkflowBuilder,
tool,
)
from agent_framework_openai._chat_client import RawOpenAIChatClient
from azure.ai.projects import models as projects_models
from azure.core.exceptions import ResourceNotFoundError
from azure.identity import AzureCliCredential
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
from openai import AsyncOpenAI

from agent_framework_foundry._agent import (
FoundryAgent,
Expand Down Expand Up @@ -1194,6 +1199,129 @@ def _import_with_missing_azure_monitor(
await agent.configure_azure_monitor()


async def test_foundry_agent_workflow_drops_function_call_paired_with_stripped_reasoning() -> None:
"""Stateless cross-agent replay keeps reasoning and its client-side tool call atomic."""

def _message(message_id: str, text: str) -> dict[str, Any]:
return {
"id": message_id,
"content": [{"annotations": [], "text": text, "type": "output_text"}],
"role": "assistant",
"status": "completed",
"type": "message",
}

def _response(response_id: str, output: list[dict[str, Any]]) -> dict[str, Any]:
return {
"id": response_id,
"created_at": 0,
"model": "gpt-5.4",
"object": "response",
"output": output,
"parallel_tool_calls": True,
"status": "completed",
"tool_choice": "auto",
"tools": [],
}

async def foundry_responses_boundary(request: httpx.Request) -> httpx.Response:
payload = json.loads(request.content)
agent_name = payload["agent_reference"]["name"]

if agent_name == "research-agent" and "previous_response_id" not in payload:
return httpx.Response(
200,
json=_response(
"resp_research_tool",
[
{
"id": "rs_required",
"summary": [{"text": "I need the local MCP tool.", "type": "summary_text"}],
"type": "reasoning",
},
{
"arguments": '{"query":"Agent Framework"}',
"call_id": "call_paired",
"id": "fc_paired",
"name": "lookup_docs",
"status": "completed",
"type": "function_call",
},
],
),
)

if agent_name == "research-agent":
return httpx.Response(
200,
json=_response(
"resp_research_final",
[_message("msg_research", "Microsoft Agent Framework")],
),
)

input_items = payload["input"]
input_types = {item.get("type") for item in input_items if isinstance(item, dict)}
if "function_call" in input_types and "reasoning" not in input_types:
return httpx.Response(
400,
json={
"error": {
"message": (
"Item 'fc_paired' of type 'function_call' was provided without its "
"required 'reasoning' item: 'rs_required'."
),
"type": "invalid_request_error",
"code": "invalid_request_error",
}
},
)

return httpx.Response(
200,
json=_response(
"resp_summary",
[_message("msg_summary", "The research result was Microsoft Agent Framework.")],
),
)

@tool(name="lookup_docs", approval_mode="never_require")
def lookup_docs(query: str) -> str:
return f"Found documentation for {query}"

transport = httpx.MockTransport(foundry_responses_boundary)
responses_client = AsyncOpenAI(
api_key="test-key",
http_client=httpx.AsyncClient(transport=transport),
max_retries=0,
)
project_client = MagicMock()
project_client.get_openai_client.return_value = responses_client

research_agent = FoundryAgent(
project_client=project_client,
agent_name="research-agent",
tools=[lookup_docs],
)
summary_agent = FoundryAgent(
project_client=project_client,
agent_name="summary-agent",
)
research_executor = AgentExecutor(research_agent, id="research")
summary_executor = AgentExecutor(summary_agent, id="summary")
workflow = (
WorkflowBuilder(start_executor=research_executor, output_from=[summary_executor])
.add_edge(research_executor, summary_executor)
.build()
)

result = await workflow.run("Research Agent Framework and summarize the result")

outputs = result.get_outputs()
assert outputs
assert outputs[-1].text == "The research result was Microsoft Agent Framework."


@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_agent_integration_tests_disabled
Expand Down
154 changes: 150 additions & 4 deletions python/packages/foundry_hosting/tests/test_responses_int.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
"""Integration tests for ResponsesHostServer with a real Foundry endpoint.

These tests exercise the full HTTP pipeline using httpx.AsyncClient with
ASGITransport — no real server process is started. The agent talks to a real
Foundry project endpoint so every test requires valid credentials.
ASGITransport — no real server process is started. Most tests talk to a real
Foundry project endpoint. Deterministic cross-package regressions replace only
the external Responses HTTP boundary.

Required environment variables:
FOUNDRY_PROJECT_ENDPOINT - The Microsoft Foundry project endpoint URL.
Expand All @@ -18,13 +19,15 @@
import os
from pathlib import Path
from typing import Annotated, Any
from unittest.mock import MagicMock

import httpx
import pytest
from agent_framework import Agent, tool
from agent_framework import Agent, SlidingWindowStrategy, tool
from agent_framework.foundry import FoundryChatClient
from azure.ai.agentserver.responses import InMemoryResponseProvider
from azure.identity import AzureCliCredential
from openai import AsyncOpenAI

from agent_framework_foundry_hosting import ResponsesHostServer

Expand All @@ -38,7 +41,6 @@
reason="No real FOUNDRY_PROJECT_ENDPOINT or FOUNDRY_MODEL provided; skipping integration tests.",
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -491,6 +493,150 @@ async def test_multi_turn_streaming(self, server: ResponsesHostServer) -> None:
assert "42" in done_events[0]["data"]["text"]


class TestReasoningHostedMcpReplay:
"""Regression coverage for stateless reasoning + hosted MCP replay."""

async def test_second_turn_does_not_replay_mcp_call_without_its_reasoning_item(self) -> None:
"""A hosted agent can continue after a reasoning model uses hosted MCP with store disabled."""
call_count = 0

def _message(message_id: str) -> dict[str, Any]:
return {
"id": message_id,
"content": [{"annotations": [], "text": "Microsoft Agent Framework", "type": "output_text"}],
"role": "assistant",
"status": "completed",
"type": "message",
}

def _response(response_id: str, output: list[dict[str, Any]]) -> dict[str, Any]:
return {
"id": response_id,
"created_at": 0,
"model": "gpt-5.4",
"object": "response",
"output": output,
"parallel_tool_calls": True,
"tool_choice": "auto",
"tools": [],
"status": "completed",
}

async def foundry_responses_boundary(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
if call_count == 1:
return httpx.Response(
200,
json=_response(
"resp_first",
[
{
"id": "rs_required",
"summary": [{"text": "The MCP server has the answer.", "type": "summary_text"}],
"type": "reasoning",
},
{
"id": "mcp_paired",
"arguments": '{"query":"Agent Framework overview"}',
"name": "microsoft_docs_search",
"server_label": "Microsoft_Learn",
"type": "mcp_call",
"output": "Microsoft Agent Framework",
"status": "completed",
},
_message("msg_first"),
],
),
)

payload = json.loads(request.content)
input_items = payload["input"]
input_types = {item.get("type") for item in input_items if isinstance(item, dict)}
if "mcp_call" in input_types and "reasoning" not in input_types:
return httpx.Response(
400,
json={
"error": {
"message": (
"Item 'mcp_paired' of type 'mcp_call' was provided without its "
"required 'reasoning' item: 'rs_required'."
),
"type": "invalid_request_error",
"code": "invalid_request_error",
}
},
)

return httpx.Response(
200,
json=_response(
"resp_second",
[_message("msg_second")],
),
)

transport = httpx.MockTransport(foundry_responses_boundary)
responses_client = AsyncOpenAI(
api_key="test-key",
http_client=httpx.AsyncClient(transport=transport),
max_retries=0,
)
project_client = MagicMock()
project_client.get_openai_client.return_value = responses_client
client = FoundryChatClient(
project_client=project_client,
model="gpt-5.4",
compaction_strategy=SlidingWindowStrategy(keep_last_groups=4),
)
learn_mcp = client.get_mcp_tool(
name="Microsoft Learn",
url="https://learn.microsoft.com/api/mcp",
allowed_tools=["microsoft_docs_search"],
approval_mode="never_require",
)
agent = Agent(
client=client, # ty: ignore[invalid-argument-type]
instructions=(
"Always use the Microsoft Learn MCP tool to answer documentation questions. "
"Keep the final answer to one short sentence."
),
tools=[learn_mcp],
default_options={ # pyrefly: ignore[bad-argument-type]
"store": False,
"reasoning": {"effort": "low", "summary": "auto"},
},
)
server = ResponsesHostServer(agent, store=InMemoryResponseProvider())

first = await _post_json(
server,
{
"input": "Use Microsoft Learn MCP to find the official Agent Framework overview and state its title.",
"stream": False,
},
)

assert first.status_code == 200
first_body = first.json()
assert first_body["status"] == "completed", first_body.get("error")
first_output_types = {item["type"] for item in first_body["output"]}
assert {"reasoning", "mcp_call"} <= first_output_types

second = await _post_json(
server,
{
"input": "Which Microsoft framework did you just look up? Reply with only its name.",
"stream": False,
"previous_response_id": first_body["id"],
},
)

assert second.status_code == 200
second_body = second.json()
assert second_body["status"] == "completed", second_body.get("error")


# ---------------------------------------------------------------------------
# Tests — tool calling
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading