Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-prepare-step-system-ordering.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/ai': patch
---

Fix `prepareStep` system message being discarded when `messages` is also returned

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add an e2e test for this to prevent regressions @TooTallNate? since we now have the DurableAgent e2e test infra as of this week :)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also when relevant/possible, we should update the DurableAgent chat app to use the feature - for example when adding/fixing provider tools and client side tools, I would've been good to add that feature to the app as an easy way to try it and see if it actually works and the DX for it (the app didn't exist at the time and the friction to do a tarball and test with flight booking app is a bit annoying)

maybe this time we should have something in the DurableAgent test app that uses prepareStep?

217 changes: 216 additions & 1 deletion packages/ai/src/agent/stream-text-iterator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,10 @@ import type {
LanguageModelV3Prompt,
LanguageModelV3ToolCall,
LanguageModelV3ToolResult,
LanguageModelV3ToolResultPart,
} from '@ai-sdk/provider';
import type { StepResult, ToolSet, UIMessageChunk } from 'ai';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

// Mock doStreamStep
vi.mock('./do-stream-step.js', () => ({
Expand DownExpand Up@@ -651,4 +652,218 @@ describe('streamTextIterator', () => {
});
});
});

describe('prepareStep system and messages ordering', () => {
it('should apply system message when prepareStep returns only system', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

const iterator = streamTextIterator({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'You are a helpful assistant.',
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'You are a helpful assistant.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
});
});

it('should preserve system message when prepareStep returns both system and messages', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

// prepareStep returns both system and messages — system should NOT be lost
const customMessages: LanguageModelV3Prompt = [
{
role: 'user',
content: [{ type: 'text', text: 'modified message' }],
},
];

const iterator = streamTextIterator({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'original' }] },
],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'Dynamic system prompt.',
messages: customMessages,
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
// System message should be prepended to the replaced messages
expect(capturedPrompt!).toHaveLength(2);
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'Dynamic system prompt.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'modified message' }],
});
});

it('should replace existing system message when messages already contains one', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

// Messages already include a system message — prepareStep's system should replace it
const customMessages: LanguageModelV3Prompt = [
{ role: 'system', content: 'Old system prompt.' },
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
];

const iterator = streamTextIterator({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'original' }] },
],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'New system prompt.',
messages: customMessages,
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
expect(capturedPrompt!).toHaveLength(2);
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'New system prompt.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
});
});

it('should update system message on subsequent steps', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

const capturedPrompts: LanguageModelV3Prompt[] = [];

const toolCall: LanguageModelV3ToolCall = {
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'testTool',
input: '{}',
};

vi.mocked(doStreamStep)
.mockImplementationOnce(async (prompt) => {
capturedPrompts.push([...prompt]);
return {
toolCalls: [toolCall],
finish: { finishReason: 'tool-calls' },
step: createMockStepResult({ finishReason: 'tool-calls' }),
};
})
.mockImplementationOnce(async (prompt) => {
capturedPrompts.push([...prompt]);
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

const iterator = streamTextIterator({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
tools: {
testTool: {
description: 'Test',
execute: async () => ({ ok: true }),
},
} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: ({ stepNumber: sn }) => ({
system: `System prompt v${sn}`,
}),
});

// First step
await iterator.next();

// Provide tool results
const toolResults: LanguageModelV3ToolResultPart[] = [
{
type: 'tool-result',
toolCallId: 'call-1',
toolName: 'testTool',
output: { type: 'text', value: '{"ok":true}' },
},
];

// Second step
await iterator.next(toolResults);

expect(capturedPrompts).toHaveLength(2);
// First step should have system v0
expect(capturedPrompts[0][0]).toEqual({
role: 'system',
content: 'System prompt v0',
});
// Second step should have system v1
expect(capturedPrompts[1][0]).toEqual({
role: 'system',
content: 'System prompt v1',
});
});
});
});
10 changes: 6 additions & 4 deletions packages/ai/src/agent/stream-text-iterator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,8 +148,13 @@ export async function* streamTextIterator({
if (prepareResult.model !== undefined) {
currentModel = prepareResult.model;
}
if (prepareResult.messages !== undefined) {
conversationPrompt = [...prepareResult.messages];
}
if (prepareResult.system !== undefined) {
// Update or prepend system message in the conversation prompt
// Update or prepend system message in the conversation prompt.
// Applied AFTER messages override so the system message isn't
// lost when messages replaces the prompt.
if (
conversationPrompt.length > 0 &&
conversationPrompt[0].role === 'system'
Expand All@@ -167,9 +172,6 @@ export async function* streamTextIterator({
});
}
}
Comment on lines 151 to 174
if (prepareResult.messages !== undefined) {
conversationPrompt = [...prepareResult.messages];
}
if (prepareResult.experimental_context !== undefined) {
currentContext = prepareResult.experimental_context;
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Fix prepareStep system message lost when messages is also returned by TooTallNate · Pull Request #1389 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-prepare-step-system-ordering.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/ai': patch
---

Fix `prepareStep` system message being discarded when `messages` is also returned

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add an e2e test for this to prevent regressions @TooTallNate? since we now have the DurableAgent e2e test infra as of this week :)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also when relevant/possible, we should update the DurableAgent chat app to use the feature - for example when adding/fixing provider tools and client side tools, I would've been good to add that feature to the app as an easy way to try it and see if it actually works and the DX for it (the app didn't exist at the time and the friction to do a tarball and test with flight booking app is a bit annoying)

maybe this time we should have something in the DurableAgent test app that uses prepareStep?

217 changes: 216 additions & 1 deletion packages/ai/src/agent/stream-text-iterator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,10 @@ import type {
LanguageModelV3Prompt,
LanguageModelV3ToolCall,
LanguageModelV3ToolResult,
LanguageModelV3ToolResultPart,
} from '@ai-sdk/provider';
import type { StepResult, ToolSet, UIMessageChunk } from 'ai';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

// Mock doStreamStep
vi.mock('./do-stream-step.js', () => ({
Expand DownExpand Up@@ -651,4 +652,218 @@ describe('streamTextIterator', () => {
});
});
});

