fix(ai): preserve tool-call metadata across MESSAGES_SNAPSHOT so addToolResult works (#859)#867
Conversation
…oolResult works The AG-UI MESSAGES_SNAPSHOT wire shape cannot reconstruct client-side tool-call metadata a server may omit: a `role: 'tool'` message only carries `toolCallId` + `content`, and an assistant snapshot message may drop `toolCalls` the client already observed via `TOOL_CALL_*` events. After a snapshot the matching `tool-call` part could vanish, so a later `addToolResult(toolCallId)` failed to locate the call and silently no-op'd. `handleMessagesSnapshotEvent` now reconciles the normalized snapshot with the pre-snapshot state: (a) detached `tool-result`-only assistant messages are anchored into the preceding assistant message to match the streaming fan-out shape, and (b) a `tool-call` part is carried forward from the prior state when the snapshot references its `toolCallId` via a `tool-result` but omits the corresponding `tool-call` part. Fixes TanStack#859
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughAdds a reconciliation pass in ChangesSnapshot tool-call reconciliation
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ 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 |
|
On the E2E coverage requirement in CONTRIBUTING — flagging this explicitly rather than leaving it buried in the PR description, so a maintainer can make the call. This fix lives entirely in the client-side Given that, I covered the behaviour with unit tests in Options, your call:
I'd lean (1) or (2); (3) feels like a lot of new harness for a client-internal fix. Happy to do whichever you prefer. |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/ai/src/activities/chat/stream/processor.ts (1)
875-901: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy liftRebuild tool-call routing/state after snapshot reconciliation.
Line 878 clears
toolCallToMessageandmessageStates, but Lines 899-900 only restorethis.messages. After a mid-streamMESSAGES_SNAPSHOT, follow-upTOOL_CALL_ARGS,TOOL_CALL_END,TOOL_CALL_RESULT,approval-requested, andui-resourceevents for the reconciled tool call can still get dropped because the routing maps were never rehydrated. Please rebuild the per-message tool state from the reconciled assistant messages before returning here.Also applies to: 919-988
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ai/src/activities/chat/stream/processor.ts` around lines 875 - 901, handleMessagesSnapshotEvent currently restores this.messages after snapshot reconciliation but leaves the routing/state maps cleared, so follow-up tool events can no longer resolve the reconciled calls. After reconcileSnapshotToolCalls returns, rebuild toolCallToMessage and messageStates from the reconciled assistant messages before emitMessagesChange, using the same tool-call/tool-result metadata preserved by aguiSnapshotMessageToUIMessage and reconcileSnapshotToolCalls. Apply the same rehydration logic anywhere the stream snapshot path resets state so TOOL_CALL_ARGS, TOOL_CALL_END, TOOL_CALL_RESULT, approval-requested, and ui-resource events can keep routing correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@packages/ai/src/activities/chat/stream/processor.ts`:
- Around line 875-901: handleMessagesSnapshotEvent currently restores
this.messages after snapshot reconciliation but leaves the routing/state maps
cleared, so follow-up tool events can no longer resolve the reconciled calls.
After reconcileSnapshotToolCalls returns, rebuild toolCallToMessage and
messageStates from the reconciled assistant messages before emitMessagesChange,
using the same tool-call/tool-result metadata preserved by
aguiSnapshotMessageToUIMessage and reconcileSnapshotToolCalls. Apply the same
rehydration logic anywhere the stream snapshot path resets state so
TOOL_CALL_ARGS, TOOL_CALL_END, TOOL_CALL_RESULT, approval-requested, and
ui-resource events can keep routing correctly.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 64b00b7f-8fc7-449a-b1a7-789f91ed573c
📒 Files selected for processing (3)
.changeset/snapshot-preserve-tool-call.mdpackages/ai/src/activities/chat/stream/processor.tspackages/ai/tests/stream-processor.test.ts
There was a problem hiding this comment.
Pull request overview
Fixes a regression where addToolResult / addToolOutput could no-op after an AG-UI MESSAGES_SNAPSHOT by reconciling snapshot-normalized messages with pre-snapshot tool-call metadata that the snapshot wire format cannot fully represent.
Changes:
- Add a reconciliation post-pass after snapshot normalization to (1) anchor
role: 'tool'fan-out messages into the preceding assistant turn and (2) carry forward missingtool-callparts from pre-snapshot state when referenced by atool-result. - Add unit tests covering anchoring, metadata preservation for the #859 repro, and avoiding tool-call duplication when the snapshot already includes toolCalls.
- Add a changeset describing the patch-level fix and behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| packages/ai/src/activities/chat/stream/processor.ts | Reconciles snapshot-normalized messages with pre-snapshot tool-call parts so tool-call metadata and tool-result anchoring remain consistent after MESSAGES_SNAPSHOT. |
| packages/ai/tests/stream-processor.test.ts | Adds regression coverage for anchoring snapshot tool messages and preserving omitted tool-call metadata so addToolResult continues to work. |
| .changeset/snapshot-preserve-tool-call.md | Publishes release notes for the patch fix addressing addToolResult / addToolOutput no-op after snapshots. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
Re: the review about rebuilding tool-call routing/state after snapshot reconciliation ( Good catch, and the mechanism is real — after a Two notes on scope:
Rehydrating per-message tool state is a reasonable robustness improvement, but (as the review notes) it is a heavy lift that widens this PR beyond #859. I would prefer to keep this PR minimal and open a dedicated follow-up issue, unless you would rather I fold it in here — happy to do whichever you prefer. |
🎯 Changes
Fixes #859 —
addToolResult/addToolOutputsilently no-op'd after aMESSAGES_SNAPSHOT.Root cause
handleMessagesSnapshotEventreplacesthis.messageswith the normalized snapshot. The AG-UIMESSAGES_SNAPSHOTwire shape cannot reconstruct client-side tool-call metadata a server may omit:role: 'tool'message only carriestoolCallId+content, andtoolCallsthe client already observed viaTOOL_CALL_*events.After the snapshot, the matching
tool-callpart could disappear, so a lateraddToolResult(toolCallId)couldn't locate the call:and silently no-op'd. Removing the snapshot (the reporter's "if I delete it") leaves the streamed assistant message intact, which is why it then worked.
Fix
handleMessagesSnapshotEventnow reconciles the normalized snapshot against the pre-snapshot state in a small post-pass (reconcileSnapshotToolCalls):tool-result-only assistant messages (produced byaguiSnapshotMessageToUIMessageforrole: 'tool'wire messages) are anchored into the preceding assistant message, matching the streaming fan-out shapeassistant: [text, tool-call, tool-result, ...]. This also removes a duplicatetool-resultthat previously appeared when both the assistanttoolCallsand a separatetoolmessage were present.tool-resultreferences atoolCallIdwhosetool-callpart is absent from the snapshot, thetool-callpart is carried forward from the pre-snapshot state soaddToolResult(toolCallId)can still find it.The trigger for (b) is deliberately narrow: it only fires when the snapshot explicitly references a
toolCallId(via atool-result) but does not itself supply the correspondingtool-callpart. This frames the change as recovering metadata the snapshot payload cannot express, not as making the snapshot non-authoritative.Tests
Added unit coverage in
stream-processor.test.ts(the same location as the prior snapshot fix #698):toolmessage into the preceding assistant messagetool-callthe snapshot omits soaddToolResultstill works (the MESSAGES_SNAPSHOT + addToolOutput #859 repro)tool-callthe snapshot already supplies✅ Checklist
Local verification
All
@tanstack/aitargets are green locally:test:lib— 164 tests passing (incl. 3 new)test:types,test:eslint(0 errors),build,test:buildI also ran the full repo-wide
pnpm run test:pr. The only failures were in packages unrelated to this change and are platform/environment artifacts on my Windows machine, not regressions:@tanstack/ai-sandbox›tests/harness-cwd.test.ts— a path-separator assertion (path.joinproduces\on Windows; the code returns/). Fails only on Windows; unrelated to this PR.@tanstack/ai-angular:build— an intermittent npm peer-dep install crash (Cannot read properties of null (reading 'matches')); re-running it on a cleanmaincheckout succeeds.Neither touches
@tanstack/ai. Linux CI should be unaffected — happy to adjust if CI surfaces anything.🚀 Release Impact
@tanstack/aipatch).Note on E2E: there is no existing
MESSAGES_SNAPSHOTharness intesting/e2e(snapshots aren't part of the recorded provider flow), and the directly analogous prior snapshot fix (#698) was covered with unit tests instream-processor.test.tsrather than E2E. I followed that precedent — happy to add an E2E scenario if you'd prefer one.I raised the two fix directions on the issue first (#859 comment) and implemented the combined approach with the focus on the actual bug fix (b). Let me know if you'd like the scope narrowed/widened.
Summary by CodeRabbit
tool-resultmessages with the originating assistant turn.