Repository navigation
Description
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
- A server tool renders a widget per the MCP Apps flow (same shape the built-in
emitUiResourceIfLinkedproduces inactivities/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'
}- 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 intometadata.tanstack.uiResources. - Model → UI:
modelMessageToUIMessage()restores them from that metadata, andapplySnapshotMetadata→appendUiResourcesturns 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
- feat: MCP Apps support (interactive ui:// widgets) #843 (MCP Apps support)
- fix(ai): preserve AG-UI fields across conversions #1204 (preserve ui-resource widgets across conversions — covers the transport, not current-run emission)
- MESSAGES_SNAPSHOT + addToolOutput #859 / fix(ai): preserve tool-call metadata across MESSAGES_SNAPSHOT so addToolResult works (#859) #867 (tool-call metadata across
MESSAGES_SNAPSHOT) - Server-generated assistant turns omit stable
id/createdAt#1087 / fix: preserve server-generated assistant message metadata #1101 (server-generated assistant metadata in snapshots) - fix(ai): handle client tool waits as invocation boundaries #996 (client tool wait boundaries)
Activity
fix(chat): keep run-emitted ui-resources on the interrupt MESSAGES_SN…