TanStack / TanStack/ai

Rejoining an in-flight run loses the assistant message when the stream starts with reasoning

Open
#1,422 0 comments 0 reactions 1 assignee View on GitHub

@AlemTuzlak is already working on this.

Since Sep 18, 2026.

waiting-on: maintainer
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

  1. useChat({ threadId, persistence: true, connection }), where connection.hydrate reports an
    active run and connection.joinRun replays it.
  2. 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:

  1. StreamProcessor.handleReasoningMessageContentEvent calls ensureAssistantMessage() without a
    preferred id, so the reasoning bubble is created under a generated id, and
    ensureAssistantMessage sets pendingManualMessageId to it.
  2. REJOIN_REBUILD_TRIGGERS in chat-client.ts contains only TEXT_MESSAGE_START,
    TEXT_MESSAGE_CONTENT, TOOL_CALL_START and MESSAGES_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.
  3. handleTextMessageStartEvent then takes the pendingManualMessageId branch and renames
    that message via this.messages.map(...) rather than appending a new one. The message is no
    longer in the array, so nothing is renamed and nothing is created, while messageStates still
    holds the (now renamed) state — so every later TEXT_MESSAGE_CONTENT is 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.