Skip to content

fix(ai): normalize AG-UI snapshot messages to UIMessage[] in MESSAGES_SNAPSHOT handler#698

Open
amitsaroj wants to merge 6 commits into
TanStack:mainfrom
amitsaroj:fix/messages-snapshot-normalize-parts
Open

fix(ai): normalize AG-UI snapshot messages to UIMessage[] in MESSAGES_SNAPSHOT handler#698
amitsaroj wants to merge 6 commits into
TanStack:mainfrom
amitsaroj:fix/messages-snapshot-normalize-parts

Conversation

@amitsaroj

@amitsaroj amitsaroj commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

🎯 Changes

Fixes #688

Root cause

handleMessagesSnapshotEvent in processor.ts was doing an unsafe type cast:

// Before (unsafe)
this.messages = [...chunk.messages] as Array<UIMessage>

AG-UI MESSAGES_SNAPSHOT messages have the shape { id, role, content } — they do not have the parts array that UIMessage requires. The as Array<UIMessage> cast masked this at compile time.

When any downstream code called message.parts.find(...) — for example, the onToolCallStateChange devtools handler in chat-client.ts — it crashed with:

TypeError: Cannot read properties of undefined (reading 'find')

Fix

Normalize each message through modelMessageToUIMessage when it does not already have parts:

// After (safe)
this.messages = chunk.messages.map((msg) =>
  'parts' in msg
    ? (msg as UIMessage)
    : modelMessageToUIMessage(msg as any, generateMessageId()),
)

This mirrors the pattern already used in normalizeToUIMessage (used by ChatClient.append) and is consistent with how all other message ingestion paths work in this codebase.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • - [ ] I have tested this code locally with pnpm run test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.

🧪 Test Plan

  1. Send a chat message that triggers a MESSAGES_SNAPSHOT event (e.g., using an AG-UI agent backend that emits snapshot events).
    1. Verify no TypeError: Cannot read properties of undefined (reading 'find') is thrown.
    1. Existing unit tests in packages/ai/tests/stream-processor.test.ts for MESSAGES_SNAPSHOT resets transient state cover the core normalization path.

Summary by CodeRabbit

  • Bug Fixes

    • Normalize streamed chat snapshot messages into a consistent UI format (convert legacy-shaped messages and assign fallback IDs when missing) so message parts are reliably available and downstream processing/rendering is stable.
  • Tests

    • Added regression tests covering snapshot normalization, ID preservation and fallback, empty snapshots, routing of subsequent text chunks, and related edge cases.

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9c7857df-e22c-451a-b9a1-4cc1b9a462b8

📥 Commits

Reviewing files that changed from the base of the PR and between 99c4a8b and d9c0cb3.

📒 Files selected for processing (2)
  • packages/ai/src/activities/chat/stream/processor.ts
  • packages/ai/tests/stream-processor.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/ai/tests/stream-processor.test.ts

📝 Walkthrough

Walkthrough

Replaces unsafe casting of MESSAGES_SNAPSHOT payloads by importing modelMessageToUIMessage and mapping snapshot messages: reuse messages with parts; convert messages without parts (using existing or generated ids) so downstream code can rely on message.parts.

Changes

Safe MESSAGES_SNAPSHOT handling

Layer / File(s) Summary
Safe normalization of MESSAGES_SNAPSHOT payloads
packages/ai/src/activities/chat/stream/processor.ts
Adds modelMessageToUIMessage import and updates MESSAGES_SNAPSHOT handler to map snapshot messages: reuse messages that already have parts, convert others via modelMessageToUIMessage (using an existing or generated id) so each resulting UIMessage includes parts.
Regression tests for snapshot normalization
packages/ai/tests/stream-processor.test.ts
Adds tests verifying AG-UI-shaped snapshot messages are normalized into UI messages with parts, preserve original ids or generate fallbacks, leave already-normalized messages unchanged, handle empty snapshots, and route subsequent TEXT_MESSAGE_CONTENT events correctly.

Sequence Diagram(s)

sequenceDiagram
  participant StreamProcessor
  participant MESSAGES_SNAPSHOT
  participant modelMessageToUIMessage
  StreamProcessor->>MESSAGES_SNAPSHOT: receive snapshot with messages[]
  alt message has parts
    StreamProcessor->>StreamProcessor: reuse UIMessage as-is
  else message lacks parts
    StreamProcessor->>modelMessageToUIMessage: convert AG-UI message (use id or generate)
    modelMessageToUIMessage->>StreamProcessor: return UIMessage with parts
  end
  StreamProcessor->>StreamProcessor: downstream code reads message.parts
