mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
7b70f80036
* Fix Foundry clients not surfacing oauth_consent_request events (#5054) Override _parse_chunk_from_openai in both RawFoundryChatClient and RawFoundryAgentChatClient to intercept response.output_item.added events with item.type == 'oauth_consent_request'. The consent link is validated (HTTPS required) and converted to Content.from_oauth_consent_request, which the AG-UI layer already knows how to emit as a CUSTOM event. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback for #5054 OAuth consent parsing - Extract shared helper (try_parse_oauth_consent_event) to avoid duplicated logic between RawFoundryChatClient and RawFoundryAgentChatClient - Use urllib.parse.urlparse() for HTTPS validation instead of case-sensitive startswith check - Sanitize log messages to avoid leaking consent_link tokens; log only item id - Add model=self.model to ChatResponseUpdate to match parent behavior - Add assertions on role, raw_representation, and model in happy-path tests - Add test for empty-string consent_link - Add test verifying non-oauth events delegate to super() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Handle response.oauth_consent_requested top-level event (#5054) Add support for the top-level response.oauth_consent_requested stream event in addition to the response.output_item.added variant. The service may emit either form; handle both so the consent link is reliably surfaced. Extract _validate_consent_link helper within _oauth_helpers.py to reduce nesting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #5054: Python: [Bug]: `FoundryAgent` (Responses API) Does Not Surface `oauth_consent_request` as a CUSTOM AG-UI Event * Address review feedback: defensive getattr and dedicated helper tests (#5054) - Use getattr(event, 'type', None) in try_parse_oauth_consent_event for defensive access against malformed events without a type attribute - Add test_oauth_helpers.py with unit tests for _validate_consent_link and try_parse_oauth_consent_event covering edge cases: - HTTPS URL with empty netloc (https:///path) - Warning log messages for rejected consent links - Event objects missing 'type' attribute Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #5054: Python: [Bug]: `FoundryAgent` (Responses API) Does Not Surface `oauth_consent_request` as a CUSTOM AG-UI Event * Fix mypy: match _parse_chunk_from_openai signature with superclass Add seen_reasoning_delta_item_ids parameter to _parse_chunk_from_openai overrides in both RawFoundryChatClient and RawFoundryAgentChatClient to match the updated superclass signature on main. Update super() calls and test assertions accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
165 lines
5.9 KiB
Python
165 lines
5.9 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from agent_framework_foundry._oauth_helpers import _validate_consent_link, try_parse_oauth_consent_event
|
|
|
|
# region _validate_consent_link tests
|
|
|
|
|
|
def test_validate_consent_link_accepts_valid_https() -> None:
|
|
"""A valid HTTPS URL with a netloc passes validation."""
|
|
link = "https://consent.example.com/auth?code=123"
|
|
assert _validate_consent_link(link, "item-1") == link
|
|
|
|
|
|
def test_validate_consent_link_rejects_http(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""An HTTP link is rejected and a warning is logged."""
|
|
with caplog.at_level(logging.WARNING):
|
|
result = _validate_consent_link("http://insecure.example.com/login", "item-2")
|
|
assert result == ""
|
|
assert "non-HTTPS" in caplog.text
|
|
assert "item-2" in caplog.text
|
|
|
|
|
|
def test_validate_consent_link_rejects_empty_netloc(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""An HTTPS URL with an empty netloc (e.g. https:///path) is rejected."""
|
|
with caplog.at_level(logging.WARNING):
|
|
result = _validate_consent_link("https:///path", "item-3")
|
|
assert result == ""
|
|
assert "non-HTTPS" in caplog.text
|
|
assert "item-3" in caplog.text
|
|
|
|
|
|
def test_validate_consent_link_rejects_non_url(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""A non-URL string is rejected."""
|
|
with caplog.at_level(logging.WARNING):
|
|
result = _validate_consent_link("not-a-url", "item-4")
|
|
assert result == ""
|
|
|
|
|
|
# endregion
|
|
|
|
# region try_parse_oauth_consent_event tests
|
|
|
|
|
|
def _make_output_item_event(
|
|
*,
|
|
item_type: str = "oauth_consent_request",
|
|
consent_link: Any = "https://consent.example.com/auth",
|
|
item_id: str = "oauth-item-1",
|
|
) -> MagicMock:
|
|
"""Create a mock ``response.output_item.added`` event."""
|
|
event = MagicMock()
|
|
event.type = "response.output_item.added"
|
|
item = MagicMock()
|
|
item.type = item_type
|
|
item.consent_link = consent_link
|
|
item.id = item_id
|
|
event.item = item
|
|
return event
|
|
|
|
|
|
def _make_top_level_event(
|
|
*,
|
|
consent_link: Any = "https://consent.example.com/authorize",
|
|
event_id: str = "consent-event-1",
|
|
) -> MagicMock:
|
|
"""Create a mock ``response.oauth_consent_requested`` event."""
|
|
event = MagicMock()
|
|
event.type = "response.oauth_consent_requested"
|
|
event.consent_link = consent_link
|
|
event.id = event_id
|
|
return event
|
|
|
|
|
|
def test_returns_none_for_unrelated_event() -> None:
|
|
"""An event with a non-oauth type returns None."""
|
|
event = MagicMock()
|
|
event.type = "response.output_text.delta"
|
|
assert try_parse_oauth_consent_event(event, "model-x") is None
|
|
|
|
|
|
def test_returns_none_for_event_without_type() -> None:
|
|
"""An event object missing a 'type' attribute returns None."""
|
|
event = object() # no type attribute
|
|
assert try_parse_oauth_consent_event(event, "model-x") is None
|
|
|
|
|
|
def test_parses_output_item_added_with_valid_link() -> None:
|
|
"""A response.output_item.added event with a valid HTTPS link produces Content."""
|
|
event = _make_output_item_event()
|
|
update = try_parse_oauth_consent_event(event, "test-model")
|
|
|
|
assert update is not None
|
|
assert update.role == "assistant"
|
|
assert update.model == "test-model"
|
|
assert update.raw_representation is event
|
|
consent = [c for c in update.contents if c.type == "oauth_consent_request"]
|
|
assert len(consent) == 1
|
|
assert consent[0].consent_link == "https://consent.example.com/auth"
|
|
|
|
|
|
def test_parses_top_level_consent_requested_event() -> None:
|
|
"""A response.oauth_consent_requested event produces Content."""
|
|
event = _make_top_level_event()
|
|
update = try_parse_oauth_consent_event(event, "test-model")
|
|
|
|
assert update is not None
|
|
consent = [c for c in update.contents if c.type == "oauth_consent_request"]
|
|
assert len(consent) == 1
|
|
assert consent[0].consent_link == "https://consent.example.com/authorize"
|
|
|
|
|
|
def test_empty_contents_for_non_https_link(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""A non-HTTPS consent_link produces an update with empty contents and logs a warning."""
|
|
event = _make_output_item_event(consent_link="http://bad.example.com/login", item_id="item-http")
|
|
with caplog.at_level(logging.WARNING):
|
|
update = try_parse_oauth_consent_event(event, "test-model")
|
|
|
|
assert update is not None
|
|
assert len(update.contents) == 0
|
|
assert "non-HTTPS" in caplog.text
|
|
|
|
|
|
def test_empty_contents_for_missing_consent_link(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""A None consent_link produces an update with empty contents and logs a warning."""
|
|
event = _make_output_item_event(consent_link=None, item_id="item-none")
|
|
with caplog.at_level(logging.WARNING):
|
|
update = try_parse_oauth_consent_event(event, "test-model")
|
|
|
|
assert update is not None
|
|
assert len(update.contents) == 0
|
|
assert "without valid consent_link" in caplog.text
|
|
|
|
|
|
def test_empty_contents_for_empty_string_consent_link(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""An empty-string consent_link produces an update with empty contents and logs a warning."""
|
|
event = _make_output_item_event(consent_link="", item_id="item-empty")
|
|
with caplog.at_level(logging.WARNING):
|
|
update = try_parse_oauth_consent_event(event, "test-model")
|
|
|
|
assert update is not None
|
|
assert len(update.contents) == 0
|
|
assert "without valid consent_link" in caplog.text
|
|
|
|
|
|
def test_empty_contents_for_https_empty_netloc(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""An HTTPS URL with empty netloc (https:///path) is rejected."""
|
|
event = _make_output_item_event(consent_link="https:///path", item_id="item-no-netloc")
|
|
with caplog.at_level(logging.WARNING):
|
|
update = try_parse_oauth_consent_event(event, "test-model")
|
|
|
|
assert update is not None
|
|
assert len(update.contents) == 0
|
|
assert "non-HTTPS" in caplog.text
|
|
|
|
|
|
# endregion
|