describe('prepareStep system and messages ordering', () => {
it('should apply system message when prepareStep returns only system', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

const iterator = streamTextIterator({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'You are a helpful assistant.',
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'You are a helpful assistant.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
});
});

it('should preserve system message when prepareStep returns both system and messages', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

// prepareStep returns both system and messages — system should NOT be lost
const customMessages: LanguageModelV3Prompt = [
{
role: 'user',
content: [{ type: 'text', text: 'modified message' }],
},
];

const iterator = streamTextIterator({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'original' }] },
],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'Dynamic system prompt.',
messages: customMessages,
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
// System message should be prepended to the replaced messages
expect(capturedPrompt!).toHaveLength(2);
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'Dynamic system prompt.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'modified message' }],
});
});

it('should replace existing system message when messages already contains one', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

// Messages already include a system message — prepareStep's system should replace it
const customMessages: LanguageModelV3Prompt = [
{ role: 'system', content: 'Old system prompt.' },
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
];

const iterator = streamTextIterator({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'original' }] },
],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'New system prompt.',
messages: customMessages,
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
expect(capturedPrompt!).toHaveLength(2);
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'New system prompt.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
});
});

it('should update system message on subsequent steps', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

const capturedPrompts: LanguageModelV3Prompt[] = [];

const toolCall: LanguageModelV3ToolCall = {
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'testTool',
input: '{}',
};

vi.mocked(doStreamStep)
.mockImplementationOnce(async (prompt) => {
capturedPrompts.push([...prompt]);
return {
toolCalls: [toolCall],
finish: { finishReason: 'tool-calls' },
step: createMockStepResult({ finishReason: 'tool-calls' }),
};
})
.mockImplementationOnce(async (prompt) => {
capturedPrompts.push([...prompt]);
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

const iterator = streamTextIterator({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
tools: {
testTool: {
description: 'Test',
execute: async () => ({ ok: true }),
},
} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: ({ stepNumber: sn }) => ({
system: `System prompt v${sn}`,
}),
});

// First step
await iterator.next();

// Provide tool results
const toolResults: LanguageModelV3ToolResultPart[] = [
{
type: 'tool-result',
toolCallId: 'call-1',
toolName: 'testTool',
output: { type: 'text', value: '{"ok":true}' },
},
];

// Second step
await iterator.next(toolResults);

expect(capturedPrompts).toHaveLength(2);
// First step should have system v0
expect(capturedPrompts[0][0]).toEqual({
role: 'system',
content: 'System prompt v0',
});
// Second step should have system v1
expect(capturedPrompts[1][0]).toEqual({
role: 'system',
content: 'System prompt v1',
});
});
});
});
10 changes: 6 additions & 4 deletions packages/ai/src/agent/stream-text-iterator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,8 +148,13 @@ export async function* streamTextIterator({
if (prepareResult.model !== undefined) {
currentModel = prepareResult.model;
}
if (prepareResult.messages !== undefined) {
conversationPrompt = [...prepareResult.messages];
}
if (prepareResult.system !== undefined) {
// Update or prepend system message in the conversation prompt
// Update or prepend system message in the conversation prompt.
// Applied AFTER messages override so the system message isn't
// lost when messages replaces the prompt.
if (
conversationPrompt.length > 0 &&
conversationPrompt[0].role === 'system'
Expand All@@ -167,9 +172,6 @@ export async function* streamTextIterator({
});
}
}
Comment on lines 151 to 174
if (prepareResult.messages !== undefined) {
conversationPrompt = [...prepareResult.messages];
}
if (prepareResult.experimental_context !== undefined) {
currentContext = prepareResult.experimental_context;
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix prepareStep system message lost when messages is also returned by TooTallNate · Pull Request #1389 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-prepare-step-system-ordering.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/ai': patch
---

Fix `prepareStep` system message being discarded when `messages` is also returned

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add an e2e test for this to prevent regressions @TooTallNate? since we now have the DurableAgent e2e test infra as of this week :)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also when relevant/possible, we should update the DurableAgent chat app to use the feature - for example when adding/fixing provider tools and client side tools, I would've been good to add that feature to the app as an easy way to try it and see if it actually works and the DX for it (the app didn't exist at the time and the friction to do a tarball and test with flight booking app is a bit annoying)

maybe this time we should have something in the DurableAgent test app that uses prepareStep?

217 changes: 216 additions & 1 deletion packages/ai/src/agent/stream-text-iterator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,10 @@ import type {
LanguageModelV3Prompt,
LanguageModelV3ToolCall,
LanguageModelV3ToolResult,
LanguageModelV3ToolResultPart,
} from '@ai-sdk/provider';
import type { StepResult, ToolSet, UIMessageChunk } from 'ai';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

// Mock doStreamStep
vi.mock('./do-stream-step.js', () => ({
Expand DownExpand Up@@ -651,4 +652,218 @@ describe('streamTextIterator', () => {
});
});
});