Loading

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers:

  • tombeckenham

🐰 I hopped through snapshots, parts awry,
I stitched each message an ID so they’d not cry,
Converted the models, one by one,
Now every part shines in the sun,
A gentle fix — the stream runs spry.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and specifically describes the main change: normalizing AG-UI snapshot messages to UIMessage[] in the MESSAGES_SNAPSHOT handler, which directly addresses the core issue being fixed.
Description check ✅ Passed The description comprehensively covers the root cause, the fix with code examples, test plan, and includes a checklist. One minor note: the test checkbox shows unchecked but author later added comprehensive tests per reviewer request.
Linked Issues check ✅ Passed The PR directly addresses issue #688 by normalizing AG-UI snapshot messages to UIMessage[] with a parts array, preventing the TypeError when downstream code accesses message.parts.find(). Message ID preservation maintains event correlation, and comprehensive tests verify the fix works correctly.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the MESSAGES_SNAPSHOT handling in StreamProcessor and adding comprehensive tests. No unrelated changes or scope creep detected in the processor.ts or test files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@packages/ai/src/activities/chat/stream/processor.ts`:
- Around line 878-882: The snapshot branch is overwriting incoming message IDs
by always calling modelMessageToUIMessage with generateMessageId(); update the
mapping so when converting non-UI messages you preserve the original msg.id and
only generate a new id if msg.id is missing—i.e., in the chunk.messages mapping
(this.messages assignment) pass msg.id (or msg['id']) as the id argument to
modelMessageToUIMessage when present, otherwise call generateMessageId(); this
keeps existing message identity used by ensureAssistantMessage,
TEXT_MESSAGE_CONTENT, toolCallToMessage, and messageStates.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1d6f8123-d73d-4983-91d7-fbe5fe45edfa

📥 Commits

Reviewing files that changed from the base of the PR and between 769a6c7 and bc8e675.

📒 Files selected for processing (1)
  • packages/ai/src/activities/chat/stream/processor.ts

Comment thread packages/ai/src/activities/chat/stream/processor.ts
@amitsaroj

Copy link
Copy Markdown
Contributor Author

Thanks for the review @coderabbitai — great catch on the ID preservation issue.

The second commit (b091adc) addresses this: instead of always generating a fresh generateMessageId(), the normalization now uses (msg as { id?: string }).id ?? generateMessageId() — preserving the AG-UI message's original id when present and only falling back to a generated ID when the message has no id field.

This ensures downstream event correlation (e.g. TEXT_MESSAGE_CONTENT, TOOL_CALL_*, ensureAssistantMessage) continues to work correctly after a MESSAGES_SNAPSHOT reset.

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@amitsaroj: Thanks for the explanation! The approach looks solid — using (msg as { id?: string }).id ?? generateMessageId() is exactly the right pattern here. Preserving the original AG-UI message id ensures that any subsequent events (TEXT_MESSAGE_CONTENT, TOOL_CALL_*, etc.) that reference that ID will correctly correlate with the already-stored message after the snapshot reset, rather than silently breaking due to a mismatched generated ID.

The fix is well-reasoned. 🐇✨

@amitsaroj

Copy link
Copy Markdown
Contributor Author

Hi @tombeckenham and TanStack maintainers — this is a small but impactful fix normalizing AG-UI MESSAGES_SNAPSHOT messages to UIMessage[] format, preventing the TypeError: Cannot read properties of undefined (reading 'find') crash. CodeRabbit's review is clean, no conflicts, and all checks pass. Could someone approve the pending workflows and merge when ready? Thank you!

@tombeckenham

Copy link
Copy Markdown
Contributor

Would you be able to add some tests for this @amitsaroj ?

@amitsaroj

Copy link
Copy Markdown
Contributor Author

