Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/preserve-model-message-created-at.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai': patch
---

Preserve `UIMessage.createdAt` when converting messages to and from `ModelMessage` so persisted transcripts retain their original timestamps.
23 changes: 22 additions & 1 deletion packages/ai/src/activities/chat/messages.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
}),
}
}

Expand DownExpand Up@@ -248,6 +251,9 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
content,
...(hasToolCalls && { toolCalls: current.toolCalls }),
...(pendingThinking.length > 0 && { thinking: pendingThinking }),
...(uiMessage.createdAt !== undefined && {
createdAt: uiMessage.createdAt,
}),
})
pendingThinking = []
}
Expand DownExpand Up@@ -291,6 +297,9 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
role: 'tool',
content: part.content,
toolCallId: part.toolCallId,
...(uiMessage.createdAt !== undefined && {
createdAt: uiMessage.createdAt,
}),
})
emittedToolResultIds.add(part.toolCallId)
}
Expand DownExpand Up@@ -350,6 +359,9 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
role: 'tool',
content: normalizeToolResult(part.output),
toolCallId: part.id,
...(uiMessage.createdAt !== undefined && {
createdAt: uiMessage.createdAt,
}),
})
emittedToolResultIds.add(part.id)
}
Expand All@@ -372,6 +384,9 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
: 'User denied this action',
}),
toolCallId: part.id,
...(uiMessage.createdAt !== undefined && {
createdAt: uiMessage.createdAt,
}),
})
emittedToolResultIds.add(part.id)
}
Expand All@@ -382,6 +397,9 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
messageList.push({
role: 'assistant',
content: null,
...(uiMessage.createdAt !== undefined && {
createdAt: uiMessage.createdAt,
}),
})
}

Expand DownExpand Up@@ -469,6 +487,9 @@ export function modelMessageToUIMessage(
id: id || generateMessageId(),
role: modelMessage.role === 'tool' ? 'assistant' : modelMessage.role,
parts,
...(modelMessage.createdAt !== undefined && {
createdAt: modelMessage.createdAt,
}),
}
}

