Skip to content

fix(dotnet): resume a tool approval without a toolCall echo - #2930

Merged
ranst91 merged 3 commits into
ag-ui-protocol:mainfrom
NathanTarbert:fix/issue-2815-dotnet-approval-resume
Oct 27, 2026
Merged

ranst91 merged 3 commits into
ag-ui-protocol:mainfrom
NathanTarbert:fix/issue-2815-dotnet-approval-resume

Conversation

@NathanTarbert

Copy link
Copy Markdown
Contributor

Thanks @yousuf64 — filing this alongside the other .NET interrupt issues made the pattern much easier to see.

A resume entry whose payload carried no toolCall was rejected at RunAgentInputExtensions.cs:184-188 before the approval branch ran, so it became a generic InterruptResponseContent and the pending approval was never answered. The response schema the server advertises at ChatResponseUpdateAGUIExtensions.cs:17-26 requires only approved, so a client building its payload from that schema could not resume an approval at all.

TryDecodeToolApprovalResume now splits. A payload carrying toolCall decodes exactly as before, so deployed first-party .NET clients are unaffected. A payload without one is resolved against the run's messages: a toolCallId on the payload matches against the pending set, otherwise a single unanswered approval-gated call binds unambiguously, otherwise the run fails with an error naming the interrupt id and every candidate call id. Zero pending calls still fall through to a generic interrupt, which is why the existing pin at InterruptContentTest.cs:129 stays green. Each entry removes the call it bound, so two entries cannot claim the same call, and a toolCallId naming an already-answered call returns null rather than approving it twice.

Recovering from the messages is the mechanism the spec already names: the uncovered-interrupt rule in docs/spec/draft/basic/patterns/interrupt-resume.mdx says a producer may notice an open interrupt because the messages carry an approval-gated tool call with neither a result nor an entry answering it. The same section requires a resuming run to carry the accumulated messages, so a resume sent with an empty message list still cannot be recovered, which is the one case in the original report that stays unsupported.

AGUIResume.Status is now read where the response is built, so a cancelled entry produces a declined approval regardless of its payload. That applies to the echo path too, where a cancelled entry carrying approved:true previously executed the tool.

One behavior worth naming: the ambiguity error is thrown during request conversion, which happens before the event stream opens, so it reaches the operator as a 500 and in server logs rather than as a RUN_ERROR. That is how every exception on that path already surfaces, including the existing NotSupportedException and FormatException from message conversion, so changing it belongs in its own PR.

The pending set is filtered by client tool name, which approximates the emit-side rule at Microsoft.Extensions.AI 10.6.0. Once the package moves to 10.9.0 the filter should use RequiresConfirmation, which is the predicate the emit side will use, and the two converge exactly.

ToolApprovalResumeWithoutEchoTest.cs adds 7 tests. Four cover the fix, including one that reads responseSchema off a live interrupt, builds a payload from its own required list and feeds it back. Three pin the boundaries: the echo path still resuming, a resume with no messages having nothing to recover from, and a resuming run carrying exactly one unresolved call.

AGUI.Server.UnitTests goes 168 to 175. All seven .NET projects green on net10.0 locally; CI covers net8.0 and net9.0.

  • Failing test written and confirmed failing before the fix
  • Fix applied, tests pass
  • Full suite passes
  • Build succeeds

Closes #2815

…otocol#2815)

A resume entry whose payload carried no toolCall was rejected before the
approval branch ran, so it became a generic InterruptResponseContent and
the pending approval was never answered. The response schema the server
advertises requires only `approved`, so a client following that contract
could not resume an approval at all.

TryDecodeToolApprovalResume now splits. A payload carrying toolCall
decodes exactly as before, so deployed first-party clients are
unaffected. A payload without one is resolved against the run's messages:
a toolCallId on the payload matches the pending set, otherwise a single
unanswered approval-gated call binds unambiguously, otherwise the run
fails with an error naming the interrupt id and every candidate call id.
Zero pending calls still fall through to a generic interrupt. Each entry
removes the call it bound, so two entries cannot claim the same call, and
a toolCallId naming an already-answered call returns null rather than
approving it twice.

AGUIResume.Status is now read where the response is built, so a cancelled
entry produces a declined approval regardless of its payload. That
applies to the echo path too, where a cancelled entry carrying
approved:true previously executed the tool.

The pending set is filtered by client tool name, which approximates the
emit side at Microsoft.Extensions.AI 10.6.0. Once the package moves to
10.9.0 the filter should use RequiresConfirmation so both sides share one
predicate.
@NathanTarbert
NathanTarbert requested a review from a team as a code owner October 1, 2026 21:36
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Python Preview Packages

