Skip to content

StreamProcessor drops the first TEXT_MESSAGE_CONTENT delta when a tool call precedes any text in the same message #1247

Description

@ramigs

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):

  1. TOOL_CALL_START arrives with parentMessageId: "msg-1", but no TEXT_MESSAGE_START for "msg-1" has been seen yet.
  2. 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".
  3. handleToolCallStartEvent then sets state.hasToolCallsSinceTextStart = true on that message's state.
  4. 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.
  5. 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 "".
  6. 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.
  7. 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

  • I agree to follow this project's Code of Conduct
  • I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    has-prAn open PR references this issuewaiting-on: maintainerThe ball is in the maintainers’ court

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions