TanStack AI version
@tanstack/ai 0.63.0, @tanstack/ai-client 0.36.0, @tanstack/ai-openai 0.25.1, @tanstack/openai-base 0.12.1, @tanstack/ai-persistence 0.7.1. Also in prod on 0.61.0.
Framework/Library version
React (useChat, persistence: true), withPersistence on Postgres, OpenAI Responses API, gpt-5.6-terra, store: false
Describe the bug and the steps to reproduce it
StreamProcessor doesn't use the message ids the stream carries. In a multi-step agent turn it files one model call's output on another call's assistant message. The server stores one assistant message per call, so the live list and a reload disagree. With persistence: true the client posts its list back and withPersistence saves it (incoming wins by id), so the misfiled copy becomes the stored thread and the model reads it on every later turn.
What goes wrong
Every text adapter (Responses, Chat Completions, Anthropic, Gemini) streams a call's reasoning before anything names the call's message, and only emits TEXT_MESSAGE_START with the first text. The processor then guesses "the active message":
- Reasoning events carry their own id but no owner, and go to the active message: the previous call's.
- A tool call with an unseen
parentMessageId also falls back to the active message. AG-UI's reference client creates it instead (resolveOrCreateAssistantMessage: "parentMessageId not found, create new, keyed by parentMessageId").
- A call that reasons and calls tools but writes no text keeps its placeholder id until the next call's
TEXT_MESSAGE_START renames it. The whole call ends up under the next call's id.
- The step signature re-sent from
response.completed goes to the active step, not the step it names (entityId / stepId), leaving an empty thinking part.
TanStack's inbound path already assigns reasoning to the assistant message that follows it (convertOwnMessages). The live processor assigns it to the one before, and uiMessagesToWire then derives the reasoning ids from that wrong owner (${messageId}-reasoning-...).
Context
Same area as #1247, #903, #1344 and #1422: the processor inferring which message an event belongs to from event order.
We hit the symptom first in #1345. The "same reasoning id, two different encrypted blobs seconds apart" there is the adapter sending each step's signature twice, with the first copy landing on the previous call. All 32 duplicated reasoning ids in our store match: same turn, the call right before, earlier blob on the earlier message. #1365 makes those threads replayable, but they still get created.
Repro
Sandbox linked below (output in the preview): TanStack's real OpenAI adapter, client and persistence, with only OpenAI's HTTP response scripted, so no API key. One turn, three calls:
- reasoning
rs_A, text, call call_S
- call
call_G, no text
- reasoning
rs_B, text
live: [rs_A, text, call_S, result, call_G, result, rs_B] [text, (empty reasoning)]
reload: [rs_A, text, call_S, result] [call_G, result] [rs_B, text]
Second turn through ChatClient with persistence: true, stored call 1:
after turn 1: reasoning=[rs_A] calls=[call_S]
after turn 2: reasoning=[rs_A,rs_B] calls=[call_S,call_G] plus tool(call_G) appended again
Fix
Route by identity wherever the stream provides it:
TOOL_CALL_START.parentMessageId names the message. Unseen means new (or it names this call's reasoning placeholder).
- A signature goes to the message that owns its step.
- Reasoning has no owner id, so its owner is the message of the same model call. After an intermediate
RUN_FINISHED, new reasoning opens a placeholder that the call's first named message claims through the existing pendingManualMessageId rename.
I have this implemented with five processor tests covering the cases above. Four compare the live list with the engine's stored messages for a real chat() turn. All five fail on main and pass with the fix, and ai, ai-client, ai-persistence, ai-openai and the e2e suite pass with no new failures.
One open question before a PR: the reasoning part leans on the per-call RUN_FINISHED, which #1201 proposed collapsing. Would you rather adapters stamp the owning message id on reasoning events so the processor never has to infer it? Happy to do it either way.
Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
https://codesandbox.io/s/q5733c
Screenshots or Videos (Optional)
No response
Do you intend to try to help solve this bug with your own PR?
No response
Terms & Code of Conduct
TanStack AI version
@tanstack/ai 0.63.0, @tanstack/ai-client 0.36.0, @tanstack/ai-openai 0.25.1, @tanstack/openai-base 0.12.1, @tanstack/ai-persistence 0.7.1. Also in prod on 0.61.0.
Framework/Library version
React (useChat, persistence: true), withPersistence on Postgres, OpenAI Responses API, gpt-5.6-terra, store: false
Describe the bug and the steps to reproduce it
StreamProcessordoesn't use the message ids the stream carries. In a multi-step agent turn it files one model call's output on another call's assistant message. The server stores one assistant message per call, so the live list and a reload disagree. Withpersistence: truethe client posts its list back andwithPersistencesaves it (incoming wins by id), so the misfiled copy becomes the stored thread and the model reads it on every later turn.What goes wrong
Every text adapter (Responses, Chat Completions, Anthropic, Gemini) streams a call's reasoning before anything names the call's message, and only emits
TEXT_MESSAGE_STARTwith the first text. The processor then guesses "the active message":parentMessageIdalso falls back to the active message. AG-UI's reference client creates it instead (resolveOrCreateAssistantMessage: "parentMessageId not found, create new, keyed by parentMessageId").TEXT_MESSAGE_STARTrenames it. The whole call ends up under the next call's id.response.completedgoes to the active step, not the step it names (entityId/stepId), leaving an empty thinking part.TanStack's inbound path already assigns reasoning to the assistant message that follows it (
convertOwnMessages). The live processor assigns it to the one before, anduiMessagesToWirethen derives the reasoning ids from that wrong owner (${messageId}-reasoning-...).Context
Same area as #1247, #903, #1344 and #1422: the processor inferring which message an event belongs to from event order.
We hit the symptom first in #1345. The "same reasoning id, two different encrypted blobs seconds apart" there is the adapter sending each step's signature twice, with the first copy landing on the previous call. All 32 duplicated reasoning ids in our store match: same turn, the call right before, earlier blob on the earlier message. #1365 makes those threads replayable, but they still get created.
Repro
Sandbox linked below (output in the preview): TanStack's real OpenAI adapter, client and persistence, with only OpenAI's HTTP response scripted, so no API key. One turn, three calls:
rs_A, text, callcall_Scall_G, no textrs_B, textSecond turn through
ChatClientwithpersistence: true, stored call 1:Fix
Route by identity wherever the stream provides it:
TOOL_CALL_START.parentMessageIdnames the message. Unseen means new (or it names this call's reasoning placeholder).RUN_FINISHED, new reasoning opens a placeholder that the call's first named message claims through the existingpendingManualMessageIdrename.I have this implemented with five processor tests covering the cases above. Four compare the live list with the engine's stored messages for a real
chat()turn. All five fail on main and pass with the fix, andai,ai-client,ai-persistence,ai-openaiand the e2e suite pass with no new failures.One open question before a PR: the reasoning part leans on the per-call
RUN_FINISHED, which #1201 proposed collapsing. Would you rather adapters stamp the owning message id on reasoning events so the processor never has to infer it? Happy to do it either way.Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
https://codesandbox.io/s/q5733c
Screenshots or Videos (Optional)
No response
Do you intend to try to help solve this bug with your own PR?
No response
Terms & Code of Conduct