describe('prepareStep system and messages ordering', () => {
it('should apply system message when prepareStep returns only system', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

const iterator = streamTextIterator({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'You are a helpful assistant.',
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'You are a helpful assistant.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
});
});

it('should preserve system message when prepareStep returns both system and messages', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

// prepareStep returns both system and messages — system should NOT be lost
const customMessages: LanguageModelV3Prompt = [
{
role: 'user',
content: [{ type: 'text', text: 'modified message' }],
},
];

const iterator = streamTextIterator({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'original' }] },
],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'Dynamic system prompt.',
messages: customMessages,
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
// System message should be prepended to the replaced messages
expect(capturedPrompt!).toHaveLength(2);
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'Dynamic system prompt.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'modified message' }],
});
});

it('should replace existing system message when messages already contains one', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

// Messages already include a system message — prepareStep's system should replace it
const customMessages: LanguageModelV3Prompt = [
{ role: 'system', content: 'Old system prompt.' },
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
];

const iterator = streamTextIterator({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'original' }] },
],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'New system prompt.',
messages: customMessages,
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
expect(capturedPrompt!).toHaveLength(2);
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'New system prompt.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
});
});

it('should update system message on subsequent steps', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

const capturedPrompts: LanguageModelV3Prompt[] = [];

const toolCall: LanguageModelV3ToolCall = {
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'testTool',
input: '{}',
};

vi.mocked(doStreamStep)
.mockImplementationOnce(async (prompt) => {
capturedPrompts.push([...prompt]);
return {
toolCalls: [toolCall],
finish: { finishReason: 'tool-calls' },
step: createMockStepResult({ finishReason: 'tool-calls' }),
};
})
.mockImplementationOnce(async (prompt) => {
capturedPrompts.push([...prompt]);
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

const iterator = streamTextIterator({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
tools: {
testTool: {
description: 'Test',
execute: async () => ({ ok: true }),
},
} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: ({ stepNumber: sn }) => ({
system: `System prompt v${sn}`,
}),
});

// First step
await iterator.next();

// Provide tool results
const toolResults: LanguageModelV3ToolResultPart[] = [
{
type: 'tool-result',
toolCallId: 'call-1',
toolName: 'testTool',
output: { type: 'text', value: '{"ok":true}' },
},
];

// Second step
await iterator.next(toolResults);

expect(capturedPrompts).toHaveLength(2);
// First step should have system v0
expect(capturedPrompts[0][0]).toEqual({
role: 'system',
content: 'System prompt v0',
});
// Second step should have system v1
expect(capturedPrompts[1][0]).toEqual({
role: 'system',
content: 'System prompt v1',
});
});
});
});
10 changes: 6 additions & 4 deletions packages/ai/src/agent/stream-text-iterator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,8 +148,13 @@ export async function* streamTextIterator({
if (prepareResult.model !== undefined) {
currentModel = prepareResult.model;
}
if (prepareResult.messages !== undefined) {
conversationPrompt = [...prepareResult.messages];
}
if (prepareResult.system !== undefined) {
// Update or prepend system message in the conversation prompt
// Update or prepend system message in the conversation prompt.
// Applied AFTER messages override so the system message isn't
// lost when messages replaces the prompt.
if (
conversationPrompt.length > 0 &&
conversationPrompt[0].role === 'system'
Expand All@@ -167,9 +172,6 @@ export async function* streamTextIterator({
});
}
}
Comment on lines 151 to 174
if (prepareResult.messages !== undefined) {
conversationPrompt = [...prepareResult.messages];
}
if (prepareResult.experimental_context !== undefined) {
currentContext = prepareResult.experimental_context;
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix prepareStep system message lost when messages is also returned by TooTallNate · Pull Request #1389 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-prepare-step-system-ordering.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/ai': patch
---

Fix `prepareStep` system message being discarded when `messages` is also returned

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add an e2e test for this to prevent regressions @TooTallNate? since we now have the DurableAgent e2e test infra as of this week :)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also when relevant/possible, we should update the DurableAgent chat app to use the feature - for example when adding/fixing provider tools and client side tools, I would've been good to add that feature to the app as an easy way to try it and see if it actually works and the DX for it (the app didn't exist at the time and the friction to do a tarball and test with flight booking app is a bit annoying)

maybe this time we should have something in the DurableAgent test app that uses prepareStep?

217 changes: 216 additions & 1 deletion packages/ai/src/agent/stream-text-iterator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,10 @@ import type {
LanguageModelV3Prompt,
LanguageModelV3ToolCall,
LanguageModelV3ToolResult,
LanguageModelV3ToolResultPart,
} from '@ai-sdk/provider';
import type { StepResult, ToolSet, UIMessageChunk } from 'ai';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

// Mock doStreamStep
vi.mock('./do-stream-step.js', () => ({
Expand DownExpand Up@@ -651,4 +652,218 @@ describe('streamTextIterator', () => {
});
});
});