Expand DownExpand Up@@ -659,7 +680,7 @@ export function normalizeToUIMessage(
// ModelMessage - convert to UIMessage
return {
...modelMessageToUIMessage(message, generateId()),
createdAt: new Date(),
createdAt: message.createdAt ?? new Date(),
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions packages/ai/src/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Comment on lines +379 to +383

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bashset -euo pipefail
# Expect: persistence code revives createdAt to Date before normalization.
rg -n -C 5 \
'ModelMessage|createdAt|JSON\.(stringify|parse)|serialize|deserialize|revive' \
. -g '*.ts' -g '*.tsx' -g '*.md'

Repository: TanStack/ai

Length of output: 50368


🏁 Script executed:

#!/usr/bin/env bashset -euo pipefail
echo"== candidate files =="
git ls-files 'packages/ai/src/**/*.ts'| rg 'type|message|persist|storage|sqlite|memory|react'||trueecho"== ModelMessage/createdAt definitions =="
rg -n "interface ModelMessage|type ModelMessage|createdAt\\?:" packages/ai/src -g '*.ts' -C 8
echo"== UIMessage definitions =="
rg -n "interface UIMessage|type UIMessage|createdAt\\?:|createdAt:" packages/ai/src packages/ -g '*.ts' -C 8
echo"== normalizeToUIMessage definition/usages =="
rg -n "function normalizeToUIMessage|const normalizeToUIMessage|normalizeToUIMessage\\(" packages/ai/src packages/ai/tests -g '*.ts' -C 8
echo"== persistence-related candidates narrow =="
rg -n "persist|hydrat|modelMessages|UIMessages|ModelMessages|JSON\.(stringify|parse)|revoke|revive" packages/ai/src packages/ai/tests -g '*.ts' -g '*.tsx' -C 4 --max-count 200 | head -n 800

Repository: TanStack/ai

Length of output: 50368


🏁 Script executed:

#!/usr/bin/env bashset -euo pipefail
echo"== package ai source files =="
git ls-files packages/ai/src/*.ts packages/ai/src/**/*.ts | sort
echo"== exact type definitions and relevant source slices =="
sed -n '1,120p' packages/ai/src/types.ts
echo"---"
sed -n '340,395p' packages/ai/src/types.ts
echo"---"
sed -n '450,505p' packages/ai/src/activities/chat/messages.ts
echo"---"
sed -n '660,690p' packages/ai/src/activities/chat/messages.ts
echo"== storage / persistence actual references in packages/ai/src =="
rg -n "localStorage|sessionStorage|getStorage|setStorage|persist|hydrate|getItem|setItem|removeItem|JSON\.stringify|JSON\.parse|modelMessages|UIMessages" packages/ai/src -g '*.ts' -C 5
echo"== deterministic JS Date round-trip probe =="
node - <<'JS'const original = { id: 'm1', role: 'user', createdAt: new Date('2025-03-01T12:34:56Z') }const jsn = JSON.stringify(original)const restored = JSON.parse(jsn)console.log('stringified:', jsn)console.log('restored type:', typeof restored.createdAt)console.log('instanceof Date:', restored.createdAt instanceof Date)console.log('constructor:', restored.createdAt.constructor.name)JS

Repository: TanStack/ai

Length of output: 50368


Handle createdAt format during hydration.

The message converters preserve createdAt, but ModelMessage.createdAt is Date while persisted JSON stores that timestamp as an ISO string. Add revival for hydrated string values, or change the persisted representation/type so normalizeToUIMessage() does not copy a string into createdAt.

🤖 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/types.ts` around lines 379 - 383, Update the hydration logic
in normalizeToUIMessage() and the related message converters to revive persisted
ISO-string createdAt values into Date instances before assigning
ModelMessage.createdAt. Preserve Date values and absent timestamps unchanged,
ensuring hydrated messages never expose a string where the createdAt type
requires Date.

}

/**
Expand Down
68 changes: 68 additions & 0 deletions packages/ai/tests/message-converters.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
})
Comment on lines +1216 to +1217

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bashset -euo pipefail
# Expect: no changed timestamp assertion compares Date objects by reference.if rg -n 'toBe\(createdAt\)' packages/ai/tests/message-converters.test.ts;thenecho"createdAt is still compared by reference">&2exit 1
fi

Repository: TanStack/ai

Length of output: 362


🏁 Script executed:

#!/usr/bin/env bashset -euo pipefail
echo"== file outline relevant =="
ast-grep outline packages/ai/tests/message-converters.test.ts --match 'createdAt' --view expanded ||trueecho"== surrounding assertions =="
sed -n '1180,1300p' packages/ai/tests/message-converters.test.ts | nl -ba -v1180
echo"== createdAt conversions in message-converters production files =="
rg -n "createdAt|DateTime" packages/ai -g '*.ts' -g '!*.test.ts'

Repository: TanStack/ai

Length of output: 305


🏁 Script executed:

#!/usr/bin/env bashset -euo pipefail
node - <<'JS'const a = new Date('2026-08-01T12:00:00Z')const b = new Date('2026-08-01T12:00:00Z')console.log('same-reference toBe-equivalent:', Object.is(a, a))console.log('same-value different-reference toBe-equivalent:', Object.is(a, b))console.log('same-value getTime():', a.getTime() === b.getTime())JS

Repository: TanStack/ai

Length of output: 267


🏁 Script executed:

#!/bin/shset -euo pipefail
echo"== expected createdAt assertion occurrences =="
grep -n 'toBe(createdAt)' packages/ai/tests/message-converters.test.ts ||trueecho"== surrounding createdAt assertions with sed =="
sed -n '1180,1300p' packages/ai/tests/message-converters.test.ts | sed -n '1,120p'echo"== createdAt/DateTime in packages/ai source (excluding tests) =="
rg -n "createdAt|DateTime" packages/ai --glob '*.ts' --glob '!**/*.test.ts'||trueecho"== Date equality semantic probe =="
node - <<'JS'const a = new Date('2026-08-01T12:00:00Z')const b = new Date('2026-08-01T12:00:00Z')console.log('same-reference equality:', Object.is(a, a))console.log('same-value different-reference equality:', Object.is(a, b))console.log('same-value getTime equality:', a.getTime() === b.getTime())JS

Repository: TanStack/ai

Length of output: 6232


Compare timestamp values, not Date object identity.

toBe(createdAt) requires the same object reference, so a converter that clones the input Date would fail this test even while preserving the timestamp value. Compare time/getTime() values instead.

Suggested assertion change
- expect(result.createdAt).toBe(createdAt)+ expect(result.createdAt?.getTime()).toBe(createdAt.getTime())- expect(uiMessages[0]?.createdAt).toBe(createdAt)+ expect(uiMessages[0]?.createdAt?.getTime()).toBe(createdAt.getTime())- expect(uiMessages[0]?.createdAt).toBe(createdAt)+ expect(uiMessages[0]?.createdAt?.getTime()).toBe(createdAt.getTime())

Also applies to lines 1236-1237 and 1288.

🤖 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/tests/message-converters.test.ts` around lines 1216 - 1217,
Update the createdAt assertions in the affected message-converter tests to
compare timestamp values via getTime() (or the equivalent time value) rather
than Date object identity, including the assertions near lines 1216, 1236, and
1288.

})

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)
Expand All@@ -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', () => {
Expand Down
Loading