Hi @tombeckenham — done! Added 6 new tests in packages/ai/tests/stream-processor.test.ts inside the existing describe('MESSAGES_SNAPSHOT') block (commit 99c4a8b):

  1. Normalizes AG-UI messages (no parts) to UIMessage[] — verifies each message gets a parts array and text content is surfaced as a TextPart
    1. Preserves original AG-UI message id — regression guard for the earlier version that always called generateMessageId() and broke downstream event correlation
    1. No TypeError on parts.find() — directly reproduces the crash from onToolCallStateChange accessing message.parts after a snapshot
    1. Pass-through for messages that already have parts — existing UIMessage snapshots are kept as-is and not re-normalized
    1. Fallback id generation — handles AG-UI messages that have no id field
    1. Stream continuation after snapshotTEXT_MESSAGE_CONTENT events routing to snapshot-hydrated messages work correctly

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@packages/ai/src/activities/chat/stream/processor.ts`:
- Around line 1713-1715: The global errored-tool check
(this.isToolCallPartErrored(toolCall.id)) can return false positives if an older
message has the same toolCall id; update the check to be scoped to the current
message by passing the current message id (e.g.
this.isToolCallPartErrored(toolCall.id, this.message?.id)) or by adding/using a
scoped helper like isToolCallPartErroredForMessage(toolCall.id, messageId), and
replace the two occurrences (around the tool completion branch and the 1741-1750
block) so they only consider parts belonging to the current message before
returning/skip logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9c7857df-e22c-451a-b9a1-4cc1b9a462b8

📥 Commits

Reviewing files that changed from the base of the PR and between 99c4a8b and d9c0cb3.

📒 Files selected for processing (2)
  • packages/ai/src/activities/chat/stream/processor.ts
  • packages/ai/tests/stream-processor.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/ai/tests/stream-processor.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@packages/ai/src/activities/chat/stream/processor.ts`:
- Around line 1713-1715: The global errored-tool check
(this.isToolCallPartErrored(toolCall.id)) can return false positives if an older
message has the same toolCall id; update the check to be scoped to the current
message by passing the current message id (e.g.
this.isToolCallPartErrored(toolCall.id, this.message?.id)) or by adding/using a
scoped helper like isToolCallPartErroredForMessage(toolCall.id, messageId), and
replace the two occurrences (around the tool completion branch and the 1741-1750
block) so they only consider parts belonging to the current message before
returning/skip logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9c7857df-e22c-451a-b9a1-4cc1b9a462b8

📥 Commits

Reviewing files that changed from the base of the PR and between 99c4a8b and d9c0cb3.

📒 Files selected for processing (2)
  • packages/ai/src/activities/chat/stream/processor.ts
  • packages/ai/tests/stream-processor.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/ai/tests/stream-processor.test.ts
🛑 Comments failed to post (1)
packages/ai/src/activities/chat/stream/processor.ts (1)

1713-1715: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Scope the errored-tool check to the current message to avoid false positives.

Line 1713 calls a global scan (isToolCallPartErrored(toolCall.id)). If an older message contains the same toolCallId in 'error', the current call is incorrectly treated as errored and its completion update is skipped.

💡 Suggested fix
-    if (this.isToolCallPartErrored(toolCall.id)) {
+    if (this.isToolCallPartErrored(messageId, toolCall.id)) {
       return
     }
@@
-  private isToolCallPartErrored(toolCallId: string): boolean {
-    return this.messages.some((msg) =>
-      msg.parts.some(
-        (part) =>
-          part.type === 'tool-call' &&
-          part.id === toolCallId &&
-          part.state === 'error',
-      ),
-    )
+  private isToolCallPartErrored(
+    messageId: string,
+    toolCallId: string,
+  ): boolean {
+    const msg = this.messages.find((m) => m.id === messageId)
+    if (!msg) return false
+    return msg.parts.some(
+      (part) =>
+        part.type === 'tool-call' &&
+        part.id === toolCallId &&
+        part.state === 'error',
+    )
   }

Also applies to: 1741-1750

🤖 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 1713 -
1715, The global errored-tool check (this.isToolCallPartErrored(toolCall.id))
can return false positives if an older message has the same toolCall id; update
the check to be scoped to the current message by passing the current message id
(e.g. this.isToolCallPartErrored(toolCall.id, this.message?.id)) or by
adding/using a scoped helper like isToolCallPartErroredForMessage(toolCall.id,
messageId), and replace the two occurrences (around the tool completion branch
and the 1741-1750 block) so they only consider parts belonging to the current
message before returning/skip logic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TypeError: Cannot read properties of undefined (reading 'find') for MESSAGES_SNAPSHOT

2 participants