describe('prepareStep system and messages ordering', () => {
it('should apply system message when prepareStep returns only system', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

const iterator = streamTextIterator({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'You are a helpful assistant.',
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'You are a helpful assistant.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
});
});

it('should preserve system message when prepareStep returns both system and messages', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

// prepareStep returns both system and messages — system should NOT be lost
const customMessages: LanguageModelV3Prompt = [
{
role: 'user',
content: [{ type: 'text', text: 'modified message' }],
},
];

const iterator = streamTextIterator({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'original' }] },
],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'Dynamic system prompt.',
messages: customMessages,
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
// System message should be prepended to the replaced messages
expect(capturedPrompt!).toHaveLength(2);
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'Dynamic system prompt.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'modified message' }],
});
});

it('should replace existing system message when messages already contains one', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

// Messages already include a system message — prepareStep's system should replace it
const customMessages: LanguageModelV3Prompt = [
{ role: 'system', content: 'Old system prompt.' },
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
];

const iterator = streamTextIterator({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'original' }] },
],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'New system prompt.',
messages: customMessages,
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
expect(capturedPrompt!).toHaveLength(2);
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'New system prompt.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
});
});

it('should update system message on subsequent steps', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

const capturedPrompts: LanguageModelV3Prompt[] = [];

const toolCall: LanguageModelV3ToolCall = {
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'testTool',
input: '{}',
};

vi.mocked(doStreamStep)
.mockImplementationOnce(async (prompt) => {
capturedPrompts.push([...prompt]);
return {
toolCalls: [toolCall],
finish: { finishReason: 'tool-calls' },
step: createMockStepResult({ finishReason: 'tool-calls' }),
};
})
.mockImplementationOnce(async (prompt) => {
capturedPrompts.push([...prompt]);
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

const iterator = streamTextIterator({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
tools: {
testTool: {
description: 'Test',
execute: async () => ({ ok: true }),
},
} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: ({ stepNumber: sn }) => ({
system: `System prompt v${sn}`,
}),
});

// First step
await iterator.next();

// Provide tool results
const toolResults: LanguageModelV3ToolResultPart[] = [
{
type: 'tool-result',
toolCallId: 'call-1',
toolName: 'testTool',
output: { type: 'text', value: '{"ok":true}' },
},
];

// Second step
await iterator.next(toolResults);

expect(capturedPrompts).toHaveLength(2);
// First step should have system v0
expect(capturedPrompts[0][0]).toEqual({
role: 'system',
content: 'System prompt v0',
});
// Second step should have system v1
expect(capturedPrompts[1][0]).toEqual({
role: 'system',
content: 'System prompt v1',
});
});
});
});
10 changes: 6 additions & 4 deletions packages/ai/src/agent/stream-text-iterator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,8 +148,13 @@ export async function* streamTextIterator({
if (prepareResult.model !== undefined) {
currentModel = prepareResult.model;
}
if (prepareResult.messages !== undefined) {
conversationPrompt = [...prepareResult.messages];
}
if (prepareResult.system !== undefined) {
// Update or prepend system message in the conversation prompt
// Update or prepend system message in the conversation prompt.
// Applied AFTER messages override so the system message isn't
// lost when messages replaces the prompt.
if (
conversationPrompt.length > 0 &&
conversationPrompt[0].role === 'system'
Expand All@@ -167,9 +172,6 @@ export async function* streamTextIterator({
});
}
}
Comment on lines 151 to 174
if (prepareResult.messages !== undefined) {
conversationPrompt = [...prepareResult.messages];
}
if (prepareResult.experimental_context !== undefined) {
currentContext = prepareResult.experimental_context;
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Fix prepareStep system message lost when messages is also returned by TooTallNate · Pull Request #1389 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-prepare-step-system-ordering.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/ai': patch
---

Fix `prepareStep` system message being discarded when `messages` is also returned

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add an e2e test for this to prevent regressions @TooTallNate? since we now have the DurableAgent e2e test infra as of this week :)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also when relevant/possible, we should update the DurableAgent chat app to use the feature - for example when adding/fixing provider tools and client side tools, I would've been good to add that feature to the app as an easy way to try it and see if it actually works and the DX for it (the app didn't exist at the time and the friction to do a tarball and test with flight booking app is a bit annoying)

maybe this time we should have something in the DurableAgent test app that uses prepareStep?

217 changes: 216 additions & 1 deletion packages/ai/src/agent/stream-text-iterator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,10 @@ import type {
LanguageModelV3Prompt,
LanguageModelV3ToolCall,
LanguageModelV3ToolResult,
LanguageModelV3ToolResultPart,
} from '@ai-sdk/provider';
import type { StepResult, ToolSet, UIMessageChunk } from 'ai';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

// Mock doStreamStep
vi.mock('./do-stream-step.js', () => ({
Expand DownExpand Up@@ -651,4 +652,218 @@ describe('streamTextIterator', () => {
});
});
});

describe('prepareStep system and messages ordering', () => {
it('should apply system message when prepareStep returns only system', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

const iterator = streamTextIterator({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'You are a helpful assistant.',
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'You are a helpful assistant.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
});
});

it('should preserve system message when prepareStep returns both system and messages', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

// prepareStep returns both system and messages — system should NOT be lost
const customMessages: LanguageModelV3Prompt = [
{
role: 'user',
content: [{ type: 'text', text: 'modified message' }],
},
];

const iterator = streamTextIterator({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'original' }] },
],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'Dynamic system prompt.',
messages: customMessages,
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
// System message should be prepended to the replaced messages
expect(capturedPrompt!).toHaveLength(2);
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'Dynamic system prompt.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'modified message' }],
});
});

it('should replace existing system message when messages already contains one', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

// Messages already include a system message — prepareStep's system should replace it
const customMessages: LanguageModelV3Prompt = [
{ role: 'system', content: 'Old system prompt.' },
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
];

const iterator = streamTextIterator({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'original' }] },
],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'New system prompt.',
messages: customMessages,
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
expect(capturedPrompt!).toHaveLength(2);
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'New system prompt.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
});
});

it('should update system message on subsequent steps', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

const capturedPrompts: LanguageModelV3Prompt[] = [];

const toolCall: LanguageModelV3ToolCall = {
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'testTool',
input: '{}',
};

vi.mocked(doStreamStep)
.mockImplementationOnce(async (prompt) => {
capturedPrompts.push([...prompt]);
return {
toolCalls: [toolCall],
finish: { finishReason: 'tool-calls' },
step: createMockStepResult({ finishReason: 'tool-calls' }),
};
})
.mockImplementationOnce(async (prompt) => {
capturedPrompts.push([...prompt]);
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

const iterator = streamTextIterator({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
tools: {
testTool: {
description: 'Test',
execute: async () => ({ ok: true }),
},
} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: ({ stepNumber: sn }) => ({
system: `System prompt v${sn}`,
}),
});

// First step
await iterator.next();

// Provide tool results
const toolResults: LanguageModelV3ToolResultPart[] = [
{
type: 'tool-result',
toolCallId: 'call-1',
toolName: 'testTool',
output: { type: 'text', value: '{"ok":true}' },
},
];

// Second step
await iterator.next(toolResults);

expect(capturedPrompts).toHaveLength(2);
// First step should have system v0
expect(capturedPrompts[0][0]).toEqual({
role: 'system',
content: 'System prompt v0',
});
// Second step should have system v1
expect(capturedPrompts[1][0]).toEqual({
role: 'system',
content: 'System prompt v1',
});
});
});
});
10 changes: 6 additions & 4 deletions packages/ai/src/agent/stream-text-iterator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,8 +148,13 @@ export async function* streamTextIterator({
if (prepareResult.model !== undefined) {
currentModel = prepareResult.model;
}
if (prepareResult.messages !== undefined) {
conversationPrompt = [...prepareResult.messages];
}
if (prepareResult.system !== undefined) {
// Update or prepend system message in the conversation prompt
// Update or prepend system message in the conversation prompt.
// Applied AFTER messages override so the system message isn't
// lost when messages replaces the prompt.
if (
conversationPrompt.length > 0 &&
conversationPrompt[0].role === 'system'
Expand All@@ -167,9 +172,6 @@ export async function* streamTextIterator({
});
}
}
Comment on lines 151 to 174
if (prepareResult.messages !== undefined) {
conversationPrompt = [...prepareResult.messages];
}
if (prepareResult.experimental_context !== undefined) {
currentContext = prepareResult.experimental_context;
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix prepareStep system message lost when messages is also returned by TooTallNate · Pull Request #1389 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-prepare-step-system-ordering.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/ai': patch
---

Fix `prepareStep` system message being discarded when `messages` is also returned

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add an e2e test for this to prevent regressions @TooTallNate? since we now have the DurableAgent e2e test infra as of this week :)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also when relevant/possible, we should update the DurableAgent chat app to use the feature - for example when adding/fixing provider tools and client side tools, I would've been good to add that feature to the app as an easy way to try it and see if it actually works and the DX for it (the app didn't exist at the time and the friction to do a tarball and test with flight booking app is a bit annoying)

maybe this time we should have something in the DurableAgent test app that uses prepareStep?

217 changes: 216 additions & 1 deletion packages/ai/src/agent/stream-text-iterator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,10 @@ import type {
LanguageModelV3Prompt,
LanguageModelV3ToolCall,
LanguageModelV3ToolResult,
LanguageModelV3ToolResultPart,
} from '@ai-sdk/provider';
import type { StepResult, ToolSet, UIMessageChunk } from 'ai';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

// Mock doStreamStep
vi.mock('./do-stream-step.js', () => ({
Expand DownExpand Up@@ -651,4 +652,218 @@ describe('streamTextIterator', () => {
});
});
});

