Skip to content

A resolved interrupt comes back as pending when a fresh client replays run history #1368

Description

@tomer953

TanStack AI version

0.26.0

Framework/Library version

Framework-agnostic

Describe the bug and the steps to reproduce it

Describe the bug and the steps to reproduce it

An interrupt is a pause. The chat stops and waits for the user. A tool approval
is one example.

The problem happens when a new ChatClient opens an old thread. The client
reads the saved events again from the start. One of those events is an old
pause from run A. Run A was already answered in an earlier session. Run B is the
continuation run that answered it, and run B already finished.

The client shows the old pause as if it is still live. The approval card comes
back on screen. It never goes away.

The cause is in the client. When the client reads the old RUN_FINISHED event
(outcome: interrupt), it calls interruptManager.hydrate(...) for it. It does
not check the run lineage first. Run B is the child of run A (parentRunId
points from B to A). That link proves run A was already answered. The client
ignores the link, so it treats an old pause as a live one.

No backend is necessary to see the fault.

Steps to reproduce

  1. Start run A. Run A ends in a pause (interrupt).
  2. Answer the pause. Run B starts as the continuation and finishes.
  3. Open the thread with a new ChatClient. Replay the saved events in order:
    • RUN_STARTED A
    • RUN_FINISHED A (outcome: interrupt)
    • RUN_STARTED B (parentRunId: A)
    • RUN_FINISHED A (interrupt, re-emitted on replay)
    • RUN_STARTED B (parentRunId: A)
    • RUN_FINISHED B (outcome: success)
  4. Read the pending-interrupt count after each event.

The interrupt on the pause carries the real binding the server stamps
(tanstack:interruptBinding). The binding holds interruptedRunId: "run-A".
The client has the lineage data. It still does not use it.

What happens

The pause from run A stays pending. It never clears, even after run B
finishes with success. Each line also prints two signals the client holds:

  • staleInterruptRun — the run the pending interrupt points at. The client
    reads it from the interrupt's own surviving binding (interruptedRunId).
  • newestRun — the newest run the client has processed.
RUN_STARTED   A                      -> pending=0  staleInterruptRun=-      newestRun=run-A
RUN_FINISHED  A (interrupt)          -> pending=1  staleInterruptRun=run-A  newestRun=run-A
RUN_STARTED   B                      -> pending=1  staleInterruptRun=run-A  newestRun=run-B
RUN_FINISHED  A (interrupt, re-emit) -> pending=1  staleInterruptRun=run-A  newestRun=run-B
RUN_STARTED   B                      -> pending=1  staleInterruptRun=run-A  newestRun=run-B
RUN_FINISHED  B (success)            -> pending=1  staleInterruptRun=run-A  newestRun=run-B

The last line is the bug in one row. The client holds an interrupt from
run-A, but the newest run it processed is run-B. Run B is the child of run
A. So the client had a clear "B superseded A" signal and did not use it.

What should happen

The pause from run A must not show as pending. Run B exists only because the
user already answered that pause. The pending count must return to 0.

RUN_FINISHED  B (success)            -> pending=0

Control case (proves the fix is safe)

The repro also runs a real live pause. Run A pauses, and no continuation run
exists. newestRun never becomes newer than the interrupt's run. This case
must stay pending=1. A live approval card is real, and the user must still
answer it.

RUN_STARTED   A                      -> pending=0  staleInterruptRun=-      newestRun=run-A
RUN_FINISHED  A (interrupt)          -> pending=1  staleInterruptRun=run-A  newestRun=run-A

Compare the two cases:

  • BUG: the interrupt's run (run-A) is older than newestRun (run-B). It
    must clear.
  • CONTROL: the interrupt's run (run-A) is the newest run. It must stay.

The two cases together prove the correct behavior. A fix must clear only an
interrupt whose run is the parent of a later run. It must never clear a live
one.

Where it happens

Note on a likely fix

parentRunId is already on the wire. It points from the continuation run back
to the run it answered. The resume path sends it. Adapters stamp it on
RUN_STARTED. It is part of the AG-UI RUN_STARTED key set. A replayed pause
whose run is the parent of a later run can be suppressed on that fact alone.

Do not order by run id. An unrelated run on the same thread is also newer. That
run must not clear a live approval card. The control case above shows why.

Your Minimal, Reproducible Example - (Sandbox Highly Recommended)

https://codesandbox.io/s/6pswkq

Screenshots or Videos (Optional)

Image

Do you intend to try to help solve this bug with your own PR?

None

Terms & Code of Conduct

  • I agree to follow this project's Code of Conduct
  • I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    has-prAn open PR references this issuewaiting-on: maintainerThe ball is in the maintainers’ court

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions