TanStack AI version
0.49.1
Framework/Library version
N/A — not framework-specific. This is a bug in @tanstack/ai's StreamProcessor itself; reproduces with zero framework involved (see minimal reproduction link below). Originally surfaced via @tanstack/ai-vue 0.19.1, but that's incidental — @tanstack/ai-client: 0.28.0.
Node.js v22.22.3 used to run the standalone repro.
Describe the bug and the steps to reproduce it
When a TOOL_CALL_START event's parentMessageId references an assistant message that hasn't had a TEXT_MESSAGE_START yet — the normal AG-UI shape for "the agent calls a tool, then explains the result, all as one assistant turn" — the first TEXT_MESSAGE_CONTENT delta of the text that follows the tool call is silently dropped from the rendered message. Every delta after the first renders correctly; only the first chunk goes missing.
This isn't an edge case — "tool call(s), then a final answer, all as one assistant turn" is one of the most common AG-UI event shapes, and it's exactly the shape any UI built to visualize live tool-call activity needs to handle.
Root cause
In StreamProcessor (packages/ai/src/activities/chat/stream/processor.ts):
TOOL_CALL_START arrives with parentMessageId: "msg-1", but no TEXT_MESSAGE_START for "msg-1" has been seen yet.
handleToolCallStartEvent calls ensureAssistantMessage("msg-1"). Since no message/state exists for that id yet, it falls through to the "backward-compat auto-create" branch (intended for callers using process() without ever sending TEXT_MESSAGE_START), which sets this.pendingManualMessageId = "msg-1".
handleToolCallStartEvent then sets state.hasToolCallsSinceTextStart = true on that message's state.
- Later, the real
TEXT_MESSAGE_START for "msg-1" arrives (the text that follows the tool call). Because pendingManualMessageId is still set to "msg-1", handleTextMessageStartEvent takes its Case 1 branch ("a manual message was created via startAssistantMessage()") instead of Case 2 ("message already exists"). Case 1 just clears pendingManualMessageId and returns.
- Only Case 2 resets
hasToolCallsSinceTextStart, currentSegmentText, and lastEmittedText. Case 1 never does. So all three are left over from the tool-call phase: hasToolCallsSinceTextStart is still true, currentSegmentText is still "".
- First
TEXT_MESSAGE_CONTENT delta arrives. In handleTextMessageContentEvent, the "is this a new segment after tool calls" check is:
if (state.hasToolCallsSinceTextStart && previousSegment.length > 0 && this.isNewTextSegment(chunk, previousSegment))
previousSegment (currentSegmentText) is "", so previousSegment.length > 0 is false — the check is skipped this time, and the delta is appended correctly: currentSegmentText = "" + delta1 = delta1. Looks fine so far.
- Second
TEXT_MESSAGE_CONTENT delta arrives. Now previousSegment is delta1 (non-empty), hasToolCallsSinceTextStart is still true (never reset), and isNewTextSegment(...) returns true (it's a constant true at the moment — see processor.ts:2120-2125) — so the reset branch fires: currentSegmentText and lastEmittedText are wiped back to "", discarding delta1, before delta2 is appended. The rendered message ends up missing its first chunk permanently — nothing recovers it later, including at TEXT_MESSAGE_END, since the flush only ever writes out currentSegmentText as it stands at that point.
I confirmed this is independent of parentMessageId's exact value — omitting it, or pointing it at a fresh/different id, doesn't help. Any TOOL_CALL_START before the first TEXT_MESSAGE_START in a run takes the same auto-create path and sets pendingManualMessageId, so the bug reproduces regardless.
Expected vs. actual
- Expected:
"Hello, world."
- Actual:
"world." — the first delta ("Hello, ") is silently lost.
Workaround
Emit an empty TEXT_MESSAGE_START + TEXT_MESSAGE_END pair for the assistant message before any TOOL_CALL_START references it as parentMessageId. That routes the first TEXT_MESSAGE_START through handleTextMessageStartEvent's "new message from the stream" branch (Case 3) instead of the auto-create path, so pendingManualMessageId never gets set, and the later real TEXT_MESSAGE_START correctly hits Case 2 and resets hasToolCallsSinceTextStart before the first content delta arrives.
Suggested fix direction
Case 1 (pendingManualMessageId) in handleTextMessageStartEvent should probably perform the same hasToolCallsSinceTextStart reset that Case 2 does, since a tool call can legitimately arrive and mark hasToolCallsSinceTextStart = true on a message before its "real" TEXT_MESSAGE_START shows up — the two code paths shouldn't diverge on that particular reset.
Existing test coverage gap
packages/ai/tests/stream-processor.test.ts has a test covering almost this exact scenario — "should preserve the server message id in tool-first flows when parentMessageId is provided" — but it sends the reply as a single TEXT_MESSAGE_CONTENT event, so it never triggers the bug (the first delta is only dropped starting from the second delta onward, per step 6-7 above).
Related (not a duplicate)
PR #903 touches the exact same code path (placeholder-message creation before TEXT_MESSAGE_START) but fixes a different symptom — toolCallToMessage/structuredMessageIds not being remapped on the placeholder-to-real-id rename, orphaning a TOOL_CALL_RESULT. It was closed without merging, and wouldn't have fixed this bug even if merged (it doesn't touch hasToolCallsSinceTextStart/currentSegmentText/lastEmittedText), but it's evidence this general area is known to be fragile.
Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
https://stackblitz.com/edit/node-cx8hsdch?file=package.json,index.js
Screenshots or Videos (Optional)
No response
Do you intend to try to help solve this bug with your own PR?
Yes, I am also opening a PR that solves the problem along side this issue.
Terms & Code of Conduct
TanStack AI version
0.49.1
Framework/Library version
N/A — not framework-specific. This is a bug in @tanstack/ai's StreamProcessor itself; reproduces with zero framework involved (see minimal reproduction link below). Originally surfaced via @tanstack/ai-vue 0.19.1, but that's incidental — @tanstack/ai-client: 0.28.0.
Node.js v22.22.3 used to run the standalone repro.
Describe the bug and the steps to reproduce it
When a
TOOL_CALL_STARTevent'sparentMessageIdreferences an assistant message that hasn't had aTEXT_MESSAGE_STARTyet — the normal AG-UI shape for "the agent calls a tool, then explains the result, all as one assistant turn" — the firstTEXT_MESSAGE_CONTENTdelta of the text that follows the tool call is silently dropped from the rendered message. Every delta after the first renders correctly; only the first chunk goes missing.This isn't an edge case — "tool call(s), then a final answer, all as one assistant turn" is one of the most common AG-UI event shapes, and it's exactly the shape any UI built to visualize live tool-call activity needs to handle.
Root cause
In
StreamProcessor(packages/ai/src/activities/chat/stream/processor.ts):TOOL_CALL_STARTarrives withparentMessageId: "msg-1", but noTEXT_MESSAGE_STARTfor"msg-1"has been seen yet.handleToolCallStartEventcallsensureAssistantMessage("msg-1"). Since no message/state exists for that id yet, it falls through to the "backward-compat auto-create" branch (intended for callers usingprocess()without ever sendingTEXT_MESSAGE_START), which setsthis.pendingManualMessageId = "msg-1".handleToolCallStartEventthen setsstate.hasToolCallsSinceTextStart = trueon that message's state.TEXT_MESSAGE_STARTfor"msg-1"arrives (the text that follows the tool call). BecausependingManualMessageIdis still set to"msg-1",handleTextMessageStartEventtakes its Case 1 branch ("a manual message was created viastartAssistantMessage()") instead of Case 2 ("message already exists"). Case 1 just clearspendingManualMessageIdand returns.hasToolCallsSinceTextStart,currentSegmentText, andlastEmittedText. Case 1 never does. So all three are left over from the tool-call phase:hasToolCallsSinceTextStartis stilltrue,currentSegmentTextis still"".TEXT_MESSAGE_CONTENTdelta arrives. InhandleTextMessageContentEvent, the "is this a new segment after tool calls" check is:previousSegment(currentSegmentText) is"", sopreviousSegment.length > 0isfalse— the check is skipped this time, and the delta is appended correctly:currentSegmentText = "" + delta1 = delta1. Looks fine so far.TEXT_MESSAGE_CONTENTdelta arrives. NowpreviousSegmentisdelta1(non-empty),hasToolCallsSinceTextStartis stilltrue(never reset), andisNewTextSegment(...)returnstrue(it's a constanttrueat the moment — seeprocessor.ts:2120-2125) — so the reset branch fires:currentSegmentTextandlastEmittedTextare wiped back to"", discardingdelta1, beforedelta2is appended. The rendered message ends up missing its first chunk permanently — nothing recovers it later, including atTEXT_MESSAGE_END, since the flush only ever writes outcurrentSegmentTextas it stands at that point.I confirmed this is independent of
parentMessageId's exact value — omitting it, or pointing it at a fresh/different id, doesn't help. AnyTOOL_CALL_STARTbefore the firstTEXT_MESSAGE_STARTin a run takes the same auto-create path and setspendingManualMessageId, so the bug reproduces regardless.Expected vs. actual
"Hello, world.""world."— the first delta ("Hello, ") is silently lost.Workaround
Emit an empty
TEXT_MESSAGE_START+TEXT_MESSAGE_ENDpair for the assistant message before anyTOOL_CALL_STARTreferences it asparentMessageId. That routes the firstTEXT_MESSAGE_STARTthroughhandleTextMessageStartEvent's "new message from the stream" branch (Case 3) instead of the auto-create path, sopendingManualMessageIdnever gets set, and the later realTEXT_MESSAGE_STARTcorrectly hits Case 2 and resetshasToolCallsSinceTextStartbefore the first content delta arrives.Suggested fix direction
Case 1 (
pendingManualMessageId) inhandleTextMessageStartEventshould probably perform the samehasToolCallsSinceTextStartreset that Case 2 does, since a tool call can legitimately arrive and markhasToolCallsSinceTextStart = trueon a message before its "real"TEXT_MESSAGE_STARTshows up — the two code paths shouldn't diverge on that particular reset.Existing test coverage gap
packages/ai/tests/stream-processor.test.tshas a test covering almost this exact scenario — "should preserve the server message id in tool-first flows when parentMessageId is provided" — but it sends the reply as a singleTEXT_MESSAGE_CONTENTevent, so it never triggers the bug (the first delta is only dropped starting from the second delta onward, per step 6-7 above).Related (not a duplicate)
PR #903 touches the exact same code path (placeholder-message creation before
TEXT_MESSAGE_START) but fixes a different symptom —toolCallToMessage/structuredMessageIdsnot being remapped on the placeholder-to-real-id rename, orphaning aTOOL_CALL_RESULT. It was closed without merging, and wouldn't have fixed this bug even if merged (it doesn't touchhasToolCallsSinceTextStart/currentSegmentText/lastEmittedText), but it's evidence this general area is known to be fragile.Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
https://stackblitz.com/edit/node-cx8hsdch?file=package.json,index.js
Screenshots or Videos (Optional)
No response
Do you intend to try to help solve this bug with your own PR?
Yes, I am also opening a PR that solves the problem along side this issue.
Terms & Code of Conduct