describe('prepareStep system and messages ordering', () => {
it('should apply system message when prepareStep returns only system', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

const iterator = streamTextIterator({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'You are a helpful assistant.',
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'You are a helpful assistant.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
});
});

it('should preserve system message when prepareStep returns both system and messages', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

// prepareStep returns both system and messages — system should NOT be lost
const customMessages: LanguageModelV3Prompt = [
{
role: 'user',
content: [{ type: 'text', text: 'modified message' }],
},
];

const iterator = streamTextIterator({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'original' }] },
],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'Dynamic system prompt.',
messages: customMessages,
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
// System message should be prepended to the replaced messages
expect(capturedPrompt!).toHaveLength(2);
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'Dynamic system prompt.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'modified message' }],
});
});

it('should replace existing system message when messages already contains one', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

// Messages already include a system message — prepareStep's system should replace it
const customMessages: LanguageModelV3Prompt = [
{ role: 'system', content: 'Old system prompt.' },
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
];

const iterator = streamTextIterator({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'original' }] },
],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'New system prompt.',
messages: customMessages,
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
expect(capturedPrompt!).toHaveLength(2);
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'New system prompt.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
});
});

it('should update system message on subsequent steps', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

const capturedPrompts: LanguageModelV3Prompt[] = [];

const toolCall: LanguageModelV3ToolCall = {
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'testTool',
input: '{}',
};

vi.mocked(doStreamStep)
.mockImplementationOnce(async (prompt) => {
capturedPrompts.push([...prompt]);
return {
toolCalls: [toolCall],
finish: { finishReason: 'tool-calls' },
step: createMockStepResult({ finishReason: 'tool-calls' }),
};
})
.mockImplementationOnce(async (prompt) => {
capturedPrompts.push([...prompt]);
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

const iterator = streamTextIterator({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
tools: {
testTool: {
description: 'Test',
execute: async () => ({ ok: true }),
},
} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: ({ stepNumber: sn }) => ({
system: `System prompt v${sn}`,
}),
});

// First step
await iterator.next();

// Provide tool results
const toolResults: LanguageModelV3ToolResultPart[] = [
{
type: 'tool-result',
toolCallId: 'call-1',
toolName: 'testTool',
output: { type: 'text', value: '{"ok":true}' },
},
];

// Second step
await iterator.next(toolResults);

expect(capturedPrompts).toHaveLength(2);
// First step should have system v0
expect(capturedPrompts[0][0]).toEqual({
role: 'system',
content: 'System prompt v0',
});
// Second step should have system v1
expect(capturedPrompts[1][0]).toEqual({
role: 'system',
content: 'System prompt v1',
});
});
});
});
10 changes: 6 additions & 4 deletions packages/ai/src/agent/stream-text-iterator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,8 +148,13 @@ export async function* streamTextIterator({
if (prepareResult.model !== undefined) {
currentModel = prepareResult.model;
}
if (prepareResult.messages !== undefined) {
conversationPrompt = [...prepareResult.messages];
}
if (prepareResult.system !== undefined) {
// Update or prepend system message in the conversation prompt
// Update or prepend system message in the conversation prompt.
// Applied AFTER messages override so the system message isn't
// lost when messages replaces the prompt.
if (
conversationPrompt.length > 0 &&
conversationPrompt[0].role === 'system'
Expand All@@ -167,9 +172,6 @@ export async function* streamTextIterator({
});
}
}
Comment on lines 151 to 174
if (prepareResult.messages !== undefined) {
conversationPrompt = [...prepareResult.messages];
}
if (prepareResult.experimental_context !== undefined) {
currentContext = prepareResult.experimental_context;
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix prepareStep system message lost when messages is also returned by TooTallNate · Pull Request #1389 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-prepare-step-system-ordering.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/ai': patch
---

Fix `prepareStep` system message being discarded when `messages` is also returned

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add an e2e test for this to prevent regressions @TooTallNate? since we now have the DurableAgent e2e test infra as of this week :)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also when relevant/possible, we should update the DurableAgent chat app to use the feature - for example when adding/fixing provider tools and client side tools, I would've been good to add that feature to the app as an easy way to try it and see if it actually works and the DX for it (the app didn't exist at the time and the friction to do a tarball and test with flight booking app is a bit annoying)

maybe this time we should have something in the DurableAgent test app that uses prepareStep?

217 changes: 216 additions & 1 deletion packages/ai/src/agent/stream-text-iterator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,10 @@ import type {
LanguageModelV3Prompt,
LanguageModelV3ToolCall,
LanguageModelV3ToolResult,
LanguageModelV3ToolResultPart,
} from '@ai-sdk/provider';
import type { StepResult, ToolSet, UIMessageChunk } from 'ai';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

// Mock doStreamStep
vi.mock('./do-stream-step.js', () => ({
Expand DownExpand Up@@ -651,4 +652,218 @@ describe('streamTextIterator', () => {
});
});
});

