Skip to content

AG-UI conversion loses UIMessage.createdAt and strips tool ModelMessage fields #1203

Description

@kolaworld

TanStack AI version

0.48.0

Framework/Library version

@tanstack/ai-client: 0.26.0 | @tanstack/ai-persistence: 0.5.0

Describe the bug and the steps to reproduce it

This regression was introduced by PR #1174 (1c0415b), which moved AG-UI extras under metadata.tanstack and replaced message object spreads with spec-only wire projections. Two conversion paths are incomplete.

Inbound: createdAt is not restored on ModelMessage

uiMessagesToWire() serializes UIMessage.createdAt as an ISO-8601 string in metadata.tanstack.createdAt (ag-ui-wire.ts#L149-L178), and chatParamsFromRequestBody() preserves that metadata (chat-params.ts#L113-L127).

For a text-only AG-UI user message, convertMessagesToModelMessages() then reaches its default branch and casts the wire message to ModelMessage without restoring createdAt (messages.ts#L118-L226). The runtime object still has metadata, but ModelMessage defines createdAt and does not define message-level metadata (types.ts#L363-L395).

reconstructChat() converts stored model messages with modelMessagesToUIMessages() (reconstruct.ts#L118-L135). modelMessageToUIMessage() restores only top-level modelMessage.createdAt and does not copy the undeclared metadata property (messages.ts#L601-L608).

The client-to-server request therefore carries the original timestamp, but the canonical model message and the reconstructed UI message do not.

Outbound: tool ModelMessage fields are removed

The public connection adapter contract accepts UIMessage[] | ModelMessage[] (connection-adapters.md#L556-L590), but buildRunAgentInputBody() casts every input to UIMessage[] before calling uiMessagesToWire() (connection-adapters.ts#L1219-L1227).

uiMessagesToWire() handles system and user explicitly, then treats every other message as an assistant message. A tool ModelMessage has no parts, so that path produces no generated content or tool call, and toAnchor() then keeps only its allowlisted fields and drops content and toolCallId (ag-ui-wire.ts#L62-L168).

{ id: "result-1", role: "tool", toolCallId: "call-1", content: "[]" } is therefore serialized as { id: "result-1", role: "tool" }, and chatParamsFromRequestBody() rejects it because AG-UI tool messages require both content and toolCallId (chat-params.ts#L78-L98).

Expected behavior

convertMessagesToModelMessages() should restore a valid metadata.tanstack.createdAt ISO string as ModelMessage.createdAt, matching the existing snapshot normalization. Invalid timestamps should be ignored consistently.

The built-in connection adapters should serialize both input types in their public contract. A tool ModelMessage should produce a valid AG-UI tool message that retains id, content, and toolCallId.

Both directions should round-trip their canonical fields: a UIMessage creation time should survive wire conversion and persistence reconstruction, and a tool ModelMessage should survive wire conversion and server parsing.

Your Minimal, Reproducible Example - (Sandbox Highly Recommended)

https://stackblitz.com/edit/vitejs-vite-g7mtazna?file=package.json,index.html,src%2Fmain.ts

Do you intend to try to help solve this bug with your own PR?

Yes, I am also opening a PR that solves the problem along side this issue

Terms & Code of Conduct

  • I agree to follow this project's Code of Conduct
  • I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

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