Skip to content

Commit 94dfef6

Browse files
abhiaiyer91Mastra Code (anthropic/claude-opus-4-8)
andauthored
fix(core): share processor state across step lifecycle chunks during streaming (#17370)
Co-authored-by: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
1 parent 4517213 commit 94dfef6

6 files changed

Lines changed: 82 additions & 10 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Fixed output processor state continuity for step lifecycle chunks during streaming. Lifecycle chunks (e.g. `step-finish`) routed through the stream `outputWriter` now share the same per-processor state map as the main model-output path, so state set in `processOutputStream` while handling content chunks is visible when the lifecycle chunk is processed (and in `processOutputResult`). Previously these chunks were handled with an isolated, empty state.
6+
7+
Note: as part of routing lifecycle chunks through output processors, an aborted stream now surfaces content the model produced before the abort (e.g. a `text-start` chunk and partial `text`) instead of dropping it. Consumers that assumed an aborted result always had empty text may now observe partial output.

observability/mastra/src/__snapshots__/agent-structured-output-trace-generate.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,11 +294,11 @@
294294
"environment": "test"
295295
},
296296
"input": {
297-
"totalChunks": 5,
297+
"totalChunks": 6,
298298
"accumulatedText": "Mock V2 generate response"
299299
},
300300
"output": {
301-
"totalChunks": 5,
301+
"totalChunks": 6,
302302
"accumulatedText": "Mock V2 generate response"
303303
}
304304
}

observability/mastra/src/__snapshots__/agent-structured-output-trace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,11 +292,11 @@
292292
"environment": "test"
293293
},
294294
"input": {
295-
"totalChunks": 3,
295+
"totalChunks": 4,
296296
"accumulatedText": "Mock V2 stream response"
297297
},
298298
"output": {
299-
"totalChunks": 3,
299+
"totalChunks": 4,
300300
"accumulatedText": "Mock V2 stream response"
301301
}
302302
}

packages/core/src/agent/agent-stream-processor.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,46 @@ describe('Processor state persistence across processOutputStream and processOutp
217217
expect(stateInOutputResult?.streamProcessed).toBe(true);
218218
expect(stateInOutputResult?.chunks).toEqual(['Hello ', 'World']);
219219
});
220+
221+
it('exposes prior processOutputStream state to the step-finish lifecycle chunk', async () => {
222+
// Regression for #16687: lifecycle chunks (step-start/step-finish) are routed
223+
// through output processors via a separate ProcessorRunner. That runner must share
224+
// the same processorStates map as the main model-output path, otherwise the
225+
// step-finish chunk is handled with a fresh, empty state and loses continuity.
226+
const stateByChunkType: Record<string, Record<string, unknown>> = {};
227+
228+
class LifecycleStateProcessor implements Processor {
229+
readonly id = 'lifecycle-state-processor';
230+
readonly name = 'Lifecycle State Processor';
231+
232+
async processOutputStream({ part, state }: any) {
233+
if (part.type === 'text-delta') {
234+
state.sawText = true;
235+
}
236+
// Snapshot the state the processor sees for each chunk type
237+
stateByChunkType[part.type] = { ...state };
238+
return part;
239+
}
240+
}
241+
242+
const agent = new Agent({
243+
id: 'lifecycle-agent',
244+
name: 'lifecycle-agent',
245+
instructions: 'Test agent',
246+
model: mockModel as any,
247+
outputProcessors: [new LifecycleStateProcessor()],
248+
});
249+
250+
const stream = await agent.stream('test message');
251+
for await (const _chunk of stream.textStream) {
252+
// consume
253+
}
254+
255+
// The processor must have observed the step-finish lifecycle chunk...
256+
expect(stateByChunkType['step-finish']).toBeDefined();
257+
// ...and at that point it must still see the state set while handling text-delta.
258+
expect(stateByChunkType['step-finish']?.sawText).toBe(true);
259+
});
220260
});
221261