describe('prepareStep system and messages ordering', () => {
it('should apply system message when prepareStep returns only system', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

const iterator = streamTextIterator({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'You are a helpful assistant.',
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'You are a helpful assistant.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
});
});

it('should preserve system message when prepareStep returns both system and messages', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

// prepareStep returns both system and messages — system should NOT be lost
const customMessages: LanguageModelV3Prompt = [
{
role: 'user',
content: [{ type: 'text', text: 'modified message' }],
},
];

const iterator = streamTextIterator({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'original' }] },
],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'Dynamic system prompt.',
messages: customMessages,
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
// System message should be prepended to the replaced messages
expect(capturedPrompt!).toHaveLength(2);
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'Dynamic system prompt.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'modified message' }],
});
});

it('should replace existing system message when messages already contains one', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

// Messages already include a system message — prepareStep's system should replace it
const customMessages: LanguageModelV3Prompt = [
{ role: 'system', content: 'Old system prompt.' },
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
];

const iterator = streamTextIterator({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'original' }] },
],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'New system prompt.',
messages: customMessages,
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
expect(capturedPrompt!).toHaveLength(2);
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'New system prompt.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
});
});

it('should update system message on subsequent steps', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

const capturedPrompts: LanguageModelV3Prompt[] = [];

const toolCall: LanguageModelV3ToolCall = {
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'testTool',
input: '{}',
};

vi.mocked(doStreamStep)
.mockImplementationOnce(async (prompt) => {
capturedPrompts.push([...prompt]);
return {
toolCalls: [toolCall],
finish: { finishReason: 'tool-calls' },
step: createMockStepResult({ finishReason: 'tool-calls' }),
};
})
.mockImplementationOnce(async (prompt) => {
capturedPrompts.push([...prompt]);
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

const iterator = streamTextIterator({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
tools: {
testTool: {
description: 'Test',
execute: async () => ({ ok: true }),
},
} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: ({ stepNumber: sn }) => ({
system: `System prompt v${sn}`,
}),
});

// First step
await iterator.next();

// Provide tool results
const toolResults: LanguageModelV3ToolResultPart[] = [
{
type: 'tool-result',
toolCallId: 'call-1',
toolName: 'testTool',
output: { type: 'text', value: '{"ok":true}' },
},
];

// Second step
await iterator.next(toolResults);

expect(capturedPrompts).toHaveLength(2);
// First step should have system v0
expect(capturedPrompts[0][0]).toEqual({
role: 'system',
content: 'System prompt v0',
});
// Second step should have system v1
expect(capturedPrompts[1][0]).toEqual({
role: 'system',
content: 'System prompt v1',
});
});
});
});
10 changes: 6 additions & 4 deletions packages/ai/src/agent/stream-text-iterator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,8 +148,13 @@ export async function* streamTextIterator({
if (prepareResult.model !== undefined) {
currentModel = prepareResult.model;
}
if (prepareResult.messages !== undefined) {
conversationPrompt = [...prepareResult.messages];
}
if (prepareResult.system !== undefined) {
// Update or prepend system message in the conversation prompt
// Update or prepend system message in the conversation prompt.
// Applied AFTER messages override so the system message isn't
// lost when messages replaces the prompt.
if (
conversationPrompt.length > 0 &&
conversationPrompt[0].role === 'system'
Expand All@@ -167,9 +172,6 @@ export async function* streamTextIterator({
});
}
}
Comment on lines 151 to 174
if (prepareResult.messages !== undefined) {
conversationPrompt = [...prepareResult.messages];
}
if (prepareResult.experimental_context !== undefined) {
currentContext = prepareResult.experimental_context;
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Fix prepareStep system message lost when messages is also returned by TooTallNate · Pull Request #1389 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-prepare-step-system-ordering.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/ai': patch
---

Fix `prepareStep` system message being discarded when `messages` is also returned

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add an e2e test for this to prevent regressions @TooTallNate? since we now have the DurableAgent e2e test infra as of this week :)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also when relevant/possible, we should update the DurableAgent chat app to use the feature - for example when adding/fixing provider tools and client side tools, I would've been good to add that feature to the app as an easy way to try it and see if it actually works and the DX for it (the app didn't exist at the time and the friction to do a tarball and test with flight booking app is a bit annoying)

maybe this time we should have something in the DurableAgent test app that uses prepareStep?

217 changes: 216 additions & 1 deletion packages/ai/src/agent/stream-text-iterator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,10 @@ import type {
LanguageModelV3Prompt,
LanguageModelV3ToolCall,
LanguageModelV3ToolResult,
LanguageModelV3ToolResultPart,
} from '@ai-sdk/provider';
import type { StepResult, ToolSet, UIMessageChunk } from 'ai';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

// Mock doStreamStep
vi.mock('./do-stream-step.js', () => ({
Expand DownExpand Up@@ -651,4 +652,218 @@ describe('streamTextIterator', () => {
});
});
});

