Message Reference Model
The four message chains and their reference relationships — why mutate is permanent pollution, push is truly ephemeral, and replacing arrays hinges on element identity
Why this page is second
The vast majority of “I modified messages but it snapped back” or “I thought the change was temporary but it
permanently polluted the conversation” issues trace back to the four message chains the AI SDK maintains internally
and the shallow reference relationships between them — which this page covers.
After reading, you can answer:
- “I
.push’d a message inprepareStep, will it still be there next step?” - “Where does
stepInputMessagescome from? Is it rebuilt each step?” - “Is it safe to modify
responseMessages?” - “Where does
prepareStepResult.messagesget written back to after the step?”
The four message chains
The anchors below point to the streamText path in ai@7.0.8 (generate-text/stream-text.ts:1732-2328, the async
streamStep function). The generateText path has identical structure but different locations
(generate-text/generate-text.ts:1340-1350); the semantics quoted below apply to both paths.
1. initialMessages — the input, unchanged for the whole invocation
const initialMessages = initialPrompt.messages; // stream-text.ts:1589
Origin: the array passed to agent.stream({ messages }), stored after standardizePrompt normalization.
Lifetime: unchanged for the entire agent.stream() invocation. The SDK does not push new elements into it.
Identity: the array reference is fixed; the inner message object references are also fixed — this is the root of all the traps that follow.
2. responseMessages — accumulated during the loop, assistant / tool only
// Initialized as empty
const initialResponseMessages = []; // stream-text.ts:1090
// ... at step end, before the next step begins ...
responseMessages.push(...(await toResponseMessages({ content, tools }))); // stream-text.ts:1318
Origin: the SDK converts each step’s content to role: "assistant" + role: "tool" messages via the
toResponseMessages(...) helper, then pushes them to the accumulator.
Critical constraint: toResponseMessages only produces role: "assistant" and role: "tool" — the SDK never
pushes role: "user" into responseMessages. This is why a user message pushed from prepareStep can
“auto-disappear” next step.
3. stepInputMessages — rebuilt each step, shallow spread
const stepInputMessages = [...initialMessages, ...responseMessages]; // stream-text.ts:1801
Origin: at the start of each step loop iteration, the SDK rebuilds this array and passes it as the messages param to
prepareStep.
Three critical properties:
- Rebuilt per step —
[...a, ...b]spread creates a new array. Outer identity changes. - Shallow references — elements (message objects) are not deep-copied; they’re still the same objects at
initialMessages[i]andresponseMessages[j]. - Mutating element fields = mutating the original chain — any mutation of
stepInputMessages[k].contentdirectly affects the object pointed to byinitialMessages[k].
4. prepareStepResult.messages — valid for this step only
const prepareStepResult = await prepareStep?.({ ..., messages: stepInputMessages }); // stream-text.ts:1806
// ...
const stepMessages = prepareStepResult?.messages ?? stepInputMessages; // stream-text.ts:1852 — used only for this step's model call
Origin: returned from your prepareStep function as { messages }.
Critical properties:
- Used only for this step’s model call (input to
convertToLanguageModelPrompt). - Not written back to
initialMessagesorresponseMessagesafter the call. - Next step’s
stepInputMessagesrebuilds fresh from[...initialMessages, ...responseMessages]— any additions or deletions in your returned array vanish.
Reference relationship diagram
Core observation: both stepInputMessages and prepareStepResult.messages are “new array per step”, but where
the element references come from determines whether your modifications persist.
Three modification modes — lifetime comparison
| Operation | Visible this step | Still there next stepInputMessages? | Why | Rating |
|---|---|---|---|---|
Mutate an existing message object fieldmsg.content += 'x'msg.content.push(part) | Yes | Yes — permanently polluted | Shared object reference, mutation bleeds into initialMessages[k] | Verdict: almost always a bug |
Replace the entire messages arrayreturn { messages: [...otherArray] } | Yes | Depends on element origin: - Elements from initialMessages / responseMessages still shared- Newly-constructed objects not shared | New array is discarded, but any mutated objects inside remain shared | Verdict: context-dependent |
Push a new message object to the returned arrayprep.push({ role: "user", content }) | Yes | No — gone next step | New object never entered initialMessages, won’t enter responseMessages (only accepts assistant/tool) | Verdict: the canonical truly-ephemeral injection |
Delete an existing messagereturn { messages: prep.filter(...) } | Yes (doesn’t see the deleted one) | Yes (original chain intact) | Returned array not written back; stepInputMessages rebuild still contains it | Verdict: misleading — hides, doesn’t delete |
Distilled rules:
- Want “ephemeral for this step”: return a new array or push a new object.
- Want “permanently modify the conversation”: don’t do it in
prepareStep— modify themessagesinput before callingagent.stream(). - Never mutate the fields of existing message objects.
Why shallow references, not deep copies
The AI SDK’s choice is deliberate:
- Performance: long conversations (100+ messages, huge content) can’t afford a deep clone every step.
- Consistency: all upstream callers (engine layer, business layer) see the same message objects the SDK sees internally — convenient for persistence and observability.
- Clear contract: the docs state “don’t mutate messages” in
prepareStep(officially warned, though not in a prominent place).
The cost is that developers must manually respect immutability — the SDK won’t enforce it. Break it, pay for it.
Practical rules
prepareStep: ({ messages }) => {
// WRONG — never
messages[messages.length - 1].content += "injection";
messages[0].content.push({ type: "text", text: "..." });
// OK — true ephemeral injection
return {
messages: [...messages, { role: "user", content: "injection (this step only)" }],
};
// OK — filter for this step (new array, no object mutation)
return {
messages: messages.filter((m) => m.role !== "tool"),
};
// OK — replace one message for this step (new object)
return {
messages: messages.map((m, i) => (i === messages.length - 1 ? { ...m, content: reformat(m.content) } : m)),
};
};
An often-overlooked detail: when the accumulated response messages get written
In the streamText path: the accumulated response messages advance when one step ends, before the next step
begins (stream-text.ts:1317 recordedSteps.push; 1318-1321 stepMessagesForNextStep); onStepEnd (v6:
onStepFinish, still a deprecated alias) fires just before that, via a downstream transform (event processor at
stream-text.ts:1273-1325). So:
prepareStep(n+1)seesstepInputMessagesalready containing the assistant/tool messages generated in step nonStepEnd(n)’s payloadstepResult.response.messagesiscloneModelMessages(stepResponseMessages)— a deep clone (stream-text.ts:1301)
In the generateText path: stepResult.response.messages is likewise cloneModelMessages(stepResponseMessages) — a
deep clone (generate-text.ts:1291).
v7 change: as of
ai@7.0.8both paths deep-clone the step’s response messages intostepResult.response.messages, so mutating them insideonStepEnddoes not propagate into the next step. Earlier versions handed streamText live references. The conservative rule still stands: don’t rely on mutating messages from callbacks — treat them as read-only.
Further reading
- prepareStep Semantics — builds on this page’s reference model to unpack all typical
prepareSteppatterns and traps - Runtime Lifecycle — the full runtime picture of how message chains evolve over time
- SDK source anchors (
ai@7.0.8, paths undersrc/):- streamText path:
generate-text/stream-text.ts:1732-2328(streamSteploop);1806-1818(prepareStep call site);1794-1804(accumulatedResponseMessages/stepInputMessagesthat feed the next step) - generateText path:
generate-text/generate-text.ts:1340-1350(do-while loop);805-817(prepareStep call) - Downstream transform / stepResult assembly:
generate-text/stream-text.ts:1273-1325(currentStepResultassembly);1306-1309(onStepEnd/onStepFinishemission);1317(recordedSteps.push)
- streamText path: