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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    pythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflowtriageUsage: [Issues], Target: All issues that still need to be triaged

    Type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions