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:
- 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.
- 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:
- 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.
- 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.
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 handedRUN_FINISHED{outcome:{type:"interrupt", …}}again, rebuilds the same card, answers it, and is rejected again.The neighbouring
APPROVAL_RESUME_CANCELLEDbranch already does the right thing: it calls_clear_thread_snapshot_interruptso the resolved interrupt stops being replayed.NOT_FOUND— the more terminal of the two — does not.In
_agent_run.pyon this pin, the resume-error branch computesshould_clear_tool_approval_statefor both codes (line ~2232), clears the tool-approval state for both (line ~2238), and then clears the snapshot interrupt only forAPPROVAL_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:
pendingInterruptsis 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_FOUNDwhile a snapshot exists will do. Two reliable ones:In both cases, after the
NOT_FOUNDrun,GET /threads/{id}still returns the interrupt and the nextconnect()re-offers the card.Run side by side against
main@d56e81357e5179b6f916b180c0cb34ea2e73c80bwith a minimal in-memory snapshot store, the contrast is exact — the same thread, the same surfaced interrupt, only the resume status differs:Impact
NOT_FOUNDis 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 stockagent_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_FOUNDas terminal for the interrupt it names, the wayAPPROVAL_RESUME_CANCELLEDalready 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_FOUNDstill holds the interrupt as pending and is blocked from sending anything. If the protocol intendsNOT_FOUNDto 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.