Rejoining an in-flight run loses the assistant message when the stream starts with reasoning
@AlemTuzlak is already working on this.
Since Sep 18, 2026.
- Dominant language
- TypeScript
- Stars
- 3.1k
- Forks
- 331
- Avg merge
- 1d 22h
- Merged PRs (30d)
- 160
Description
Rejoining an in-flight run loses the assistant message when the stream starts with reasoning
Packages: @tanstack/ai-client 0.31.1 (trigger set unchanged in 0.32.1), @tanstack/ai 0.54.0
What happens
A chat client with persistence: true hydrates, finds a run still in flight, and rejoins it with
connection.joinRun. If the replayed run emits reasoning before text, the assistant message is
removed from the transcript the moment the text begins, and the answer never appears. The run
completes normally on the server; only the client loses it.
With no reasoning in the stream, the same rejoin renders correctly — so this only shows up with
thinking/reasoning models.
Reproduction
useChat({ threadId, persistence: true, connection }), whereconnection.hydratereports an
active run andconnection.joinRunreplays it.- Replay this chunk sequence (what an adapter emits for a reasoning turn):
RUN_STARTED
REASONING_START / REASONING_MESSAGE_START
REASONING_MESSAGE_CONTENT × n
REASONING_MESSAGE_END / REASONING_END
TEXT_MESSAGE_START (messageId: "msg_1")
TEXT_MESSAGE_CONTENT × n
TEXT_MESSAGE_END
RUN_FINISHED
Observed: the transcript holds assistant[thinking] while the reasoning streams, then loses the
whole assistant message at TEXT_MESSAGE_START; the text that follows renders nothing.
Expected: the thinking part stays and the answer streams into the same message.
Cause
Three pieces, all reached only on the rejoin path:
StreamProcessor.handleReasoningMessageContentEventcallsensureAssistantMessage()without a
preferred id, so the reasoning bubble is created under a generated id, and
ensureAssistantMessagesetspendingManualMessageIdto it.REJOIN_REBUILD_TRIGGERSinchat-client.tscontains onlyTEXT_MESSAGE_START,
TEXT_MESSAGE_CONTENT,TOOL_CALL_STARTandMESSAGES_SNAPSHOT. So the first text chunk fires
dropTrailingInFlightAssistant(), which removes the trailing assistant message — but on a
reasoning stream that message is the one this same replay built moments earlier, not a stale
hydrated one.handleTextMessageStartEventthen takes thependingManualMessageIdbranch and renames
that message viathis.messages.map(...)rather than appending a new one. The message is no
longer in the array, so nothing is renamed and nothing is created, whilemessageStatesstill
holds the (now renamed) state — so every laterTEXT_MESSAGE_CONTENTis applied to a message
that is not in the transcript.
Suggested fix
Treat reasoning content as a rebuild trigger, so the drop happens before the replay builds anything
— the same order the no-reasoning case already gets:
const REJOIN_REBUILD_TRIGGERS = new Set([
'TEXT_MESSAGE_START',
'TEXT_MESSAGE_CONTENT',
'TOOL_CALL_START',
'MESSAGES_SNAPSHOT',
'REASONING_MESSAGE_CONTENT',
])
Verified in a browser against 0.31.1: with that one entry added, the thinking part survives, the
message is renamed to the stream's own message id at TEXT_MESSAGE_START, and the answer streams
in as it does without reasoning. This also matches the documented intent of the trigger set —
"dropped only when real content arrives" — since reasoning content is content.
A narrower alternative would be for dropTrailingInFlightAssistant to skip a message the current
replay created, or for the reasoning path to clear pendingManualMessageId when its message is
dropped; the trigger-set change is the smallest of the three.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.