Skip to content

Commit e06a159

Browse files
authored
fix(core): OM multi-step merge must drop memory source when promoting to response (#15277)
1 parent 890b24c commit e06a159

3 files changed

Lines changed: 95 additions & 0 deletions

File tree

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 assistant message tracking when ObservationalMemory clears step-1 output to memory and step-2 text merges into the same assistant message, so merged text is not lost on the next response clear.

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ export class MessageStateManager {
4040
this.memoryMessagesPersisted.add(message);
4141
break;
4242
case 'response':
43+
// Promoting from memory (e.g. OM step prepare → merge step-2 text): keep a single
44+
// canonical source so clear.response.db() cannot drop merged content while the
45+
// message remains only in memoryMessages.
46+
if (this.memoryMessages.has(message)) {
47+
this.memoryMessages.delete(message);
48+
}
4349
this.newResponseMessages.add(message);
4450
this.newResponseMessagesPersisted.add(message);
4551
// Handle case where a client-side tool response was added as user input
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { MastraDBMessage } from '../../../memory';
3+
import { MessageList } from '../index';
4+
5+
const threadId = 'om-thread';
6+
const resourceId = 'om-user';
7+
8+
/**
9+
* ObservationalMemory step > 0 clears response DB, persists, then re-adds as memory.
10+
* Step-2 assistant text merges into the same assistant message and must move back to
11+
* the response source exclusively so later clears / persistence see merged content.
12+
*/
13+
describe('MessageList — OM multi-step source handoff', () => {
14+
it('removes memory source when merged assistant content is promoted to response', () => {
15+
const assistantId = 'asst-om-1';
16+
const step1: MastraDBMessage = {
17+
id: assistantId,
18+
role: 'assistant',
19+
type: 'text',
20+
createdAt: new Date(1),
21+
threadId,
22+
resourceId,
23+
content: {
24+
format: 2,
25+
parts: [
26+
{ type: 'step-start' },
27+
{
28+
type: 'tool-invocation',
29+
toolInvocation: { state: 'call', toolCallId: 'tc-1', toolName: 'noop', args: {} },
30+
},
31+
],
32+
},
33+
};
34+
35+
const list = new MessageList({ threadId, resourceId });
36+
list.add(step1, 'response');
37+
38+
const cleared = list.clear.response.db();
39+
expect(cleared).toHaveLength(1);
40+
for (const m of cleared) {
41+
list.add(m, 'memory');
42+
}
43+
44+
const step2Merge: MastraDBMessage = {
45+
id: assistantId,
46+
role: 'assistant',
47+
type: 'text',
48+
createdAt: new Date(2),
49+
threadId,
50+
resourceId,
51+
content: {
52+
format: 2,
53+
parts: [
54+
{ type: 'step-start' },
55+
{
56+
type: 'tool-invocation',
57+
toolInvocation: {
58+
state: 'result',
59+
toolCallId: 'tc-1',
60+
toolName: 'noop',
61+
args: {},
62+
result: { ok: true },
63+
},
64+
},
65+
{ type: 'text', text: 'Done.' },
66+
],
67+
},
68+
};
69+
70+
list.add(step2Merge, 'response');
71+
72+
const responseDb = list.get.response.db();
73+
expect(responseDb).toHaveLength(1);
74+
expect(responseDb[0]!.id).toBe(assistantId);
75+
expect(responseDb[0]!.content.parts?.some(p => p.type === 'text' && p.text === 'Done.')).toBe(true);
76+
77+
// Must not still be tracked as a memory-only row for the same object identity
78+
expect(list.get.remembered.db().some(m => m.id === assistantId)).toBe(false);
79+
80+
const secondClear = list.clear.response.db();
81+
expect(secondClear).toHaveLength(1);
82+
expect(secondClear[0]!.content.parts?.some(p => p.type === 'text' && p.text === 'Done.')).toBe(true);
83+
});
84+
});

0 commit comments

Comments
 (0)