Skip to content

Commit 25bfa52

Browse files
Fix: Handle object-style finishReason for AI SDK v5/v6 compatibility (#695)
--------- Signed-off-by: Peter Wielander <mittgfu@gmail.com> Co-authored-by: Peter Wielander <mittgfu@gmail.com>
1 parent 80955e7 commit 25bfa52

File tree

5 files changed

+189
-14
lines changed

5 files changed

+189
-14
lines changed

.changeset/clean-parrots-search.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/ai": patch
3+
---
4+
5+
Fix: Handle object-style finishReason for AI SDK v5/v6 compatibility
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { normalizeFinishReason } from './do-stream-step.js';
3+
4+
describe('normalizeFinishReason', () => {
5+
describe('string finish reasons', () => {
6+
it('should pass through "stop"', () => {
7+
expect(normalizeFinishReason('stop')).toBe('stop');
8+
});
9+
10+
it('should pass through "tool-calls"', () => {
11+
expect(normalizeFinishReason('tool-calls')).toBe('tool-calls');
12+
});
13+
14+
it('should pass through "length"', () => {
15+
expect(normalizeFinishReason('length')).toBe('length');
16+
});
17+
18+
it('should pass through "content-filter"', () => {
19+
expect(normalizeFinishReason('content-filter')).toBe('content-filter');
20+
});
21+
22+
it('should pass through "error"', () => {
23+
expect(normalizeFinishReason('error')).toBe('error');
24+
});
25+
26+
it('should pass through "other"', () => {
27+
expect(normalizeFinishReason('other')).toBe('other');
28+
});
29+
30+
it('should pass through "unknown"', () => {
31+
expect(normalizeFinishReason('unknown')).toBe('unknown');
32+
});
33+
});
34+
35+
describe('object finish reasons', () => {
36+
it('should extract "stop" from object', () => {
37+
expect(normalizeFinishReason({ type: 'stop' })).toBe('stop');
38+
});
39+
40+
it('should extract "tool-calls" from object', () => {
41+
expect(normalizeFinishReason({ type: 'tool-calls' })).toBe('tool-calls');
42+
});
43+
44+
it('should extract "length" from object', () => {
45+
expect(normalizeFinishReason({ type: 'length' })).toBe('length');
46+
});
47+
48+
it('should extract "content-filter" from object', () => {
49+
expect(normalizeFinishReason({ type: 'content-filter' })).toBe(
50+
'content-filter'
51+
);
52+
});
53+
54+
it('should extract "error" from object', () => {
55+
expect(normalizeFinishReason({ type: 'error' })).toBe('error');
56+
});
57+
58+
it('should extract "other" from object', () => {
59+
expect(normalizeFinishReason({ type: 'other' })).toBe('other');
60+
});
61+
62+
it('should extract "unknown" from object', () => {
63+
expect(normalizeFinishReason({ type: 'unknown' })).toBe('unknown');
64+
});
65+
66+
it('should return "unknown" for object without type property', () => {
67+
expect(normalizeFinishReason({})).toBe('unknown');
68+
});
69+
70+
it('should return "unknown" for object with null type', () => {
71+
expect(normalizeFinishReason({ type: null })).toBe('unknown');
72+
});
73+
74+
it('should return "unknown" for object with undefined type', () => {
75+
expect(normalizeFinishReason({ type: undefined })).toBe('unknown');
76+
});
77+
78+
it('should handle object with additional properties', () => {
79+
expect(
80+
normalizeFinishReason({
81+
type: 'stop',
82+
reason: 'end_turn',
83+
metadata: { foo: 'bar' },
84+
})
85+
).toBe('stop');
86+
});
87+
});
88+
89+
describe('edge cases', () => {
90+
it('should return "unknown" for undefined', () => {
91+
expect(normalizeFinishReason(undefined)).toBe('unknown');
92+
});
93+
94+
it('should return "unknown" for null', () => {
95+
expect(normalizeFinishReason(null)).toBe('unknown');
96+
});
97+
98+
it('should return "unknown" for number', () => {
99+
expect(normalizeFinishReason(42)).toBe('unknown');
100+
});
101+
102+
it('should return "unknown" for boolean', () => {
103+
expect(normalizeFinishReason(true)).toBe('unknown');
104+
});
105+
106+
it('should return "unknown" for array', () => {
107+
expect(normalizeFinishReason(['stop'])).toBe('unknown');
108+
});
109+
110+
it('should handle empty string', () => {
111+
expect(normalizeFinishReason('')).toBe('');
112+
});
113+
});
114+
115+
describe('bug reproduction', () => {
116+
it('should handle object format that caused [object Object] error', () => {
117+
const normalized = normalizeFinishReason({ type: 'stop' });
118+
expect(normalized).toBe('stop');
119+
expect(typeof normalized).toBe('string');
120+
});
121+
122+
it('should handle tool-calls object format', () => {
123+
const normalized = normalizeFinishReason({ type: 'tool-calls' });
124+
expect(normalized).toBe('tool-calls');
125+
expect(typeof normalized).toBe('string');
126+
});
127+
});
128+
});

packages/ai/src/agent/do-stream-step.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
} from '@ai-sdk/provider';
99
import {
1010
gateway,
11+
type FinishReason,
1112
type StepResult,
1213
type StopCondition,
1314
type ToolChoice,
@@ -448,6 +449,26 @@ export async function doStreamStep(
448449
return { toolCalls, finish, step };
449450
}
450451

452+
/**
453+
* Normalize the finish reason to the AI SDK FinishReason type.
454+
* AI SDK v6 may return an object with a 'type' property,
455+
* while AI SDK v5 returns a plain string. This function handles both.
456+
*
457+
* @internal Exported for testing
458+
*/
459+
export function normalizeFinishReason(rawFinishReason: unknown): FinishReason {
460+
// Handle object-style finish reason (possible in some AI SDK versions/providers)
461+
if (typeof rawFinishReason === 'object' && rawFinishReason !== null) {
462+
const objReason = rawFinishReason as { type?: string };
463+
return (objReason.type as FinishReason) ?? 'unknown';
464+
}
465+
// Handle string finish reason (standard format)
466+
if (typeof rawFinishReason === 'string') {
467+
return rawFinishReason as FinishReason;
468+
}
469+
return 'unknown';
470+
}
471+
451472
// This is a stand-in for logic in the AI-SDK streamText code which aggregates
452473
// chunks into a single step result.
453474
function chunksToStep(
@@ -563,7 +584,7 @@ function chunksToStep(
563584
toolResults: [],
564585
staticToolResults: [],
565586
dynamicToolResults: [],
566-
finishReason: finish?.finishReason || 'unknown',
587+
finishReason: normalizeFinishReason(finish?.finishReason),
567588
usage: finish?.usage || { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
568589
warnings: streamStart?.warnings,
569590
request: {

packages/ai/src/agent/durable-agent.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -980,11 +980,11 @@ async function executeTool(
980980
});
981981

982982
return {
983-
type: 'tool-result',
983+
type: 'tool-result' as const,
984984
toolCallId: toolCall.toolCallId,
985985
toolName: toolCall.toolName,
986986
output: {
987-
type: 'text',
987+
type: 'text' as const,
988988
value: JSON.stringify(toolResult) ?? '',
989989
},
990990
};

packages/ai/src/agent/stream-text-iterator.ts

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
LanguageModelV2ToolResultPart,
66
} from '@ai-sdk/provider';
77
import type {
8+
FinishReason,
89
StepResult,
910
StreamTextOnStepFinishCallback,
1011
ToolChoice,
@@ -259,8 +260,12 @@ export async function* streamTextIterator({
259260
lastStep = step;
260261
lastStepWasToolCalls = false;
261262

262-
if (finish?.finishReason === 'tool-calls') {
263+
// Normalize finishReason - AI SDK v6 returns { unified, raw }, v5 returns a string
264+
const finishReason = normalizeFinishReason(finish?.finishReason);
265+
266+
if (finishReason === 'tool-calls') {
263267
lastStepWasToolCalls = true;
268+
264269
// Add assistant message with tool calls to the conversation
265270
conversationPrompt.push({
266271
role: 'assistant',
@@ -296,7 +301,7 @@ export async function* streamTextIterator({
296301
done = true;
297302
}
298303
}
299-
} else if (finish?.finishReason === 'stop') {
304+
} else if (finishReason === 'stop') {
300305
// Add assistant message with text content to the conversation
301306
const textContent = step.content.filter(
302307
(item) => item.type === 'text'
@@ -310,26 +315,28 @@ export async function* streamTextIterator({
310315
}
311316

312317
done = true;
313-
} else if (finish?.finishReason === 'length') {
318+
} else if (finishReason === 'length') {
314319
// Model hit max tokens - stop but don't throw
315320
done = true;
316-
} else if (finish?.finishReason === 'content-filter') {
321+
} else if (finishReason === 'content-filter') {
317322
// Content filter triggered - stop but don't throw
318323
done = true;
319-
} else if (finish?.finishReason === 'error') {
324+
} else if (finishReason === 'error') {
320325
// Model error - stop but don't throw
321326
done = true;
322-
} else if (finish?.finishReason === 'other') {
327+
} else if (finishReason === 'other') {
323328
// Other reason - stop but don't throw
324329
done = true;
325-
} else if (finish?.finishReason === 'unknown') {
330+
} else if (finishReason === 'unknown') {
326331
// Unknown reason - stop but don't throw
327332
done = true;
328-
} else if (!finish?.finishReason) {
333+
} else if (!finishReason) {
329334
// No finish reason - this might happen on incomplete streams
330335
done = true;
331336
} else {
332-
throw new Error(`Unexpected finish reason: ${finish?.finishReason}`);
337+
throw new Error(
338+
`Unexpected finish reason: ${typeof finish?.finishReason === 'object' ? JSON.stringify(finish?.finishReason) : finish?.finishReason}`
339+
);
333340
}
334341

335342
if (onStepFinish) {
@@ -361,12 +368,11 @@ async function writeToolOutputToUI(
361368
toolResults: LanguageModelV2ToolResultPart[]
362369
) {
363370
'use step';
364-
365371
const writer = writable.getWriter();
366372
try {
367373
for (const result of toolResults) {
368374
await writer.write({
369-
type: 'tool-output-available',
375+
type: 'tool-output-available' as const,
370376
toolCallId: result.toolCallId,
371377
output: JSON.stringify(result) ?? '',
372378
});
@@ -388,3 +394,18 @@ function filterToolSet(tools: ToolSet, activeTools: string[]): ToolSet {
388394
}
389395
return filtered;
390396
}
397+
398+
/**
399+
* Normalize finishReason from different AI SDK versions.
400+
* - AI SDK v6: returns { unified: 'tool-calls', raw: 'tool_use' }
401+
* - AI SDK v5: returns 'tool-calls' string directly
402+
*/
403+
function normalizeFinishReason(raw: unknown): FinishReason | undefined {
404+
if (raw == null) return undefined;
405+
if (typeof raw === 'string') return raw as FinishReason;
406+
if (typeof raw === 'object') {
407+
const obj = raw as { unified?: FinishReason; type?: FinishReason };
408+
return obj.unified ?? obj.type ?? 'unknown';
409+
}
410+
return undefined;
411+
}

0 commit comments

Comments
 (0)