Skip to content

ui-resource parts emitted during the current run are dropped by the interrupt MESSAGES_SNAPSHOT #1397

Closed

Description

@bertrandgressier

Summary

ui-resource parts (MCP Apps widgets) emitted by a server tool via context.emitCustomEvent('ui-resource', …) during the current run are silently dropped from the client state when the run pauses on a client tool / approval and emits its MESSAGES_SNAPSHOT. Visually: the widget renders, then disappears ~2 ms later (the moment the snapshot lands).

Library versions: @tanstack/ai 0.54.0 (also @tanstack/ai-client, @tanstack/ai-react 0.24.1). Server = chat() + toServerSentEventsResponse, client = browser useChat.

Setup

  1. A server tool renders a widget per the MCP Apps flow (same shape the built-in emitUiResourceIfLinked produces in activities/chat/tools/tool-calls.js):
execute: async (input, context) => {
  context.emitCustomEvent('ui-resource', {
    type: 'ui-resource',
    resource: { uri: 'ui://myapp/fireflies', mimeType: 'application/json', text: JSON.stringify(payload) },
    serverId: 'myapp',
    toolCallId: context.toolCallId,
    toolName: 'present_fireflies',
  })
  return 'widget shown'
}
  1. The model then calls a client tool (await_widget_result) which pauses the run (interrupt).

Observed behavior (wire trace)

TOOL_CALL_START/ARGS/END  present_fireflies        (call_00…)
CUSTOM                    ui-resource              ← part reaches the client, widget renders
TOOL_CALL_RESULT          call_00…
TOOL_CALL_START/ARGS/END  await_widget_result      (call_01…)
MESSAGES_SNAPSHOT         ← client state replaced wholesale; ui-resource part is GONE
STATE_SNAPSHOT
RUN_FINISHED              outcome: interrupt

Client-side timeline (instrumented onMessagesChange): the ui-resource part is appended right after the CUSTOM event, and disappears at the MESSAGES_SNAPSHOT — handleMessagesSnapshotEvent replaces this.messages with the normalized snapshot, and the snapshot's anchor assistant message carries no metadata.tanstack.uiResources.

Parsed snapshot (4 wire messages) — note the anchor has createdAt but no uiResources:

[
  { "role": "user", "content": "…", "metadata": { "tanstack": { "createdAt": "…" } } },
  { "role": "reasoning", "content": "…" },
  { "role": "assistant", "id": "…", "content": null,
    "toolCalls": [
      { "id": "call_00…", "function": { "name": "present_fireflies", "arguments": "{…}" } },
      { "id": "call_01…", "function": { "name": "await_widget_result", "arguments": "{…}" } }
    ],
    "metadata": { "tanstack": { "createdAt": "…" } } },
  { "role": "tool", "toolCallId": "call_00…", "metadata": { "tanstack": { "toolResult": true } } }
]

Root cause

The round-trip machinery for ui-resources across snapshots already exists:

  • UI → Model: assistantMetadata() (activities/chat/messages.js) stores ui-resource parts into metadata.tanstack.uiResources.
  • Model → UI: modelMessageToUIMessage() restores them from that metadata, and applySnapshotMetadata → appendUiResources turns them back into parts client-side (fix(ai): preserve AG-UI fields across conversions #1204).

…so widgets carried in the history sent back by the client survive interrupt snapshots fine. What's missing is the emission side: a ui-resource CUSTOM event emitted during the current run is only streamed to the client — it is never recorded onto the owning assistant ModelMessage in the server's conversation state. When the run pauses, buildMessagesSnapshotChunk() (uiMessagesToWire(modelMessagesToUIMessages(this.messages))) serializes messages that have no uiResources metadata, and the client's richer state is replaced by the impoverished snapshot.

This looks like the same family as #859/#867 (tool-call metadata lost across MESSAGES_SNAPSHOT, fixed for toolCallMetadata) and #1087/#1101 (server-generated assistant turns losing id/createdAt in snapshots) — the ui-resource equivalent of those gaps.

Impact

Any interactive widget that pauses the run on a client tool — i.e. exactly the interactive MCP Apps UX — vanishes from the UI at the pause. It also poisons downstream state: the continuation request's history no longer contains the part, so transcripts/persistence lose it too.

Workaround (app-level, current)

We wrap the server stream before toServerSentEventsResponse: remember CUSTOM ui-resource chunks as they pass, and when a MESSAGES_SNAPSHOT chunk is emitted, inject the remembered parts into the anchor assistant message's metadata.tanstack.uiResources (dedup by toolCallId/toolName/uri). The client then restores them natively via appendUiResources. This keeps client state intact through pauses and continuations.

Suggested fix

Record ui-resource emissions onto the server-side conversation state so buildMessagesSnapshotChunk serializes them naturally — e.g. when the CUSTOM event is emitted, append the part to the anchor assistant message's metadata.tanstack.uiResources (mirroring where toolCallMetadata is preserved per #867).

Happy to turn our capture into a failing unit test if useful — the repro is deterministic (any server tool emitting the CUSTOM event followed by a client-tool pause).

Related

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

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