Skip to content

Commit aebde9c

Browse files
CalebBarnesMastra Code (anthropic/claude-opus-4-6)abhiaiyer91roaminro
authored
fix(core): fix message part ordering in buildMessagesFromChunks to use first-delta position (#16073)
## Problem `buildMessagesFromChunks` emitted text and reasoning parts at `*-end` time, not when they first appeared in the stream. When spans overlapped with tool calls (e.g. `text-start` → `tool-call` → `text-end`), the tool call would appear before the text part in the final message — breaking step-start insertion logic and agent loop behavior. ## Solution Refactored part emission to use **first-delta ordering**: parts are pushed into the `parts` array when their first delta arrives (not on `*-start` or `*-end`), then mutated in-place as subsequent deltas arrive. This preserves the true stream order without accumulating deltas into intermediate arrays. Key changes: - **First-delta positioning** — a part is created and pushed to the array on the first `text-delta` or `reasoning-delta`, establishing its position relative to tool calls and other parts - **In-place mutation** — subsequent deltas append directly to the part via a reference map (`textRefs`/`reasoningRefs`), avoiding `deltas[]` accumulation and `join("")` at the end - **Metadata maps** — `*-start` chunks only stash `providerMetadata` in lightweight maps (`textMeta`/`reasoningMeta`); no part is created until content actually arrives - **Redacted reasoning** — still pushed immediately on `reasoning-start` since redacted spans have no deltas - **Empty reasoning** — emitted on `reasoning-end` if no deltas arrived, preserving OpenAI `item_reference` requirements (#9005) - **Empty text cleanup** — text parts where every delta was an empty string `''` are filtered out at the end ## Testing - Added regression test for interleaved `text-start` → `reasoning-start` → `reasoning-delta` → `text-delta` ordering (from #15914) - Updated existing interleaved text/tool-call test to verify first-delta ordering - All 29 tests pass <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## ELI5 When the model streams bits of text, thoughts, or tool calls, this change makes sure each saved message piece appears when its first real content shows up so the saved order matches what happened live. ## Overview Refactors buildMessagesFromChunks to use first-delta ordering: text and reasoning parts are created/pushed when their first content delta arrives (not at span start or end), preserving true stream-relative ordering with tool calls and other parts. Provider metadata from span-start is stashed and applied when content appears; redacted reasoning is still emitted immediately; empty reasoning parts are emitted when required to satisfy OpenAI item_reference semantics; purely-empty text spans are filtered out except when they carry providerMetadata. ## Changes - First-delta positioning: create and push text/reasoning parts on first text-delta/reasoning-delta so part ordering reflects actual content arrival (#15914). - In-place mutation: subsequent deltas update the already-pushed part via reference maps (textRefs/reasoningRefs) instead of buffering and joining at end. - *-start metadata handling: text-start and non-redacted reasoning-start stash providerMetadata in lightweight meta maps (textMeta/reasoningMeta) without creating parts until content arrives. - Redacted reasoning: redacted reasoning spans are emitted immediately on reasoning-start (since no deltas will arrive). - Empty reasoning: reasoning-end emits an empty reasoning part if no deltas arrived to satisfy downstream item_reference requirements (consistent with #9005 rationale). - Empty text filtering: drop text parts whose deltas were all empty strings while preserving empty text parts that carry providerMetadata. - Stream-end/cleanup: text-end cleans refs/meta without forcing a flush; unclosed reasoning spans with no deltas are emitted as empty reasoning at stream end. - Step-start insertion: step-start markers are inserted after filtering so they remain correctly ordered relative to actual content. ## Tests - Added regression test for interleaved reasoning/text ordering and updated tests for interleaved text/tool-call/step-start scenarios. - Updated inline snapshot expectations for mixed text/reasoning blocks in options tests. - Test run: 29 tests pass. ## Changesets - Adds a patch changeset for @mastra/core documenting the fix: streaming message parts (text, reasoning, tool calls) are now ordered by actual arrival sequence. ## Issues Fixed - Fixes #16007 (message persistence ordering) and #15914 (buildMessagesFromChunks ordering bug). <!-- end of auto-generated comment: release notes by coderabbit.ai --> Fixes #16007 Fixes #15914 --------- Co-authored-by: CalebBarnes <CalebBarnes@users.noreply.github.com> Co-authored-by: Mastra Code (anthropic/claude-opus-4-6) <noreply@mastra.ai> Co-authored-by: Abhi Aiyer <abhiaiyer91@gmail.com> Co-authored-by: Roamin <97863888+roaminro@users.noreply.github.com>
1 parent 900d086 commit aebde9c

5 files changed

Lines changed: 215 additions & 154 deletions

File tree

.changeset/whole-pugs-carry.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 ordering in agent streaming responses. Message parts (text, reasoning, tool calls) now appear in the correct order they arrived in the stream, preventing incorrect step sequencing and agent loop behavior issues.

observability/mastra/src/__snapshots__/multi-step-text-accumulation-trace.json

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,12 @@
129129
},
130130
"metadata": {
131131
"runId": "<runId-1>",
132+
"environment": "test",
132133
"modelMetadata": {
133134
"modelId": "mock-model-id",
134135
"modelVersion": "v2",
135136
"modelProvider": "mock-provider"
136-
},
137-
"environment": "test"
137+
}
138138
},
139139
"input": [
140140
{
@@ -267,9 +267,9 @@
267267
},
268268
"metadata": {
269269
"runId": "<runId-1>",
270+
"environment": "test",
270271
"toolCallId": "call-calc-1",
271-
"toolName": "calculator",
272-
"environment": "test"
272+
"toolName": "calculator"
273273
}
274274
}
275275
}
@@ -305,12 +305,12 @@
305305
},
306306
"metadata": {
307307
"runId": "<runId-1>",
308+
"environment": "test",
308309
"modelMetadata": {
309310
"modelId": "mock-model-id",
310311
"modelVersion": "v2",
311312
"modelProvider": "mock-provider"
312-
},
313-
"environment": "test"
313+
}
314314
},
315315
"input": [
316316
{
@@ -323,15 +323,11 @@
323323
},
324324
{
325325
"role": "assistant",
326-
"content": "[tool: calculator]"
326+
"content": "Let me calculate that for you. [tool: calculator]"
327327
},
328328
{
329329
"role": "tool",
330330
"content": "[tool: calculator]"
331-
},
332-
{
333-
"role": "assistant",
334-
"content": "Let me calculate that for you. "
335331
}
336332
],
337333
"output": {

packages/core/src/loop/test-utils/options.ts

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7571,18 +7571,18 @@ export function optionsTests({ loopFn, runId }: { loopFn: typeof loop; runId: st
75717571
},
75727572
{
75737573
"providerOptions": undefined,
7574-
"text": "This is a test.",
7574+
"text": "Hello, world!",
75757575
"type": "text",
75767576
},
75777577
{
75787578
"providerOptions": undefined,
7579-
"text": "Separate thoughts",
7580-
"type": "reasoning",
7579+
"text": "This is a test.",
7580+
"type": "text",
75817581
},
75827582
{
75837583
"providerOptions": undefined,
7584-
"text": "Hello, world!",
7585-
"type": "text",
7584+
"text": "Separate thoughts",
7585+
"type": "reasoning",
75867586
},
75877587
],
75887588
"role": "assistant",
@@ -7608,18 +7608,18 @@ export function optionsTests({ loopFn, runId }: { loopFn: typeof loop; runId: st
76087608
},
76097609
{
76107610
"providerOptions": undefined,
7611-
"text": "This is a test.",
7611+
"text": "Hello, world!",
76127612
"type": "text",
76137613
},
76147614
{
76157615
"providerOptions": undefined,
7616-
"text": "Separate thoughts",
7617-
"type": "reasoning",
7616+
"text": "This is a test.",
7617+
"type": "text",
76187618
},
76197619
{
76207620
"providerOptions": undefined,
7621-
"text": "Hello, world!",
7622-
"type": "text",
7621+
"text": "Separate thoughts",
7622+
"type": "reasoning",
76237623
},
76247624
],
76257625
"role": "assistant",
@@ -7673,18 +7673,18 @@ export function optionsTests({ loopFn, runId }: { loopFn: typeof loop; runId: st
76737673
},
76747674
{
76757675
"providerOptions": undefined,
7676-
"text": "This is a test.",
7676+
"text": "Hello, world!",
76777677
"type": "text",
76787678
},
76797679
{
76807680
"providerOptions": undefined,
7681-
"text": "Separate thoughts",
7682-
"type": "reasoning",
7681+
"text": "This is a test.",
7682+
"type": "text",
76837683
},
76847684
{
76857685
"providerOptions": undefined,
7686-
"text": "Hello, world!",
7687-
"type": "text",
7686+
"text": "Separate thoughts",
7687+
"type": "reasoning",
76887688
},
76897689
],
76907690
"role": "assistant",
@@ -7752,18 +7752,18 @@ export function optionsTests({ loopFn, runId }: { loopFn: typeof loop; runId: st
77527752
},
77537753
{
77547754
"providerOptions": undefined,
7755-
"text": "This is a test.",
7755+
"text": "Hello, world!",
77567756
"type": "text",
77577757
},
77587758
{
77597759
"providerOptions": undefined,
7760-
"text": "Separate thoughts",
7761-
"type": "reasoning",
7760+
"text": "This is a test.",
7761+
"type": "text",
77627762
},
77637763
{
77647764
"providerOptions": undefined,
7765-
"text": "Hello, world!",
7766-
"type": "text",
7765+
"text": "Separate thoughts",
7766+
"type": "reasoning",
77677767
},
77687768
],
77697769
"role": "assistant",
@@ -7789,18 +7789,18 @@ export function optionsTests({ loopFn, runId }: { loopFn: typeof loop; runId: st
77897789
},
77907790
{
77917791
"providerOptions": undefined,
7792-
"text": "This is a test.",
7792+
"text": "Hello, world!",
77937793
"type": "text",
77947794
},
77957795
{
77967796
"providerOptions": undefined,
7797-
"text": "Separate thoughts",
7798-
"type": "reasoning",
7797+
"text": "This is a test.",
7798+
"type": "text",
77997799
},
78007800
{
78017801
"providerOptions": undefined,
7802-
"text": "Hello, world!",
7803-
"type": "text",
7802+
"text": "Separate thoughts",
7803+
"type": "reasoning",
78047804
},
78057805
],
78067806
"role": "assistant",
@@ -7854,18 +7854,18 @@ export function optionsTests({ loopFn, runId }: { loopFn: typeof loop; runId: st
78547854
},
78557855
{
78567856
"providerOptions": undefined,
7857-
"text": "This is a test.",
7857+
"text": "Hello, world!",
78587858
"type": "text",
78597859
},
78607860
{
78617861
"providerOptions": undefined,
7862-
"text": "Separate thoughts",
7863-
"type": "reasoning",
7862+
"text": "This is a test.",
7863+
"type": "text",
78647864
},
78657865
{
78667866
"providerOptions": undefined,
7867-
"text": "Hello, world!",
7868-
"type": "text",
7867+
"text": "Separate thoughts",
7868+
"type": "reasoning",
78697869
},
78707870
],
78717871
"role": "assistant",

packages/core/src/loop/workflows/agentic-execution/build-messages-from-chunks.test.ts

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,10 @@ describe('buildMessagesFromChunks', () => {
8080
{ type: 'text-end', payload: { id: 't1' } },
8181
]);
8282
expect(result).toHaveLength(2);
83-
// Parts are emitted in text-end order
84-
expect(result[0]).toMatchObject({ type: 'text', text: 'Goodbye' });
85-
expect(result[1]).toMatchObject({ type: 'text', text: 'Hello, world!' });
83+
// Parts are emitted in first-delta order (content arrival order), not text-end order.
84+
// t1's first delta arrives before t2's first delta, so t1 appears first.
85+
expect(result[0]).toMatchObject({ type: 'text', text: 'Hello, world!' });
86+
expect(result[1]).toMatchObject({ type: 'text', text: 'Goodbye' });
8687
});
8788

