diff --git a/.changeset/preserve-model-message-created-at.md b/.changeset/preserve-model-message-created-at.md new file mode 100644 index 000000000..ddb62be11 --- /dev/null +++ b/.changeset/preserve-model-message-created-at.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai': patch +--- + +Preserve `UIMessage.createdAt` when converting messages to and from `ModelMessage` so persisted transcripts retain their original timestamps. diff --git a/packages/ai/src/activities/chat/messages.ts b/packages/ai/src/activities/chat/messages.ts index 87dbb17f1..153cf5711 100644 --- a/packages/ai/src/activities/chat/messages.ts +++ b/packages/ai/src/activities/chat/messages.ts @@ -186,6 +186,9 @@ function buildUserOrToolMessage(uiMessage: UIMessage): ModelMessage { return { role: uiMessage.role as 'user' | 'assistant' | 'tool', content: collapseContentParts(contentParts), + ...(uiMessage.createdAt !== undefined && { + createdAt: uiMessage.createdAt, + }), } } @@ -248,6 +251,9 @@ function buildAssistantMessages(uiMessage: UIMessage): Array { content, ...(hasToolCalls && { toolCalls: current.toolCalls }), ...(pendingThinking.length > 0 && { thinking: pendingThinking }), + ...(uiMessage.createdAt !== undefined && { + createdAt: uiMessage.createdAt, + }), }) pendingThinking = [] } @@ -291,6 +297,9 @@ function buildAssistantMessages(uiMessage: UIMessage): Array { role: 'tool', content: part.content, toolCallId: part.toolCallId, + ...(uiMessage.createdAt !== undefined && { + createdAt: uiMessage.createdAt, + }), }) emittedToolResultIds.add(part.toolCallId) } @@ -350,6 +359,9 @@ function buildAssistantMessages(uiMessage: UIMessage): Array { role: 'tool', content: normalizeToolResult(part.output), toolCallId: part.id, + ...(uiMessage.createdAt !== undefined && { + createdAt: uiMessage.createdAt, + }), }) emittedToolResultIds.add(part.id) } @@ -372,6 +384,9 @@ function buildAssistantMessages(uiMessage: UIMessage): Array { : 'User denied this action', }), toolCallId: part.id, + ...(uiMessage.createdAt !== undefined && { + createdAt: uiMessage.createdAt, + }), }) emittedToolResultIds.add(part.id) } @@ -382,6 +397,9 @@ function buildAssistantMessages(uiMessage: UIMessage): Array { messageList.push({ role: 'assistant', content: null, + ...(uiMessage.createdAt !== undefined && { + createdAt: uiMessage.createdAt, + }), }) } @@ -469,6 +487,9 @@ export function modelMessageToUIMessage( id: id || generateMessageId(), role: modelMessage.role === 'tool' ? 'assistant' : modelMessage.role, parts, + ...(modelMessage.createdAt !== undefined && { + createdAt: modelMessage.createdAt, + }), } } @@ -659,7 +680,7 @@ export function normalizeToUIMessage( // ModelMessage - convert to UIMessage return { ...modelMessageToUIMessage(message, generateId()), - createdAt: new Date(), + createdAt: message.createdAt ?? new Date(), } } } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 6893dd710..0996ba05a 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -376,6 +376,11 @@ export interface ModelMessage< * resume the SAME message bubble in place (see `@tanstack/ai-persistence`). */ id?: string + /** + * Optional message creation timestamp. When present, message converters + * preserve it across persist → hydrate round-trips. + */ + createdAt?: Date } /** diff --git a/packages/ai/tests/message-converters.test.ts b/packages/ai/tests/message-converters.test.ts index f037a396d..5f99916c2 100644 --- a/packages/ai/tests/message-converters.test.ts +++ b/packages/ai/tests/message-converters.test.ts @@ -1202,14 +1202,29 @@ describe('Message Converters', () => { expect(result.parts).toEqual([{ type: 'text', content: 'Hello' }]) expect(result.createdAt).toBeTruthy() }) + + it('should preserve createdAt when converting a ModelMessage', () => { + const createdAt = new Date('2025-01-01') + const message: ModelMessage = { + role: 'user', + content: 'Hello', + createdAt, + } + + const result = normalizeToUIMessage(message, () => 'generated-id') + + expect(result.createdAt).toBe(createdAt) + }) }) describe('Round-trip symmetry: UI -> Model -> UI', () => { it('should round-trip simple text user message', () => { + const createdAt = new Date('2025-01-01') const original: UIMessage = { id: 'msg-1', role: 'user', parts: [{ type: 'text', content: 'Hello world' }], + createdAt, } const modelMessages = uiMessageToModelMessages(original) @@ -1218,6 +1233,59 @@ describe('Message Converters', () => { expect(uiMessages.length).toBe(1) expect(uiMessages[0]?.role).toBe(original.role) expect(uiMessages[0]?.parts).toEqual(original.parts) + expect(uiMessages[0]?.createdAt).toBe(createdAt) + }) + + it('should preserve createdAt for assistant segments and tool results', () => { + const createdAt = new Date('2025-01-01') + const original: UIMessage = { + id: 'msg-1', + role: 'assistant', + parts: [ + { type: 'text', content: 'Checking inventory.' }, + { + type: 'tool-call', + id: 'tc-1', + name: 'getInventory', + arguments: '{}', + state: 'input-complete', + }, + { + type: 'tool-result', + toolCallId: 'tc-1', + content: '{"ok":true}', + state: 'complete', + }, + ], + createdAt, + } + + const modelMessages = uiMessageToModelMessages(original) + + expect(modelMessages).toEqual([ + { + role: 'assistant', + content: 'Checking inventory.', + toolCalls: [ + { + id: 'tc-1', + type: 'function', + function: { name: 'getInventory', arguments: '{}' }, + }, + ], + createdAt, + }, + { + role: 'tool', + content: '{"ok":true}', + toolCallId: 'tc-1', + createdAt, + }, + ]) + + const uiMessages = modelMessagesToUIMessages(modelMessages) + expect(uiMessages).toHaveLength(1) + expect(uiMessages[0]?.createdAt).toBe(createdAt) }) it('should round-trip assistant with tool-call + tool-result', () => {