diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 43374b85d29..ae7bbb2e009 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -39,7 +39,7 @@ from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential -from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event +from agent_framework_foundry._oauth_helpers import parse_oauth_consent_output_items, try_parse_oauth_consent_event from ._constants import FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY from ._feature_usage import ( @@ -426,6 +426,10 @@ def _parse_response_from_openai( options: dict[str, Any], ) -> Any: parsed_response = super()._parse_response_from_openai(response, options) + if parsed_response.messages: + parsed_response.messages[0].contents.extend( + parse_oauth_consent_output_items(getattr(response, "output", None)) + ) if agent_session_id := _extract_foundry_hosted_agent_session_id(response): parsed_response.additional_properties["agent_session_id"] = agent_session_id return parsed_response diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 30dbc443065..c2d20b188b7 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -56,7 +56,7 @@ from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential -from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event +from agent_framework_foundry._oauth_helpers import parse_oauth_consent_output_items, try_parse_oauth_consent_event from ._feature_usage import ( FeatureIndex, @@ -346,6 +346,9 @@ def _parse_response_from_openai( if getattr(item, "type", None) != "reasoning": continue self._attach_foundry_reasoning_replay_item(chat_response.messages[0].contents, item) + chat_response.messages[0].contents.extend( + parse_oauth_consent_output_items(getattr(response, "output", None)) + ) return chat_response @override diff --git a/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py index 873d42c3d9c..b60658f0506 100644 --- a/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py +++ b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from collections.abc import Iterable from typing import Any from urllib.parse import urlparse @@ -48,28 +49,47 @@ def try_parse_oauth_consent_event(event: Any, model: str) -> ChatResponseUpdate else: return None + return ChatResponseUpdate( + contents=_oauth_consent_contents(raw_item, consent_link), + role="assistant", + model=model, + raw_representation=event, + ) + + +def parse_oauth_consent_output_items(output: Iterable[Any] | None) -> list[Content]: + """Parse ``oauth_consent_request`` items from a non-streaming response's ``output``. + + Non-streaming responses carry the consent request as an output item instead of + a stream event, so it has to be surfaced from the completed response as well. + """ + contents: list[Content] = [] + for item in output or (): + if getattr(item, "type", None) == "oauth_consent_request": + contents.extend(_oauth_consent_contents(item, getattr(item, "consent_link", None) or "")) + return contents + + +def _oauth_consent_contents(raw_item: Any, consent_link: str) -> list[Content]: + """Build the consent content for an oauth_consent_request item, validating its link.""" item_id = getattr(raw_item, "id", "") if consent_link: consent_link = _validate_consent_link(consent_link, item_id) - contents: list[Content] = [] if consent_link: - contents.append( - Content.from_oauth_consent_request( - consent_link=consent_link, - raw_representation=raw_item, - ) - ) - else: - logger.warning( - "Received oauth_consent_request output without valid consent_link (item id=%s)", - item_id, + content = Content.from_oauth_consent_request( + consent_link=consent_link, + raw_representation=raw_item, ) - - return ChatResponseUpdate( - contents=contents, - role="assistant", - model=model, - raw_representation=event, + # The consent request is a user-input request: workflows (``AgentExecutor``) register it + # under its id, so keep the provider item id instead of leaving it empty. + raw_id = getattr(raw_item, "id", None) + if isinstance(raw_id, str) and raw_id: + content.id = raw_id + return [content] + logger.warning( + "Received oauth_consent_request output without valid consent_link (item id=%s)", + item_id, ) + return [] diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 3fe96541499..61c556e7236 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -2006,6 +2006,44 @@ def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None: assert update.raw_representation is mock_event +async def test_run_non_streaming_surfaces_oauth_consent_request() -> None: + """A non-streaming agent run surfaces an oauth_consent_request output item instead of an empty answer.""" + + def handle_request(request: Any) -> Any: + return _OPENAI_HTTPX.Response( + 200, + json={ + "id": "resp_consent", + "object": "response", + "created_at": 0, + "model": "gpt-5.4", + "status": "incomplete", + "output": [ + { + "type": "oauth_consent_request", + "id": "oauth-item-1", + "consent_link": "https://consent-host.example.com/login?data=abc123", + "server_label": "github", + } + ], + }, + ) + + async with AsyncOpenAI( + api_key="test-key", + http_client=DefaultAsyncHttpxClient(transport=_OPENAI_HTTPX.MockTransport(handle_request)), + max_retries=0, + ) as async_client: + project_client = MagicMock() + project_client.get_openai_client.return_value = async_client + agent = FoundryAgent(project_client=project_client, agent_name="toolbox-agent") + response = await agent.run("List my repos") + + consent_contents = [c for m in response.messages for c in m.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" + + def test_client_model_not_set_from_openai_chat_model(monkeypatch: pytest.MonkeyPatch) -> None: """client.model must be empty when OPENAI_CHAT_MODEL is set.""" monkeypatch.setenv("OPENAI_CHAT_MODEL", "gpt-4.1-from-env") diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index a4660da378c..fc2138ea4ac 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -1983,6 +1983,44 @@ def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None: assert update.raw_representation is mock_event +async def test_get_response_non_streaming_surfaces_oauth_consent_request() -> None: + """A non-streaming response carrying an oauth_consent_request output item surfaces the consent link.""" + + def handle_request(request: Any) -> Any: + return _OPENAI_HTTPX.Response( + 200, + json={ + "id": "resp_consent", + "object": "response", + "created_at": 0, + "model": "test-model", + "status": "incomplete", + "output": [ + { + "type": "oauth_consent_request", + "id": "oauth-item-1", + "consent_link": "https://consent-host.example.com/login?data=abc123", + "server_label": "github", + } + ], + }, + ) + + async with AsyncOpenAI( + api_key="test-key", + base_url="https://example.test/v1", + http_client=DefaultAsyncHttpxClient(transport=_OPENAI_HTTPX.MockTransport(handle_request)), + ) as async_client: + project_client = MagicMock() + project_client.get_openai_client.return_value = async_client + client = FoundryChatClient(project_client=project_client, model="test-model") + response = await client.get_response([Message(role="user", contents=["List my repos"])]) + + consent_contents = [c for m in response.messages for c in m.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" + + def test_agent_accepts_foundry_chat_clients() -> None: mock_project = MagicMock() mock_openai = _make_mock_openai_client() diff --git a/python/packages/foundry/tests/foundry/test_oauth_helpers.py b/python/packages/foundry/tests/foundry/test_oauth_helpers.py index 2ab209e141e..06e4f4eca7b 100644 --- a/python/packages/foundry/tests/foundry/test_oauth_helpers.py +++ b/python/packages/foundry/tests/foundry/test_oauth_helpers.py @@ -3,12 +3,29 @@ from __future__ import annotations import logging -from typing import Any +from collections.abc import AsyncIterable, Awaitable +from typing import Any, Literal, overload from unittest.mock import MagicMock import pytest - -from agent_framework_foundry._oauth_helpers import _validate_consent_link, try_parse_oauth_consent_event +from agent_framework import ( + AgentExecutor, + AgentResponse, + AgentResponseUpdate, + AgentRunInputs, + AgentSession, + BaseAgent, + Content, + Message, + ResponseStream, + WorkflowBuilder, +) + +from agent_framework_foundry._oauth_helpers import ( + _validate_consent_link, + parse_oauth_consent_output_items, + try_parse_oauth_consent_event, +) # region _validate_consent_link tests @@ -162,3 +179,121 @@ def test_empty_contents_for_https_empty_netloc(caplog: pytest.LogCaptureFixture) # endregion + + +# region consent request id + + +def _make_output_item(*, item_id: Any = "oauth-item-1", consent_link: str = "https://consent.example.com/auth") -> Any: + """Create a mock non-streaming ``oauth_consent_request`` output item.""" + item = MagicMock() + item.type = "oauth_consent_request" + item.consent_link = consent_link + item.id = item_id + return item + + +def test_output_item_added_keeps_provider_item_id() -> None: + """The consent content carries the provider item id, as user-input requests need one.""" + update = try_parse_oauth_consent_event(_make_output_item_event(item_id="oauth-item-42"), "test-model") + + assert update is not None + assert [c.id for c in update.contents if c.type == "oauth_consent_request"] == ["oauth-item-42"] + + +def test_top_level_consent_requested_event_keeps_event_id() -> None: + """A top-level ``response.oauth_consent_requested`` event keeps its id on the content.""" + update = try_parse_oauth_consent_event(_make_top_level_event(event_id="consent-event-7"), "test-model") + + assert update is not None + assert [c.id for c in update.contents if c.type == "oauth_consent_request"] == ["consent-event-7"] + + +def test_non_streaming_output_item_keeps_provider_item_id() -> None: + """Non-streaming output items keep the provider item id on the consent content.""" + contents = parse_oauth_consent_output_items([_make_output_item(item_id="oauth-item-9")]) + + assert len(contents) == 1 + assert contents[0].type == "oauth_consent_request" + assert contents[0].user_input_request is True + assert contents[0].id == "oauth-item-9" + + +def test_consent_content_without_string_id_has_no_id() -> None: + """A provider item without a usable id does not get a made-up one.""" + contents = parse_oauth_consent_output_items([_make_output_item(item_id=None)]) + + assert len(contents) == 1 + assert contents[0].id is None + + +class _ConsentEmittingAgent(BaseAgent): + """Agent that returns the consent content parsed from a Foundry output item.""" + + def __init__(self, *, item_id: str, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._item_id = item_id + + def _consent_contents(self) -> list[Content]: + return parse_oauth_consent_output_items([_make_output_item(item_id=self._item_id)]) + + @overload + def run( + self, + messages: AgentRunInputs | None = ..., + *, + stream: Literal[False] = ..., + session: AgentSession | None = ..., + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]]: ... + + @overload + def run( + self, + messages: AgentRunInputs | None = ..., + *, + stream: Literal[True], + session: AgentSession | None = ..., + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + contents = self._consent_contents() + + if stream: + + async def _stream() -> AsyncIterable[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=contents, role="assistant") + + return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) + + async def _run() -> AgentResponse: + return AgentResponse(messages=[Message("assistant", contents)]) + + return _run() + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_workflow_pauses_for_oauth_consent_request(stream: bool) -> None: + """In a workflow, the consent request becomes a ``request_info`` event keyed by the provider item id.""" + agent = _ConsentEmittingAgent(item_id="oauth-item-wf", id="consent_agent", name="ConsentAgent") + workflow = WorkflowBuilder(start_executor=AgentExecutor(agent, id="consent_exec")).build() + + if stream: + events = [event async for event in workflow.run("connect my calendar", stream=True)] + else: + events = list(await workflow.run("connect my calendar")) + + request_info_events = [event for event in events if event.type == "request_info"] + assert len(request_info_events) == 1 + assert request_info_events[0].request_id == "oauth-item-wf" + + +# endregion