Repository navigation
Rejoining an in-flight run loses the assistant message when the stream starts with reasoning #1422
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.
Activity
AlemTuzlak commentedon Sep 25, 2026
This is fixed on
mainby #1357.REASONING_MESSAGE_CONTENTis now inREJOIN_REBUILD_TRIGGERSinpackages/ai-client/src/chat-client.ts, so a reasoning-first replay keeps the assistant message. Closing. Please reopen if you still see it on the latest release.aayushbtw commentedon Oct 29, 2026
Still happens on
@tanstack/ai-client1.0.0 andmainwhen reasoning arrives only as an encrypted value (Azure OpenAI Responses, no readable summary). There's noREASONING_MESSAGE_CONTENT, so the first rebuild trigger isREASONING_ENCRYPTED_VALUE. By then,REASONING_MESSAGE_START(with metadata) has already created the replay's assistant message, anddropTrailingInFlightAssistant()removes it. The answer never renders. Fix and tests in #1942: drop only the assistant message hydrated before the replay, by id, so no chunk order can hit this.