Version 0.0.0.dev1793106201 published to TestPyPI.

Warning: These packages are built from contributor code that may not yet have been vetted for correctness or security. Install at your own risk and do not use in production.

Install with uv

Add the TestPyPI index to your pyproject.toml:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = true

Then install the packages you need:

# Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1793106201' --index testpypi

# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1793106201' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1793106201' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1793106201' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1793106201' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1793106201' --index testpypi

Install with pip

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  ag-ui-protocol==0.0.0.dev1793106201

Use --extra-index-url https://pypi.org/simple/ so pip can resolve
transitive dependencies (pydantic, fastapi, etc.) from real PyPI.


Commit: bfa3ebf

@pkg-pr-new

pkg-pr-new Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@ag-ui/a2a-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a-middleware@2930

@ag-ui/a2ui-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-middleware@2930

@ag-ui/event-throttle-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/event-throttle-middleware@2930

@ag-ui/mcp-apps-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-apps-middleware@2930

@ag-ui/mcp-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-middleware@2930

@ag-ui/a2a

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a@2930

@ag-ui/adk-js

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/adk-js@2930

@ag-ui/adk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/adk@2930

@ag-ui/ag2

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/ag2@2930

@ag-ui/agno

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/agno@2930

@ag-ui/antigravity

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/antigravity@2930

@ag-ui/aws-strands

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/aws-strands@2930

@ag-ui/claude-agent-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-agent-sdk@2930

@ag-ui/claude-managed-agents

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-managed-agents@2930

@ag-ui/crewai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/crewai@2930

@ag-ui/langchain

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langchain@2930

@ag-ui/langgraph

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langgraph@2930

@ag-ui/llamaindex

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/llamaindex@2930

@ag-ui/mastra

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mastra@2930

@ag-ui/pydantic-ai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/pydantic-ai@2930

@ag-ui/vercel-ai-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/vercel-ai-sdk@2930

@ag-ui/watsonx

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/watsonx@2930

@ag-ui/a2ui-toolkit

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-toolkit@2930

create-ag-ui-app

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/create-ag-ui-app@2930

@ag-ui/client

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/client@2930

@ag-ui/core

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/core@2930

@ag-ui/encoder

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/encoder@2930

@ag-ui/proto

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/proto@2930

commit: ffe8307

@BenTaylorDev

Copy link
Copy Markdown
Contributor

One pending call works well, and the tests fail without the change. Some gaps:

  1. Parallel approvals. The schema only asks for approved, so a client that follows it sends two bare entries, and ResolvePendingApprovalCall throws at :351. Each interrupt already has its own id, so could we map the interrupt id to its call? The other option is to put toolCallId in the schema.
  2. With fix(dotnet): keep server approval interrupts when client tools are declared #2926, a single approval also breaks. The plain calls fix(dotnet): keep server approval interrupts when client tools are declared #2926 stops interrupting have no result, and CollectPendingApprovalCalls counts them as pending. So one bare {approved:true} hits the ambiguity error. The two PRs need the same rule.
  3. A non-approval interrupt with an approved bool in its payload gets taken as a tool approval.

Smaller: collect the echo entries before you resolve the bare ones, so order does not matter. Also, #2815's repro sends messages:[]. That still finds no pending call, so I would say "refs" instead of "closes".

@NathanTarbert

Copy link
Copy Markdown
Contributor Author

Hey @BenTaylorDev,

Pushed a follow-up. A bare {approved} entry now binds only to the call its interrupt was raised for, matched through FICC's approval request id, and entries with a toolCall echo or toolCallId resolve first. Parallel bare approvals each land on their own call, a call is only answered once per resume even if two entries name it, and a non-approval interrupt that happens to carry an approved field no longer approves anything. There's also a round trip through the real FICC, so a change to how MEAI mints that id would fail CI rather than slip through.

A resume entry whose payload carried `approved` and `toolCallId` was matched
against pending calls without checking that its interrupt was an approval
interrupt. A custom interrupt answered with that shape was turned into a tool
approval, so FunctionInvokingChatClient ran the tool and the custom handler
never saw its answer; a `toolCallId` naming a call outside the history threw
during request conversion.

The interrupt id is now authoritative: an id without the FICC approval prefix
never approves anything and stays a generic interrupt response, and a
`toolCallId` that contradicts the approval interrupt's own call is rejected.
Test summaries that still described pre-fix behaviour are rewritten.
@ranst91
ranst91 merged commit d3f0283 into ag-ui-protocol:main Oct 27, 2026
51 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[dotnet] Tool-approval resume without toolCall echo degrades to generic interrupt

3 participants