Skip to content

Python: surface mid-run oauth_consent_request items from ResponsesHostServer - #7659

Open
Giles Odigwe (giles17) wants to merge 8 commits into
microsoft:mainfrom
giles17:fix-hosting-oauth-consent
Open

Python: surface mid-run oauth_consent_request items from ResponsesHostServer#7659
Giles Odigwe (giles17) wants to merge 8 commits into
microsoft:mainfrom
giles17:fix-hosting-oauth-consent

Conversation

@giles17

Copy link
Copy Markdown
Contributor

Motivation & Context

A customer running a hosted agent behind ResponsesHostServer with an on-behalf-of (OBO) MCP server never gets an OAuth consent card in Teams. The host logs Content type 'oauth_consent_request' is not supported yet. This is usually safe to ignore. and returns a completed response with no consent item, so the user has no way to authorize the delegated token and the agent can never call the tool.

They had already upgraded per #3950, which fixed the chat-client hop (_oauth_helpers.try_parse_oauth_consent_event now converts the upstream event into Content.from_oauth_consent_request(...)). The content is dropped one layer later, in the hosting layer, so no version bump of agent-framework-core/agent-framework-foundry helps.

_to_outputs in agent_framework_foundry_hosting/_responses.py has branches for text, reasoning, function call/result, image generation, MCP call/result, shell call/result, and function_approval_request, but none for oauth_consent_request, so that content hits the catch-all else and is discarded. The host already emits the item for connect-time consent failures (when _ensure_agent_ready() raises and consent_url_from_error finds a URL), and the inbound conversion in _output_item_to_message already understands the item type — the outbound mid-run half was simply missing. An OBO server that needs a per-user token at tool-invocation time connects fine and therefore never takes the connect-time path.

This is a different root cause from #7227, which is about parsing connect-time gateway errors whose source type is a2a_preview rather than mcp.

