From f2bb71ca8bb39da4f3d284c222b9e142e6643441 Mon Sep 17 00:00:00 2001 From: Zakaria Khchiche <45357915+Zakariakhchiche@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:29:02 +0200 Subject: [PATCH 1/7] Python: Surface oauth_consent_request in non-streaming Foundry responses Add a helper that converts oauth_consent_request output items of a completed response into consent content, reusing the streaming validation. --- .../agent_framework_foundry/_oauth_helpers.py | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py index 873d42c3d9c..4480204aaa4 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,43 @@ 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( + return [ 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, - ) - - return ChatResponseUpdate( - contents=contents, - role="assistant", - model=model, - raw_representation=event, + ] + logger.warning( + "Received oauth_consent_request output without valid consent_link (item id=%s)", + item_id, ) + return [] From c061a441390c7d85c46fa7449f0a0832fb0d62ee Mon Sep 17 00:00:00 2001 From: Zakaria Khchiche <45357915+Zakariakhchiche@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:33:17 +0200 Subject: [PATCH 2/7] Surface oauth consent items in non-streaming FoundryChatClient responses --- .../packages/foundry/agent_framework_foundry/_chat_client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From 4dfc6f0d2b6c10e646d72f1c80b5e3d2cf2e5bc8 Mon Sep 17 00:00:00 2001 From: Zakaria Khchiche <45357915+Zakariakhchiche@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:34:25 +0200 Subject: [PATCH 3/7] Surface oauth consent items in non-streaming FoundryAgent responses --- python/packages/foundry/agent_framework_foundry/_agent.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 From 9d8df42da68fdda45cf01b0761454ecfe8118cfc Mon Sep 17 00:00:00 2001 From: Zakaria Khchiche <45357915+Zakariakhchiche@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:35:10 +0200 Subject: [PATCH 4/7] Add non-streaming oauth consent regression test for FoundryChatClient --- .../tests/foundry/test_foundry_chat_client.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) 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() From 0b6899653d2df538c22e27e82fd7d39e03315fdb Mon Sep 17 00:00:00 2001 From: Zakaria Khchiche <45357915+Zakariakhchiche@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:36:35 +0200 Subject: [PATCH 5/7] Add non-streaming oauth consent regression test for FoundryAgent --- .../tests/foundry/test_foundry_agent.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) 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") From 1c64b43ec60041d920d0ada1fef9cc8e8be00420 Mon Sep 17 00:00:00 2001 From: Zakaria Khchiche <45357915+Zakariakhchiche@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:55:40 +0200 Subject: [PATCH 6/7] Preserve the provider item id on Foundry oauth consent content Refactor consent link handling to create content object and set ID. --- .../agent_framework_foundry/_oauth_helpers.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py index 4480204aaa4..b60658f0506 100644 --- a/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py +++ b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py @@ -78,12 +78,16 @@ def _oauth_consent_contents(raw_item: Any, consent_link: str) -> list[Content]: consent_link = _validate_consent_link(consent_link, item_id) if consent_link: - return [ - Content.from_oauth_consent_request( - consent_link=consent_link, - raw_representation=raw_item, - ) - ] + content = Content.from_oauth_consent_request( + consent_link=consent_link, + raw_representation=raw_item, + ) + # 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, From 8b0a11a8223bc07221715bd8f660855877c8f0c4 Mon Sep 17 00:00:00 2001 From: Zakaria Khchiche <45357915+Zakariakhchiche@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:56:50 +0200 Subject: [PATCH 7/7] Cover consent request ids and the workflow pause path Refactor tests for OAuth consent helpers and add new tests for output items and consent requests. --- .../tests/foundry/test_oauth_helpers.py | 141 +++++++++++++++++- 1 file changed, 138 insertions(+), 3 deletions(-) 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