222262
describe('OutputProcessor Metadata with Streaming (Issue #11454)', () => {

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8141,6 +8141,15 @@ export function optionsTests({ loopFn, runId }: { loopFn: typeof loop; runId: st
81418141
"runId": "test-run-id",
81428142
"type": "step-start",
81438143
},
8144+
{
8145+
"from": "AGENT",
8146+
"payload": {
8147+
"id": "id-2",
8148+
"providerMetadata": undefined,
8149+
},
8150+
"runId": "test-run-id",
8151+
"type": "text-start",
8152+
},
81448153
{
81458154
"from": "AGENT",
81468155
"payload": {},
@@ -8203,7 +8212,7 @@ export function optionsTests({ loopFn, runId }: { loopFn: typeof loop; runId: st
82038212
},
82048213
"output": {
82058214
"steps": [],
8206-
"text": "",
8215+
"text": "Hello",
82078216
"toolCalls": [],
82088217
"usage": {
82098218
"inputTokens": 0,
@@ -8692,6 +8701,15 @@ export function optionsTests({ loopFn, runId }: { loopFn: typeof loop; runId: st
86928701
"runId": "test-run-id",
86938702
"type": "step-start",
86948703
},
8704+
{
8705+
"from": "AGENT",
8706+
"payload": {
8707+
"id": "id-2",
8708+
"providerMetadata": undefined,
8709+
},
8710+
"runId": "test-run-id",
8711+
"type": "text-start",
8712+
},
86958713
{
86968714
"from": "AGENT",
86978715
"payload": {},
@@ -8817,7 +8835,7 @@ export function optionsTests({ loopFn, runId }: { loopFn: typeof loop; runId: st
88178835
},
88188836
"output": {
88198837
"steps": [],
8820-
"text": "",
8838+
"text": "Hello",
88218839
"toolCalls": [],
88228840
"usage": {
88238841
"inputTokens": 3,

packages/core/src/loop/workflows/stream.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,23 @@ export function workflowLoopStream<Tools extends ToolSet = ToolSet, OUTPUT = und
3535
// Normalize requestContext so data-chunk processors and the agentic loop share the same instance
3636
const requestContext = rest.requestContext ?? new RequestContext();
3737

38-
// Create a ProcessorRunner for data-* chunks so they go through output processors
38+
// Create a ProcessorRunner for chunks routed through outputWriter (data-* custom
39+
// chunks plus lifecycle chunks like step-finish) so they go through output processors.
40+
// Share the loop's processorStates map so these chunks see the same per-processor state
41+
// that the main model-output stream path populates; otherwise the runner would build an
42+
// isolated empty state map and break state continuity across the step lifecycle.
3943
const hasOutputProcessors = rest.outputProcessors && rest.outputProcessors.length > 0;
44+
const dataChunkProcessorStates = hasOutputProcessors
45+
? (rest.processorStates ?? new Map<string, ProcessorState>())
46+
: undefined;
4047
const dataChunkProcessorRunner = hasOutputProcessors
4148
? new ProcessorRunner({
4249
outputProcessors: rest.outputProcessors,
4350
logger: rest.logger || new ConsoleLogger({ level: 'error' }),
4451
agentName: agentId || 'unknown',
52+
processorStates: dataChunkProcessorStates,
4553
})
4654
: undefined;
47-
const dataChunkProcessorStates = hasOutputProcessors ? new Map<string, ProcessorState>() : undefined;
4855

4956
// Create a ProcessorStreamWriter so output processors can emit custom chunks back to the stream
5057
const dataChunkStreamWriter = {
@@ -69,7 +76,7 @@ export function workflowLoopStream<Tools extends ToolSet = ToolSet, OUTPUT = und
6976
processorId,
7077
} = await dataChunkProcessorRunner.processPart(
7178
chunk,
72-
(rest.processorStates ?? dataChunkProcessorStates!) as Map<string, ProcessorState<OUTPUT>>,
79+
dataChunkProcessorStates! as Map<string, ProcessorState<OUTPUT>>,
7380
undefined, // observabilityContext
7481
requestContext,
7582
messageList,
@@ -143,7 +150,7 @@ export function workflowLoopStream<Tools extends ToolSet = ToolSet, OUTPUT = und
143150
processorId,
144151
} = await dataChunkProcessorRunner.processPart(
145152
chunk,
146-
(rest.processorStates ?? dataChunkProcessorStates!) as Map<string, ProcessorState<OUTPUT>>,
153+
dataChunkProcessorStates! as Map<string, ProcessorState<OUTPUT>>,
147154
undefined,
148155
requestContext,
149156
messageList,

0 commit comments

Comments
 (0)