Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 301
fix(ai): preserve message creation timestamps#1069
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
fiRepository: 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())JSRepository: 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())JSRepository: TanStack/ai Length of output: 6232 Compare timestamp values, not
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 | ||
| }) | ||
| 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', () => { | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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:
Repository: TanStack/ai
Length of output: 50368
🏁 Script executed:
Repository: TanStack/ai
Length of output: 50368
🏁 Script executed:
Repository: TanStack/ai
Length of output: 50368
Handle
createdAtformat during hydration.The message converters preserve
createdAt, butModelMessage.createdAtisDatewhile persisted JSON stores that timestamp as an ISO string. Add revival for hydrated string values, or change the persisted representation/type sonormalizeToUIMessage()does not copy a string intocreatedAt.🤖 Prompt for AI Agents