Skip to content

Python: [Bug]: APPROVAL_RESUME_NOT_FOUND leaves the interrupt in the thread snapshot, so the unanswerable approval card is re-offered on every connect #7570

Description

Description

Summary

When a resume is rejected with APPROVAL_RESUME_NOT_FOUND, the pending-approval registry has no entry for that interrupt and never will — the code is terminal by definition. But the thread snapshot still records the interrupt, so a client that reconnects is handed RUN_FINISHED{outcome:{type:"interrupt", …}} again, rebuilds the same card, answers it, and is rejected again.

The neighbouring APPROVAL_RESUME_CANCELLED branch already does the right thing: it calls _clear_thread_snapshot_interrupt so the resolved interrupt stops being replayed. NOT_FOUND — the more terminal of the two — does not.

In _agent_run.py on this pin, the resume-error branch computes should_clear_tool_approval_state for both codes (line ~2232), clears the tool-approval state for both (line ~2238), and then clears the snapshot interrupt only for APPROVAL_RESUME_CANCELLED (line ~2240). The helper it would need is already imported and already handles a subset of ids.

Why it is not self-limiting

Two properties turn "one rejected resume" into a thread the user cannot leave:

  1. The card is rebuilt from the snapshot on every connect, so it survives reloads and replica changes — it is not client state that a refresh clears.
  2. A client with an open interrupt cannot simply send a message instead. The AG-UI client refuses to start a run while pendingInterrupts is non-empty, so a surface that turns a send into a resume (any HITL client that must address open interrupts first) has no way out: the answer is rejected and the message is converted into another rejected answer.

The degrade text a host typically shows on this code — "resend your answer as a new message" — is therefore advice the protocol has made impossible to follow.

Observed in production on our AG-UI app: ten rejected resumes on one thread across four deployments and roughly two hours, after which the conversation was abandoned. The user's transcript was intact the whole time; only the interrupt was unanswerable.

Reproduction

Any path that produces APPROVAL_RESUME_NOT_FOUND while a snapshot exists will do. Two reliable ones:

  1. Process/replica change. Register an approval, restart the process (the in-memory pending-approval registry is not rehydrated from the snapshot), then resume. The snapshot still carries the interrupt; the registry does not.
  2. A resume consumed by a run that then dies. The entry is consumed at ingress, the run fails before applying it, and the retry finds nothing pending.

In both cases, after the NOT_FOUND run, GET /threads/{id} still returns the interrupt and the next connect() re-offers the card.

Run side by side against main @ d56e81357e5179b6f916b180c0cb34ea2e73c80b with a minimal in-memory snapshot store, the contrast is exact — the same thread, the same surfaced interrupt, only the resume status differs:

--- APPROVAL_RESUME_CANCELLED (the code that DOES clear)
  interrupt surfaced              : ['c1']
  snapshot.interrupt (after run 1): ['c1']
  resume -> RUN_ERROR code        : ['APPROVAL_RESUME_CANCELLED']
  snapshot.interrupt (after resume): CLEARED

--- APPROVAL_RESUME_NOT_FOUND (the code that does NOT)
  interrupt surfaced              : ['c1']
  snapshot.interrupt (after run 1): ['c1']
  resume -> RUN_ERROR code        : ['APPROVAL_RESUME_NOT_FOUND']
  snapshot.interrupt (after resume): ['c1']

Impact

NOT_FOUND is a rejection the user could otherwise recover from by simply continuing the conversation. Leaving the interrupt asserted converts it into a dead thread — every answer rejected, every message converted into a rejected answer — with no path back that does not involve discarding the conversation.

Prior art (checked before filing)

Verified against: live main @ d56e81357e5179b6f916b180c0cb34ea2e73c80b (2026-08-04T17:23Z, core 1.13.0) — installed fresh and reproduced with the stock agent_framework_ag_ui.AgentFrameworkAgent, no application wrappers of any kind.

Code Sample

Error Messages / Stack Traces

Package Versions

agent-framework-core: 1.13.0, agent-framework-ag-ui: 1.0.1

Python Version

No response

Additional Context

Suggested direction

Treat APPROVAL_RESUME_NOT_FOUND as terminal for the interrupt it names, the way APPROVAL_RESUME_CANCELLED already is: clear those ids from the thread snapshot in the same branch that clears the tool-approval state, so the next connect does not re-offer a card no registry can satisfy. The helper and the id set are both already in hand at that point.

Worth considering alongside it: a client that has just been told NOT_FOUND still holds the interrupt as pending and is blocked from sending anything. If the protocol intends NOT_FOUND to be recoverable, the rejection needs to release the client's pending state as well — otherwise the recovery it advises cannot be performed from the surface that received it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

ag-uiUsage: [Issues, PRs], Target: AG-UI protocol integrationpythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions