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
- Start run A. Run A ends in a pause (interrupt).
- Answer the pause. Run B starts as the continuation and finishes.
- 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)
- 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)
Do you intend to try to help solve this bug with your own PR?
None
Terms & Code of Conduct
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
ChatClientopens an old thread. The clientreads 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_FINISHEDevent(
outcome: interrupt), it callsinterruptManager.hydrate(...)for it. It doesnot check the run lineage first. Run B is the child of run A (
parentRunIdpoints 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
ChatClient. Replay the saved events in order:RUN_STARTEDARUN_FINISHEDA (outcome: interrupt)RUN_STARTEDB (parentRunId: A)RUN_FINISHEDA (interrupt, re-emitted on replay)RUN_STARTEDB (parentRunId: A)RUN_FINISHEDB (outcome: success)The interrupt on the pause carries the real binding the server stamps
(
tanstack:interruptBinding). The binding holdsinterruptedRunId: "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 Bfinishes with success. Each line also prints two signals the client holds:
staleInterruptRun— the run the pending interrupt points at. The clientreads it from the interrupt's own surviving binding (
interruptedRunId).newestRun— the newest run the client has processed.The last line is the bug in one row. The client holds an interrupt from
run-A, but the newest run it processed isrun-B. Run B is the child of runA. 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.Control case (proves the fix is safe)
The repro also runs a real live pause. Run A pauses, and no continuation run
exists.
newestRunnever becomes newer than the interrupt's run. This casemust stay
pending=1. A live approval card is real, and the user must stillanswer it.
Compare the two cases:
run-A) is older thannewestRun(run-B). Itmust clear.
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
ChatClient.observeInterruptStatereads the terminal and callshydratewith no lineage check —
packages/ai-client/src/chat-client.ts#L1196-L1220InterruptManager.hydratereplaces its items with no dedup by run —packages/ai-client/src/interrupt-manager.ts#L548Note on a likely fix
parentRunIdis already on the wire. It points from the continuation run backto the run it answered. The resume path sends it. Adapters stamp it on
RUN_STARTED. It is part of the AG-UIRUN_STARTEDkey set. A replayed pausewhose 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)
Do you intend to try to help solve this bug with your own PR?
None
Terms & Code of Conduct