fix(ai): reset segment state when a tool-first message's TEXT_MESSAGE_START arrives - #1248
fix(ai): reset segment state when a tool-first message's TEXT_MESSAGE_START arrives#1248ramigs wants to merge 1 commit into
Conversation
…SSAGE_START arrives StreamProcessor dropped the first TEXT_MESSAGE_CONTENT delta whenever a TOOL_CALL_START's parentMessageId preceded that message's TEXT_MESSAGE_START (the "call a tool, then explain the result" AG-UI shape). The auto-create path in ensureAssistantMessage() sets pendingManualMessageId, which routes the later real TEXT_MESSAGE_START through handleTextMessageStartEvent's Case 1 instead of Case 2 — and only Case 2 reset hasToolCallsSinceTextStart/currentSegmentText/lastEmittedText. Case 1 now performs the same reset. Fixes TanStack#1247 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (7)
Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review. 📝 WalkthroughWalkthroughThe change fixes ChangesTool-first assistant text
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to This localized fix restores the first text emitted after a tool call without changing the broader message flow; no actionable merge-blocking risk remains after normal checks and review. Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Full details: Out of Scope Changes checkExplanation The changes remain within scope. The processor fix, regression tests, changeset, generated route updates, and dedicated end-to-end harness all support the linked issue. No unrelated functional changes are evident. Full details: Docstring CoverageExplanation Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 6 files. (1 skipped: 1 unsupported.) ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
A tool call that opens an assistant turn drops the first text word that follows it.
TOOL_CALL_STARTwith aparentMessageIdthat has noTEXT_MESSAGE_STARTyet auto-creates the message and leaves stale state behind, so the second text delta wipes out the first. This PR resets that state in the code path that was missing it.🎯 Changes
StreamProcessordropped the firstTEXT_MESSAGE_CONTENTdelta whenever a tool call opened an assistant turn (no leading text before it) and the model replied with two or more deltas after the tool result."Hello, world."rendered as"world.".This fix resets the leftover tool-call state at the point
TEXT_MESSAGE_STARTarrives, so no stale state reaches the content handler at all.Root cause
Issue.
StreamProcessordrops the firstTEXT_MESSAGE_CONTENTdelta of the text that follows a tool call, when that tool call'sparentMessageIdhad no priorTEXT_MESSAGE_START. This is the common AG-UI shape "call a tool, then explain the result," as one assistant turn.Cause. In
handleToolCallStartEvent(packages/ai/src/activities/chat/stream/processor.ts),ensureAssistantMessage()auto-creates the message and setspendingManualMessageId, then markshasToolCallsSinceTextStart = true. When the realTEXT_MESSAGE_STARTfor that message arrives,handleTextMessageStartEventfindspendingManualMessageIdstill set and takes Case 1 ("manual message"), which clears the pending id and returns. Only Case 2 ("message already exists") resetshasToolCallsSinceTextStart,currentSegmentText, andlastEmittedText. Case 1 skips that reset. The stalehasToolCallsSinceTextStartsurvives into the content handler, and its "new segment after a tool call" check fires one delta late — on the second delta, not the first — wiping the already-accumulated first delta before the second one is added.Fix. Case 1 now performs the same reset Case 2 already does, so
hasToolCallsSinceTextStartnever survives past the message's realTEXT_MESSAGE_START.Possible alternatives
fix(ai): keep first text delta after a tool call in the same turn), already open, fixes the same root state at a different point: it patcheshandleTextMessageContentEventso the "new segment" reset fires on the first post-tool delta (when the prior segment is empty) instead of the second. I found this bug and wrote this fix independently — issue StreamProcessor drops the first TEXT_MESSAGE_CONTENT delta when a tool call precedes any text in the same message #1247 predates my discovery of fix(ai): keep first text delta after a tool call in the same turn #1246 — but fix(ai): keep first text delta after a tool call in the same turn #1246 got there first and is a valid, different-layer fix for the same underlying state bug. A maintainer may prefer one over the other; I am not closing this PR so both are visible for comparison.handleTextMessageContentEventonly (the approach fix(ai): keep first text delta after a tool call in the same turn #1246 takes): works, but leaves the twoTEXT_MESSAGE_STARTcode paths (Case 1 and Case 2) diverging on state they should agree on, which is what let this bug in undetected in the first place (see the existing "tool-first flows" test that only exercised a single delta).Testing
Commands run:
pnpm exec vitest run tests/stream-processor.test.ts(packages/ai) — 206/206 passpnpm test:lib(packages/ai) — 1720/1720 passpnpm test:types,pnpm test:build(packages/ai) — cleannx affected --targets=test:sherif,test:knip,test:docs,test:kiira,test:oxlint,test:lib,test:types,test:build,build(repo-wide, matchespnpm test:pr's target set) — all passpnpm exec playwright test tool-first-text.spec.ts(testing/e2e) — pass on this branch; also confirmed it fails on unpatchedmain(rebuilt@tanstack/aiwith the source fix reverted, reran, got"world."instead of"Hello, world.", then restored the fix and reran green)Manual test (reproduce, then confirm fixed):
main, run the repro in issue StreamProcessor drops the first TEXT_MESSAGE_CONTENT delta when a tool call precedes any text in the same message #1247 (orpnpm exec vitest run tests/stream-processor.test.ts -t "1247"on this branch, pointed atmain'sprocessor.ts) — the assembled text comes out as"world."."Hello, world.".cd testing/e2e && pnpm exec playwright test tool-first-text.spec.ts— visits/tool-first-text, a harness page that streams the exact wire shape from the issue, and asserts the rendered text.How this PR makes testing easy: a new unit test (
packages/ai/tests/stream-processor.test.ts) reproduces the bug in isolation, and a new E2E harness (api.tool-first-text-wire.ts+tool-first-text.tsx+tool-first-text.spec.ts) reproduces it through the real browser client, following the existingforeign-interruptwire-harness pattern.Linked issues
Fixes #1247
Risk / rollback
Low risk: the change only runs when
hasToolCallsSinceTextStartis alreadytruein Case 1, mirrors logic Case 2 already ships, and the existing "tool-first flows" test (stream-processor.test.ts:4065) still passes unchanged. Revert is a plaingit revertof this PR; no flags or migrations involved.✅ Checklist
pnpm run test:pr(via the equivalentnx affectedtarget set — see Testing).docs/chat-architecture.md.pnpm changeset).🚀 Release Impact
Summary by CodeRabbit
Bug Fixes
Tests