Description & Review Guide

  • What are the major changes?

    • _to_outputs gains an oauth_consent_request branch that emits response.output_item.added / .done for an OAuthConsentRequestOutputItem, carrying the consent link and a server_label read from the content's additional properties (defaulting to agent_framework). The link is validated as an absolute HTTPS URL; anything else is logged and skipped rather than emitted.
    • Both _handle_inner_agent and _handle_inner_workflow now terminate the response with response.incomplete (reason OAuth consent required for N tool(s).) instead of response.completed when at least one consent item was emitted mid-run, matching the existing connect-time behavior.
    • The item construction, link validation, and incomplete reason are factored into small module-level helpers (_emit_oauth_consent_item, _consent_link_from_content, _consent_server_label, _consent_incomplete_reason) that the pre-existing connect-time path now reuses, so both paths cannot drift apart.
    • New TestMidRunOAuthConsentSurfacing tests cover streaming, non-streaming, multiple consent contents in one update, and invalid links (empty, http://, non-URL) being skipped while the turn still completes.
  • What is the impact of these changes?

    • Clients now receive an actionable oauth_consent_request output item for mid-run consent, so a consent card can be rendered and the user can authorize and re-send the prompt. Automatic resumption of the interrupted turn remains separately tracked by Python: [Bug]: ResponsesHostServer has no turn suspension/resumption, so user must re-send message after OAuth consent #5594.
    • Behavior change for callers: a turn that produces a consent request now ends as incomplete rather than completed. This mirrors the connect-time path that already returned incomplete, and previously such a turn produced no output item at all.
    • No public API surface changes; all new helpers are private. The connect-time refactor is behavior-preserving and covered by the existing TestOAuthConsentSurfacing tests.
  • What do you want reviewers to focus on?

    • Whether response.incomplete is the right terminal status for a mid-run consent request, versus keeping completed and relying solely on the output item.

Related Issue

Fixes #7658

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

…tServer

`_to_outputs` had no branch for `oauth_consent_request` content, so a consent
link produced after the agent was entered (for example by an on-behalf-of MCP
server that needs a per-user token at tool-invocation time) fell into the
catch-all and was dropped with "Content type 'oauth_consent_request' is not
supported yet". The client saw a completed response with no consent prompt.

Only connect-time consent failures raised by `_ensure_agent_ready` were
surfaced as `oauth_consent_request` output items, and the inbound conversion
(`_output_item_to_message`) already handled the item type, so the outbound
direction was the missing half.

- Emit `oauth_consent_request` added/done output items from `_to_outputs`,
  validating the link is an absolute HTTPS URL and reading `server_label` from
  the content's additional properties.
- End the response as `incomplete` (instead of `completed`) when a consent
  request was emitted mid-run, in both the agent and workflow handlers,
  matching the connect-time path.
- Factor the item emission, link validation, and incomplete reason into shared
  helpers reused by the connect-time path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 992057d8-78a8-45a7-9201-35af0919b071
Copilot AI balanced review requested due to automatic review settings August 13, 2026 21:28
@agent-framework-automation agent-framework-automation Bot added the python Usage: [Issues, PRs], Target: Python label Aug 13, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Surfaces mid-run OAuth consent requests through Python’s Foundry Responses host.

Changes:

  • Emits OAuth consent output items and marks affected responses incomplete.
  • Adds consent-link validation and shared emission helpers.
  • Adds streaming and non-streaming tests.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
_responses.py Handles and emits mid-run OAuth consent requests.
test_responses.py Tests consent output, validation, and response status.
Suppressed comments (1)

python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py:214

  • The real Foundry OAuth parser does not populate additional_properties: agent_framework_foundry/_oauth_helpers.py:59-62 puts the upstream item (which carries server_label) in raw_representation. Consequently, actual mid-run OBO events always fall back to agent_framework; only this PR's synthetic test preserves the label. Fall back to the raw item's server_label so clients receive the originating server identity.
def _consent_server_label(content: Content) -> str:
    """Return the server label to report for an ``oauth_consent_request`` content."""
    label = content.additional_properties.get("server_label") if content.additional_properties else None
    return label if isinstance(label, str) and label else "agent_framework"

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py Outdated
Comment thread python/packages/foundry_hosting/tests/test_responses.py
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Python Test Coverage

Python Test Coverage Report •
FileStmtsMissCoverMissing
packages/core/agent_framework
   _oauth.py320100% 
packages/foundry/agent_framework_foundry
   _oauth_helpers.py270100% 
packages/foundry_hosting/agent_framework_foundry_hosting
   _responses.py89411687%118–120, 236, 301–302, 316, 319–320, 340, 506, 515, 592, 624, 712, 762, 824, 836, 852–853, 858–861, 865–867, 872, 880, 883, 894, 939, 960, 971–973, 987–989, 992, 1026–1029, 1034, 1037, 1102–1103, 1204, 1275–1277, 1281, 1284–1287, 1291–1293, 1298–1302, 1306–1308, 1315–1316, 1319–1322, 1328, 1331–1337, 1347, 1353, 1357, 1381, 1420–1421, 1425, 1667, 1679, 2131–2132, 2136, 2181, 2183, 2185, 2187, 2191, 2199, 2202–2206, 2208, 2218, 2222, 2267, 2269, 2271–2274, 2282, 2284
TOTAL48260449390% 

Python Unit Test Overview

Tests Skipped Failures Errors Time
9801 36 💤 0 ❌ 0 🔥 2m 38s ⏱️

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAF Automated Review — Iteration 1

Result: Findings reported
Scope: full PR (1 commit(s)): b452dd73d248
Model: gpt-5.6-sol

Overview

The change consistently surfaces valid mid-run OAuth consent requests as output items and terminates affected agent and workflow responses as incomplete, with coverage for streaming, non-streaming, multiple requests, and common invalid links. Shared emit/reason helpers also keep the connect-time and mid-run paths aligned. Two residual gaps remain: malformed HTTPS syntax can fail the whole response, and real Foundry events do not propagate their required server label through the new hosting conversion.

Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
2 verified findings remained after source verification (2 medium) across 1 file. Details are attached to the affected lines below.

Affected areas: python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py

Comment thread python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py Outdated
Giles Odigwe (giles17) and others added 2 commits August 14, 2026 12:12
- Harden consent link validation. `urlparse` raises `ValueError` for malformed
  authorities such as `https://[broken`, which turned an unrenderable link into
  a failed response instead of skipping the item, and a non-empty `netloc` is
  not sufficient on its own (`https://@` has one but no host). Validation now
  catches the parse error and requires a hostname, in both the hosting layer
  and `agent_framework_foundry._oauth_helpers`, which had the same defect.

- Preserve the server label. `try_parse_oauth_consent_event` only kept the
  upstream item in `raw_representation`, so every real Foundry consent event
  was re-emitted with the fallback label. The parser now copies `server_label`
  into `additional_properties`, and hosting falls back to the raw item's label
  before defaulting.

- Validate connect-time consent links too. An entry-time consent error with no
  renderable link now produces `response.failed` rather than an `incomplete`
  carrying no link the user can act on, and the reported count reflects the
  items actually emitted.

- Suppress duplicate consent prompts. `WorkflowAgent` replays the inner agent's
  content as workflow output, so the same consent request reached the host
  twice and produced two prompts. `_to_outputs` now takes the set of emitted
  `(consent_link, server_label)` pairs and skips repeats.

- Cover the workflow hosting path, which was previously exercised only through
  the regular agent handler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 992057d8-78a8-45a7-9201-35af0919b071
…onsent

# Conflicts:
#	python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAF Automated Review — Iteration 2

Result: Findings reported
Scope: 12 net-new commit(s): 9a06fa3f426d, 9645d33cde44, ee27065359e0, 5fafa1856907, 8c4da3c3b9b8, 4aa737eee5da, ae7fa3389c8f, e2893200277b, e1e005f226a2, 12621e0a7465, 9b33db988a05, fbc4b985d6a9
Model: gpt-5.6-sol

Overview

The change surfaces mid-run OAuth consent as Responses output, preserves the MCP server label, suppresses workflow replay duplicates, and covers regular and workflow hosting paths. URL guards reject several unsafe forms, but rejected consent requirements are currently erased and some syntactically unusable HTTPS URLs still pass validation, leaving clients with either a false successful terminal state or an unusable incomplete response.

Reviewed the supplied incremental change set across correctness, security/reliability, architecture, and failure behavior.
2 verified findings remained after source verification (2 medium) across 1 file. Details are attached to the affected lines below.

Affected areas: python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py

Comment thread python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py Outdated
Comment thread python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py Outdated
@TaoChenOSU

Copy link
Copy Markdown
Contributor

Why didn't the consent item get emitted when the code enters the agent?

…inks

Addresses two review findings on the mid-run OAuth consent surfacing.

Consent link validation was incomplete. `urlparse` only validates the port
when it is read, so `https://host:bad` and `https://host:99999` passed the
previous check, and a non-empty hostname was accepted even when it contained
characters no URL client can resolve (`https://exa mple.com`). `urlparse` also
silently strips tab and newline, letting control characters through. The
validator now reads `port` inside the guarded block, checks the hostname
against a permitted character set for both registered names and IPv6 literals,
and rejects whitespace and control characters before parsing. The same rules
are applied in the foundry parser and the hosting layer so the two agree.

Dropping an unusable link also erased the consent requirement: nothing was
recorded, so both host paths emitted `response.completed` and a blocked turn
looked successful, reproducing the silent drop this feature exists to fix.
Consent requests are now tracked in a `_ConsentTracker` holding the requests
that were emitted and the ones whose link could not be surfaced. A response
with at least one usable link still terminates as `incomplete`, and a response
where consent was required but no link could be shown terminates as
`response.failed`, matching the connect-time path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 992057d8-78a8-45a7-9201-35af0919b071
Comment thread python/packages/foundry/agent_framework_foundry/_oauth_helpers.py Outdated
Comment thread python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py Outdated
Comment thread python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py Outdated
Comment thread python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py Outdated
@giles17

Copy link
Copy Markdown
Contributor Author

Why didn't the consent item get emitted when the code enters the agent?

Tao Chen (@TaoChenOSU) Because at connect time there's nothing to emit. OBO consent is per-user and per-invocation: the gateway lists the tools fine with app credentials, then demands consent only when a tool is actually called for a specific user. That arrives mid-run as a streaming oauth_consent_request content item rather than an exception, so consent_url_from_error never sees it.

Share the consent-link validator and stop dropping unusable consent
requests at the parser layer.

- Add `agent_framework/_oauth.py` with `validate_oauth_consent_link`,
  the single definition of what makes a consent link renderable. The
  Foundry parser and the Foundry hosting layer had drifted copies of
  these rules; both now delegate to it while keeping their own empty
  string vs `None` return contracts. `foundry_hosting` depends on
  `agent-framework-core` but not on `agent-framework-foundry`, so core
  is the only module both packages can reach.

- Extract `_finish_consent_response` so the agent and workflow paths
  share one definition of the terminal status precedence: a surfaced
  consent link ends the turn `incomplete`, an unusable one ends it
  `failed`, otherwise `completed`. Higher precedence request and
  session persistence failures still apply before it.

- Always surface an `oauth_consent_request` marker from
  `try_parse_oauth_consent_event`, even when the link is missing or
  unusable. Returning empty contents meant the host never recorded the
  request as dropped, so the exact unusable link case this change
  exists to fail was reported as `response.completed` instead. Link
  validation stays in the parser for diagnostics, and the host remains
  the single authority on whether a link is renderable.

Tests cover the shared validator in core, the preserved marker in the
parser, and the existing host side failure path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 992057d8-78a8-45a7-9201-35af0919b071
Resolve conflicts with the resilient long-running agent refactor, which
removed `_to_outputs` and folded its dispatch into an async
`_OutputItemTracker.handle()`, and centralized terminal event emission in
the caller instead of the two inner handlers.

Every conflict takes main's side verbatim. The OAuth consent work is
re-applied on top of the new structure:

- `_ConsentTracker` state moves onto `_OutputItemTracker` as `consent`.
  The tracker is already created once per response and threaded through
  both the agent and workflow paths, so the `consent_tracker` parameter
  threading is no longer needed.
- The `oauth_consent_request` branch moves into `_OutputItemTracker.handle()`,
  ahead of the fallback `else` that logs the unsupported-content warning.
- `_finish_consent_response` hooks the single centralized terminal site,
  replacing `emit_completed(usage=tracker.usage)`, and now passes usage
  through.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 992057d8-78a8-45a7-9201-35af0919b071
A `WorkflowAgent` surfaces an OAuth consent request through
`AgentExecutor.ctx.request_info(...)`, which parks the workflow on a pending
`request_info` event. OAuth consent has no response content type, so nothing a
later turn sends can answer that request. Ending the turn `incomplete` promised
a continuation the workflow cannot honor, and the next turn on the same
conversation restored the parked checkpoint and failed deep in the workflow
machinery with `Unexpected content type while awaiting request info responses`.

Report the block directly instead:

- `_finish_consent_response` takes a `resumable` flag. The agent path stays
  `incomplete`, because a plain agent re-runs on the next turn and picks up the
  newly granted access. The workflow path ends `failed` with a message telling
  the user to grant consent and start a new conversation.
- `_pending_consent_links` reads the consent requests a restored checkpoint is
  parked on straight off `WorkflowCheckpoint.pending_request_info_events`. The
  restore-only run does not replay them as agent response updates, so they are
  not observable from the update stream.
- `_handle_inner_workflow` raises before starting the run when the restored
  checkpoint is parked on consent, letting the caller emit the single terminal
  failure event the same way an unresumable `previous_response_id` does.

The consent link is still surfaced in both cases; only the terminal status
changes. This does not make workflow consent resumable, which needs a matching
input contract in the core workflow layer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 992057d8-78a8-45a7-9201-35af0919b071
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: ResponsesHostServer drops mid-run oauth_consent_request content ('not supported yet'), consent link never reaches the client

4 participants