describe('prepareStep system and messages ordering', () => {
it('should apply system message when prepareStep returns only system', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

const iterator = streamTextIterator({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'You are a helpful assistant.',
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'You are a helpful assistant.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
});
});

it('should preserve system message when prepareStep returns both system and messages', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

// prepareStep returns both system and messages — system should NOT be lost
const customMessages: LanguageModelV3Prompt = [
{
role: 'user',
content: [{ type: 'text', text: 'modified message' }],
},
];

const iterator = streamTextIterator({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'original' }] },
],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'Dynamic system prompt.',
messages: customMessages,
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
// System message should be prepended to the replaced messages
expect(capturedPrompt!).toHaveLength(2);
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'Dynamic system prompt.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'modified message' }],
});
});

it('should replace existing system message when messages already contains one', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

let capturedPrompt: LanguageModelV3Prompt | undefined;

vi.mocked(doStreamStep).mockImplementationOnce(async (prompt) => {
capturedPrompt = prompt;
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

// Messages already include a system message — prepareStep's system should replace it
const customMessages: LanguageModelV3Prompt = [
{ role: 'system', content: 'Old system prompt.' },
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
];

const iterator = streamTextIterator({
prompt: [
{ role: 'user', content: [{ type: 'text', text: 'original' }] },
],
tools: {} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: () => ({
system: 'New system prompt.',
messages: customMessages,
}),
});

await iterator.next();

expect(capturedPrompt).toBeDefined();
expect(capturedPrompt!).toHaveLength(2);
expect(capturedPrompt![0]).toEqual({
role: 'system',
content: 'New system prompt.',
});
expect(capturedPrompt![1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
});
});

it('should update system message on subsequent steps', async () => {
const mockWritable = createMockWritable();
const mockModel = vi.fn();

const capturedPrompts: LanguageModelV3Prompt[] = [];

const toolCall: LanguageModelV3ToolCall = {
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'testTool',
input: '{}',
};

vi.mocked(doStreamStep)
.mockImplementationOnce(async (prompt) => {
capturedPrompts.push([...prompt]);
return {
toolCalls: [toolCall],
finish: { finishReason: 'tool-calls' },
step: createMockStepResult({ finishReason: 'tool-calls' }),
};
})
.mockImplementationOnce(async (prompt) => {
capturedPrompts.push([...prompt]);
return {
toolCalls: [],
finish: { finishReason: 'stop' },
step: createMockStepResult({ finishReason: 'stop' }),
};
});

const iterator = streamTextIterator({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
tools: {
testTool: {
description: 'Test',
execute: async () => ({ ok: true }),
},
} as ToolSet,
writable: mockWritable,
model: mockModel as any,
prepareStep: ({ stepNumber: sn }) => ({
system: `System prompt v${sn}`,
}),
});

// First step
await iterator.next();

// Provide tool results
const toolResults: LanguageModelV3ToolResultPart[] = [
{
type: 'tool-result',
toolCallId: 'call-1',
toolName: 'testTool',
output: { type: 'text', value: '{"ok":true}' },
},
];

// Second step
await iterator.next(toolResults);

expect(capturedPrompts).toHaveLength(2);
// First step should have system v0
expect(capturedPrompts[0][0]).toEqual({
role: 'system',
content: 'System prompt v0',
});
// Second step should have system v1
expect(capturedPrompts[1][0]).toEqual({
role: 'system',
content: 'System prompt v1',
});
});
});
});
10 changes: 6 additions & 4 deletions packages/ai/src/agent/stream-text-iterator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,8 +148,13 @@ export async function* streamTextIterator({
if (prepareResult.model !== undefined) {
currentModel = prepareResult.model;
}
if (prepareResult.messages !== undefined) {
conversationPrompt = [...prepareResult.messages];
}
if (prepareResult.system !== undefined) {
// Update or prepend system message in the conversation prompt
// Update or prepend system message in the conversation prompt.
// Applied AFTER messages override so the system message isn't
// lost when messages replaces the prompt.
if (
conversationPrompt.length > 0 &&
conversationPrompt[0].role === 'system'
Expand All@@ -167,9 +172,6 @@ export async function* streamTextIterator({
});
}
}
Comment on lines 151 to 174
if (prepareResult.messages !== undefined) {
conversationPrompt = [...prepareResult.messages];
}
if (prepareResult.experimental_context !== undefined) {
currentContext = prepareResult.experimental_context;
}
Expand Down
Loading