Skip to content

Commit 0c72f03

Browse files
TylerBarnesMastra Code (openai/gpt-5.5)
andauthored
fix(core): keep part timestamps out of message ordering (#17598)
This keeps message part timestamps from affecting transcript ordering. Part timestamps are still preserved as event metadata, but only the message-level `createdAt` advances the ordering watermark used for later messages/signals. Before, a late part timestamp could push a later signal forward: ```ts message.createdAt = 10:00:00 part.createdAt = 10:00:30 nextSignal.createdAt = 10:00:30.001 ``` After, later messages/signals are ordered from the message-level timestamp: ```ts message.createdAt = 10:00:00 part.createdAt = 10:00:30 nextSignal.createdAt = 10:00:03 ``` Also preserves the existing message-level `createdAt` when merging response content into a memory-loaded assistant message, since OM uses that timestamp to decide whether a message has already been observed. Verified with the focused message-list regression test, `pnpm build:core`, and `pnpm --filter ./packages/core check`. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## ELI5 Imagine you're organizing messages by time. This PR fixes a bug where a late timestamp on a piece of a message (a "part") could reorder later messages — now only the main message timestamp controls ordering, while part timestamps are preserved as metadata and no longer affect transcript ordering. ## Changes **Changeset Entry** (.changeset/old-news-press.md) - Adds a patch release note for `@mastra/core` describing the fix: message-part timestamps no longer advance the transcript ordering watermark. **Message Merging** (packages/core/src/agent/message-list/merge/MessageMerger.ts) - Preserve an existing message's `createdAt` when merging incoming content; removed logic that would update/advance `createdAt` from the incoming message. - Documentation updated to clarify that `createdAt` must remain unchanged so previously observed messages are not reprocessed. **Message List Ordering** (packages/core/src/agent/message-list/message-list.ts) - `MessageList.updateLastCreatedAt` now advances the ordering watermark using only message-level `createdAt`. - Removed prior logic that inspected part-level timestamps when updating the watermark. - Comments updated to reflect that `lastCreatedAt` is the message-level ordering timestamp. **Tests** (packages/core/src/agent/message-list/tests) - message-list-om-multistep.test.ts: - Updated OM multi-step test to ensure promoted (memory-loaded) assistant responses retain the original `createdAt`. - Added a regression test asserting that a tool-part with a later timestamp does not advance subsequent signal timestamps. - message-list-ordering.test.ts: - Adjusted assertions so signals are normalized using the response (message-level) timestamp rather than a later part timestamp. ## Verification - Focused regression tests added/updated. - Build and check commands referenced: `pnpm build:core` (runs turbo build --filter ./packages/core) and `pnpm --filter ./packages/core check` (typecheck/check step used in CI). <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Mastra Code (openai/gpt-5.5) <noreply@mastra.ai>
1 parent b3e9781 commit 0c72f03

5 files changed

Lines changed: 92 additions & 23 deletions

File tree

.changeset/old-news-press.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Fixed message part timestamps affecting transcript ordering by ensuring only message-level timestamps advance the ordering watermark.

packages/core/src/agent/message-list/merge/MessageMerger.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,18 +85,19 @@ export class MessageMerger {
8585
}
8686

8787
/**
88-
* Merge an incoming assistant message into the latest assistant message
88+
* Merge an incoming assistant message into the latest assistant message.
89+
*
90+
* This preserves the existing message-level createdAt. OM uses that timestamp
91+
* as its observation boundary, so moving it forward can make an already
92+
* observed message look unobserved and eligible for reprocessing.
8993
*
9094
* This handles:
9195
* - Updating tool invocations with their results
9296
* - Adding new parts in the correct order using anchor maps
9397
* - Inserting step-start markers where needed
94-
* - Updating timestamps and content strings
98+
* - Updating content strings
9599
*/
96100
static merge(latestMessage: MastraDBMessage, incomingMessage: MastraDBMessage): void {
97-
// Update timestamp
98-
latestMessage.createdAt = incomingMessage.createdAt || latestMessage.createdAt;
99-
100101
if (incomingMessage.content.metadata) {
101102
latestMessage.content.metadata = {
102103
...(latestMessage.content.metadata ?? {}),
@@ -208,9 +209,6 @@ export class MessageMerger {
208209
partsToAdd,
209210
});
210211

211-
if (latestMessage.createdAt.getTime() < incomingMessage.createdAt.getTime()) {
212-
latestMessage.createdAt = incomingMessage.createdAt;
213-
}
214212
if (!latestMessage.content.content && incomingMessage.content.content) {
215213
latestMessage.content.content = incomingMessage.content.content;
216214
}

packages/core/src/agent/message-list/message-list.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,17 +1596,10 @@ export class MessageList {
15961596
private lastCreatedAt?: number;
15971597

15981598
private updateLastCreatedAt(message: MastraDBMessage): void {
1599-
const latestMessageTime = message.createdAt.getTime();
1600-
const latestPartTime = Array.isArray(message.content.parts)
1601-
? message.content.parts.reduce((latest, part) => {
1602-
if (typeof part.createdAt === 'number' && part.createdAt > latest) {
1603-
return part.createdAt;
1604-
}
1605-
return latest;
1606-
}, latestMessageTime)
1607-
: latestMessageTime;
1608-
1609-
this.lastCreatedAt = Math.max(this.lastCreatedAt || 0, latestPartTime);
1599+
// Message-level createdAt controls transcript ordering and OM observation boundaries.
1600+
// Part timestamps are event metadata within a message and must not advance the
1601+
// ordering watermark used to timestamp later messages/signals.
1602+
this.lastCreatedAt = Math.max(this.lastCreatedAt || 0, message.createdAt.getTime());
16101603
}
16111604

16121605
// this makes sure messages added in order will always have a date atleast 1ms apart.
@@ -1632,10 +1625,9 @@ export class MessageList {
16321625

16331626
const now = new Date();
16341627
const nowTime = startDate?.getTime() || now.getTime();
1635-
// find the latest createdAt in stored messages and parts
16361628
const lastTime = this.lastCreatedAt || 0;
16371629

1638-
// make sure our new message is created later than the latest known message time
1630+
// make sure our new message is created later than the latest known ordering timestamp
16391631
// it's expected that messages are added to the list in order if they don't have a createdAt date on them
16401632
if (nowTime <= lastTime) {
16411633
const newDate = new Date(lastTime + 1);

packages/core/src/agent/message-list/tests/message-list-om-multistep.test.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from 'vitest';
22
import type { MastraDBMessage } from '../../../memory';
3+
import { createSignal } from '../../signals';
34
import { MessageList } from '../index';
45

56
const threadId = 'om-thread';
@@ -13,11 +14,12 @@ const resourceId = 'om-user';
1314
describe('MessageList — OM multi-step source handoff', () => {
1415
it('removes memory source when merged assistant content is promoted to response', () => {
1516
const assistantId = 'asst-om-1';
17+
const originalCreatedAt = new Date(1);
1618
const step1: MastraDBMessage = {
1719
id: assistantId,
1820
role: 'assistant',
1921
type: 'text',
20-
createdAt: new Date(1),
22+
createdAt: originalCreatedAt,
2123
threadId,
2224
resourceId,
2325
content: {
@@ -72,6 +74,7 @@ describe('MessageList — OM multi-step source handoff', () => {
7274
const responseDb = list.get.response.db();
7375
expect(responseDb).toHaveLength(1);
7476
expect(responseDb[0]!.id).toBe(assistantId);
77+
expect(responseDb[0]!.createdAt).toEqual(originalCreatedAt);
7578
expect(responseDb[0]!.content.parts?.some(p => p.type === 'text' && p.text === 'Done.')).toBe(true);
7679

7780
// Must not still be tracked as a memory-only row for the same object identity
@@ -81,4 +84,74 @@ describe('MessageList — OM multi-step source handoff', () => {
8184
expect(secondClear).toHaveLength(1);
8285
expect(secondClear[0]!.content.parts?.some(p => p.type === 'text' && p.text === 'Done.')).toBe(true);
8386
});
87+
88+
it('does not let promoted memory part timestamps advance signal timestamps', () => {
89+
const now = Date.now();
90+
const assistantId = 'asst-om-late-part';
91+
const messageCreatedAt = new Date(now);
92+
const latePartCreatedAt = now + 30_000;
93+
94+
const list = new MessageList({ threadId, resourceId });
95+
list.add(
96+
{
97+
id: assistantId,
98+
role: 'assistant',
99+
type: 'text',
100+
createdAt: messageCreatedAt,
101+
threadId,
102+
resourceId,
103+
content: {
104+
format: 2,
105+
parts: [
106+
{ type: 'text', text: 'Observed response start' },
107+
{
108+
type: 'tool-invocation',
109+
createdAt: latePartCreatedAt,
110+
toolInvocation: { state: 'call', toolCallId: 'tc-late', toolName: 'noop', args: {} },
111+
},
112+
],
113+
},
114+
},
115+
'memory',
116+
);
117+
118+
list.add(
119+
{
120+
id: assistantId,
121+
role: 'assistant',
122+
type: 'text',
123+
createdAt: new Date(now + 2_000),
124+
threadId,
125+
resourceId,
126+
content: {
127+
format: 2,
128+
parts: [
129+
{
130+
type: 'tool-invocation',
131+
toolInvocation: {
132+
state: 'result',
133+
toolCallId: 'tc-late',
134+
toolName: 'noop',
135+
args: {},
136+
result: { ok: true },
137+
},
138+
},
139+
{ type: 'text', text: 'Observed response complete' },
140+
],
141+
},
142+
},
143+
'response',
144+
);
145+
146+
const signalForTranscript = list.addSignal(
147+
createSignal({
148+
id: 'next-signal',
149+
type: 'user-message',
150+
contents: 'Next signal',
151+
createdAt: new Date(now + 3_000),
152+
}),
153+
);
154+
155+
expect(signalForTranscript.createdAt.getTime()).toBeLessThan(latePartCreatedAt);
156+
});
84157
});

packages/core/src/agent/message-list/tests/message-list-ordering.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,8 @@ describe('Message ordering with identical timestamps (Issue #10683)', () => {
517517
const storedSignal = list.get.all.db().at(-1)!;
518518
const recalledSignal = mastraDBMessageToSignal(storedSignal);
519519
expect(storedSignal.id).toBe(signal.id);
520-
expect(storedSignal.createdAt.getTime()).toBe(toolPartTime + 1);
520+
expect(storedSignal.createdAt.getTime()).toBe(responseTime.getTime() + 1);
521+
expect(storedSignal.createdAt.getTime()).toBeLessThan(toolPartTime);
521522
expect(recalledSignal.createdAt).toEqual(storedSignal.createdAt);
522523
expect(recalledSignal.acceptedAt).toEqual(signalTime);
523524
expect(storedSignal.content.metadata?.signal).toMatchObject({

0 commit comments

Comments
 (0)