8889
// ── ProviderMetadata cascading ──────────────────────────────
@@ -304,6 +305,44 @@ describe('buildMessagesFromChunks', () => {
304305

305306
// ── Mixed content ordering ──────────────────────────────────
306307

308+
it('should preserve stream start order when text-end arrives after tool-call', () => {
309+
// text-start arrives BEFORE tool-call, but text-end arrives AFTER.
310+
// Parts should reflect the order content *first appeared* in the stream.
311+
const result = parts([
312+
{ type: 'text-start', payload: { id: 't1' } },
313+
{ type: 'text-delta', payload: { id: 't1', text: 'Before tool' } },
314+
// Tool call arrives while text span t1 is still open
315+
{
316+
type: 'tool-call',
317+
payload: { toolCallId: 'tc1', toolName: 'myTool', args: {} },
318+
},
319+
// Text span t1 closes after the tool-call
320+
{ type: 'text-end', payload: { id: 't1' } },
321+
]);
322+
323+
const types = result.map((p: any) => p.type);
324+
// text t1 started before tool-call, so it should appear first
325+
expect(types).toEqual(['text', 'tool-invocation']);
326+
});
327+
328+
it('should preserve stream start order when reasoning-end arrives after tool-call', () => {
329+
const result = parts([
330+
{ type: 'reasoning-start', payload: { id: 'r1' } },
331+
{ type: 'reasoning-delta', payload: { id: 'r1', text: 'Thinking...' } },
332+
// Tool call arrives while reasoning span is still open
333+
{
334+
type: 'tool-call',
335+
payload: { toolCallId: 'tc1', toolName: 'myTool', args: {} },
336+
},
337+
// Reasoning ends after tool-call
338+
{ type: 'reasoning-end', payload: { id: 'r1' } },
339+
]);
340+
341+
const types = result.map((p: any) => p.type);
342+
// reasoning started before tool-call, so it should appear first
343+
expect(types).toEqual(['reasoning', 'tool-invocation']);
344+
});
345+
307346
it('should preserve correct order: reasoning, text, tool-call', () => {
308347
const result = parts([
309348
{ type: 'reasoning-start', payload: { id: 'r1' } },
@@ -322,6 +361,41 @@ describe('buildMessagesFromChunks', () => {
322361
expect(types).toEqual(['reasoning', 'text', 'tool-invocation']);
323362
});
324363

364+
it('should produce reasoning → text → tool-calls when reasoning-end arrives after text-end (#15914)', () => {
365+
// Regression for #15914: stream order from Ollama qwen3 has text-start before
366+
// reasoning-start, reasoning-delta before text-delta, and reasoning-end after text-end.
367+
// Parts should follow first-content-arrival order (reasoning before text).
368+
const result = parts([
369+
{ type: 'response-metadata', payload: { id: 'rm1', modelId: 'test-model' } },
370+
{ type: 'text-start', payload: { id: 't1' } },
371+
{ type: 'reasoning-start', payload: { id: 'r1' } },
372+
{ type: 'reasoning-delta', payload: { id: 'r1', text: 'Thinking...' } },
373+
{ type: 'text-delta', payload: { id: 't1', text: 'Hello' } },
374+
{ type: 'tool-call-input-streaming-start', payload: { toolCallId: 'tc1', toolName: 'myTool', args: {} } },
375+
{ type: 'tool-call-delta', payload: { toolCallId: 'tc1', argsTextDelta: "{'q':'first'}" } },
376+
{ type: 'tool-call-input-streaming-end', payload: { toolCallId: 'tc1' } },
377+
{ type: 'tool-call', payload: { toolCallId: 'tc1', toolName: 'myTool', args: { q: 'first' } } },
378+
{ type: 'tool-call-input-streaming-start', payload: { toolCallId: 'tc2', toolName: 'myTool', args: {} } },
379+
{ type: 'tool-call-delta', payload: { toolCallId: 'tc2', argsTextDelta: "{'q':'second'}" } },
380+
{ type: 'tool-call-input-streaming-end', payload: { toolCallId: 'tc2' } },
381+
{ type: 'tool-call', payload: { toolCallId: 'tc2', toolName: 'myTool', args: { q: 'second' } } },
382+
{ type: 'text-end', payload: { id: 't1' } },
383+
{ type: 'reasoning-end', payload: { id: 'r1' } },
384+
{ type: 'finish', payload: { finishReason: 'stop', usage: {} } },
385+
]);
386+
387+
expect(result).toHaveLength(4);
388+
expect(result.map((p: any) => p.type)).toEqual(['reasoning', 'text', 'tool-invocation', 'tool-invocation']);
389+
expect(result[0]).toMatchObject({ type: 'reasoning', details: [{ type: 'text', text: 'Thinking...' }] });
390+
expect(result[1]).toMatchObject({ type: 'text', text: 'Hello' });
391+
expect(result[2]).toMatchObject({
392+
toolInvocation: { state: 'call', toolCallId: 'tc1', toolName: 'myTool', args: { q: 'first' } },
393+
});
394+
expect(result[3]).toMatchObject({
395+
toolInvocation: { state: 'call', toolCallId: 'tc2', toolName: 'myTool', args: { q: 'second' } },
396+
});
397+
});
398+
325399
// ── Empty stream / no parts ─────────────────────────────────
326400

327401
it('should return empty array for empty chunks', () => {

0 commit comments

Comments
 (0)