AI SDK v5 migration (#14549)

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Abdul Rahman
2025-09-23 01:43:43 +05:30
committed by GitHub
parent e8121919bd
commit 216d72b5d7
75 changed files with 1347 additions and 841 deletions
@@ -46,7 +46,7 @@ describe('parseStream', () => {
type: 'tool-result',
toolCallId: 'call-123',
toolName: 'send_email',
result: { sucess: true, result: 'Email sent', message: 'Success' },
result: { success: true, result: 'Email sent', message: 'Success' },
message: 'Email sent successfully',
}),
].join('\n');
@@ -67,7 +67,7 @@ describe('parseStream', () => {
type: 'tool-result',
toolCallId: 'call-123',
toolName: 'send_email',
result: { sucess: true, result: 'Email sent', message: 'Success' },
result: { success: true, result: 'Email sent', message: 'Success' },
message: 'Email sent successfully',
},
],
@@ -80,7 +80,7 @@ describe('parseStream', () => {
toolCallId: 'call-456',
toolName: 'http_request',
result: {
sucess: true,
success: true,
result: 'Response received',
message: 'Success',
},
@@ -98,7 +98,7 @@ describe('parseStream', () => {
toolCallId: 'call-456',
toolName: 'http_request',
result: {
sucess: true,
success: true,
result: 'Response received',
message: 'Success',
},
@@ -112,15 +112,16 @@ describe('parseStream', () => {
describe('reasoning events', () => {
it('should parse reasoning events correctly', () => {
const streamText = [
JSON.stringify({ type: 'reasoning-start' }),
JSON.stringify({
type: 'reasoning',
textDelta: 'Let me think about this...',
type: 'reasoning-delta',
text: 'Let me think about this...',
}),
JSON.stringify({
type: 'reasoning',
textDelta: ' I need to consider the options.',
type: 'reasoning-delta',
text: ' I need to consider the options.',
}),
JSON.stringify({ type: 'reasoning-signature' }),
JSON.stringify({ type: 'reasoning-end' }),
].join('\n');
const result = parseStream(streamText);
@@ -133,11 +134,14 @@ describe('parseStream', () => {
});
});
it('should handle reasoning without signature as thinking', () => {
const streamText = JSON.stringify({
type: 'reasoning',
textDelta: 'Still thinking...',
});
it('should handle reasoning without end as thinking', () => {
const streamText = [
JSON.stringify({ type: 'reasoning-start' }),
JSON.stringify({
type: 'reasoning-delta',
text: 'Still thinking...',
}),
].join('\n');
const result = parseStream(streamText);
@@ -151,9 +155,10 @@ describe('parseStream', () => {
it('should concatenate multiple reasoning deltas', () => {
const streamText = [
JSON.stringify({ type: 'reasoning', textDelta: 'First part' }),
JSON.stringify({ type: 'reasoning', textDelta: ' second part' }),
JSON.stringify({ type: 'reasoning', textDelta: ' third part' }),
JSON.stringify({ type: 'reasoning-start' }),
JSON.stringify({ type: 'reasoning-delta', text: 'First part' }),
JSON.stringify({ type: 'reasoning-delta', text: ' second part' }),
JSON.stringify({ type: 'reasoning-delta', text: ' third part' }),
].join('\n');
const result = parseStream(streamText);
@@ -170,8 +175,8 @@ describe('parseStream', () => {
describe('text-delta events', () => {
it('should parse text-delta events correctly', () => {
const streamText = [
JSON.stringify({ type: 'text-delta', textDelta: 'Hello, ' }),
JSON.stringify({ type: 'text-delta', textDelta: 'world!' }),
JSON.stringify({ type: 'text-delta', text: 'Hello, ' }),
JSON.stringify({ type: 'text-delta', text: 'world!' }),
].join('\n');
const result = parseStream(streamText);
@@ -183,8 +188,8 @@ describe('parseStream', () => {
});
});
it('should handle empty textDelta', () => {
const streamText = JSON.stringify({ type: 'text-delta', textDelta: '' });
it('should handle empty text', () => {
const streamText = JSON.stringify({ type: 'text-delta', text: '' });
const result = parseStream(streamText);
@@ -249,7 +254,7 @@ describe('parseStream', () => {
describe('step-finish events', () => {
it('should flush current text block on step-finish', () => {
const streamText = [
JSON.stringify({ type: 'text-delta', textDelta: 'Some text' }),
JSON.stringify({ type: 'text-delta', text: 'Some text' }),
JSON.stringify({ type: 'step-finish' }),
].join('\n');
@@ -264,7 +269,8 @@ describe('parseStream', () => {
it('should mark reasoning as not thinking on step-finish', () => {
const streamText = [
JSON.stringify({ type: 'reasoning', textDelta: 'Thinking...' }),
JSON.stringify({ type: 'reasoning-start' }),
JSON.stringify({ type: 'reasoning-delta', text: 'Thinking...' }),
JSON.stringify({ type: 'step-finish' }),
].join('\n');
@@ -282,22 +288,23 @@ describe('parseStream', () => {
describe('mixed events', () => {
it('should handle mixed event types correctly', () => {
const streamText = [
JSON.stringify({ type: 'text-delta', textDelta: 'Starting...' }),
JSON.stringify({ type: 'text-delta', text: 'Starting...' }),
JSON.stringify({
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'send_email',
args: { loadingMessage: 'Sending...', input: {} },
}),
JSON.stringify({ type: 'reasoning', textDelta: 'Let me think...' }),
JSON.stringify({ type: 'reasoning-start' }),
JSON.stringify({ type: 'reasoning-delta', text: 'Let me think...' }),
JSON.stringify({
type: 'tool-result',
toolCallId: 'call-1',
toolName: 'send_email',
result: { sucess: true, message: 'Done' },
result: { success: true, message: 'Done' },
message: 'Email sent',
}),
JSON.stringify({ type: 'text-delta', textDelta: 'Finished!' }),
JSON.stringify({ type: 'text-delta', text: 'Finished!' }),
].join('\n');
const result = parseStream(streamText);
@@ -317,7 +324,7 @@ describe('parseStream', () => {
type: 'tool-result',
toolCallId: 'call-1',
toolName: 'send_email',
result: { sucess: true, message: 'Done' },
result: { success: true, message: 'Done' },
message: 'Email sent',
},
],
@@ -348,7 +355,7 @@ describe('parseStream', () => {
it('should skip invalid JSON lines', () => {
const streamText = [
'invalid json line',
JSON.stringify({ type: 'text-delta', textDelta: 'Valid content' }),
JSON.stringify({ type: 'text-delta', text: 'Valid content' }),
'another invalid line',
].join('\n');
@@ -374,7 +381,7 @@ describe('parseStream', () => {
it('should flush remaining text block at end', () => {
const streamText = JSON.stringify({
type: 'text-delta',
textDelta: 'Unflushed content',
text: 'Unflushed content',
});
const result = parseStream(streamText);
@@ -390,8 +397,9 @@ describe('parseStream', () => {
describe('text block transitions', () => {
it('should create new text block when switching from reasoning to text', () => {
const streamText = [
JSON.stringify({ type: 'reasoning', textDelta: 'Thinking...' }),
JSON.stringify({ type: 'text-delta', textDelta: 'Speaking...' }),
JSON.stringify({ type: 'reasoning-start' }),
JSON.stringify({ type: 'reasoning-delta', text: 'Thinking...' }),
JSON.stringify({ type: 'text-delta', text: 'Speaking...' }),
].join('\n');
const result = parseStream(streamText);
@@ -410,8 +418,9 @@ describe('parseStream', () => {
it('should create new reasoning block when switching from text to reasoning', () => {
const streamText = [
JSON.stringify({ type: 'text-delta', textDelta: 'Speaking...' }),
JSON.stringify({ type: 'reasoning', textDelta: 'Thinking...' }),
JSON.stringify({ type: 'text-delta', text: 'Speaking...' }),
JSON.stringify({ type: 'reasoning-start' }),
JSON.stringify({ type: 'reasoning-delta', text: 'Thinking...' }),
].join('\n');
const result = parseStream(streamText);
@@ -1,29 +1,39 @@
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
export const extractErrorMessage = (error: unknown): string => {
if (typeof error === 'string') {
return error;
}
const isObjectWithMessage = (error: unknown): error is { message: string } => {
return (
isDefined(error) &&
typeof error === 'object' &&
'message' in error &&
typeof error.message === 'string'
);
};
if (!isDefined(error) || typeof error !== 'object') {
return 'An unexpected error occurred';
}
if ('message' in error && typeof error.message === 'string') {
return error.message;
}
if (
const isErrorWithNestedError = (
error: unknown,
): error is {
error: { message: string };
} => {
return (
isDefined(error) &&
typeof error === 'object' &&
'error' in error &&
isDefined(error.error) &&
typeof error.error === 'object' &&
'message' in error.error &&
typeof error.error.message === 'string'
) {
return error.error.message;
}
);
};
if (
const isDeepNestedError = (
error: unknown,
): error is {
data: { error: { message: string } };
} => {
return (
isDefined(error) &&
typeof error === 'object' &&
'data' in error &&
isDefined(error.data) &&
typeof error.data === 'object' &&
@@ -32,9 +42,25 @@ export const extractErrorMessage = (error: unknown): string => {
typeof error.data.error === 'object' &&
'message' in error.data.error &&
typeof error.data.error.message === 'string'
) {
);
};
export const extractErrorMessage = (error: unknown): string => {
if (typeof error === 'string') {
return error;
}
if (isObjectWithMessage(error)) {
return error.message;
}
if (isErrorWithNestedError(error)) {
return error.error.message;
}
if (isDeepNestedError(error)) {
return error.data.error.message;
}
return 'An unexpected error occurred';
return t`An unexpected error occurred`;
};
@@ -0,0 +1,14 @@
export const hasStructuredStreamData = (data: string): boolean => {
if (!data.includes('\n')) {
return false;
}
return data.split('\n').some((line) => {
try {
JSON.parse(line);
return true;
} catch {
return false;
}
});
};
@@ -1,19 +1,130 @@
import { isDefined } from 'twenty-shared/utils';
import {
type ParsedStep,
parseStreamLine,
splitStreamIntoLines,
type ErrorEvent,
type ReasoningDeltaEvent,
type TextBlock,
type TextDeltaEvent,
type ToolCallEvent,
type ToolEvent,
type ToolResultEvent,
} from '@/ai/types/streamTypes';
} from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
type TextBlock =
| { type: 'reasoning'; content: string; isThinking: boolean }
| { type: 'text'; content: string }
| null;
import type { ParsedStep } from '@/ai/types/ParsedStep';
const handleToolCall = (
event: ToolCallEvent,
output: ParsedStep[],
flushTextBlock: () => void,
) => {
flushTextBlock();
output.push({
type: 'tool',
events: [event],
});
};
const handleToolResult = (
event: ToolResultEvent,
output: ParsedStep[],
flushTextBlock: () => void,
) => {
flushTextBlock();
const toolEntry = output.find(
(item): item is { type: 'tool'; events: ToolEvent[] } =>
item.type === 'tool' &&
item.events.some(
(e) => e.type === 'tool-call' && e.toolCallId === event.toolCallId,
),
);
if (isDefined(toolEntry)) {
toolEntry.events.push(event);
} else {
output.push({
type: 'tool',
events: [event],
});
}
};
const handleReasoningStart = (flushTextBlock: () => void): TextBlock => {
flushTextBlock();
return {
type: 'reasoning',
content: '',
isThinking: true,
};
};
const handleReasoningDelta = (
event: ReasoningDeltaEvent,
currentTextBlock: TextBlock,
flushTextBlock: () => void,
): TextBlock => {
if (!currentTextBlock || currentTextBlock.type !== 'reasoning') {
flushTextBlock();
return {
type: 'reasoning',
content: event.text || '',
isThinking: true,
};
}
currentTextBlock.content += event.text || '';
return currentTextBlock;
};
const handleReasoningEnd = (currentTextBlock: TextBlock): TextBlock => {
if (currentTextBlock?.type === 'reasoning') {
return {
...currentTextBlock,
isThinking: false,
};
}
return currentTextBlock;
};
const handleTextDelta = (
event: TextDeltaEvent,
currentTextBlock: TextBlock,
flushTextBlock: () => void,
): TextBlock => {
if (!currentTextBlock || currentTextBlock.type !== 'text') {
flushTextBlock();
return { type: 'text', content: event.text || '' };
}
currentTextBlock.content += event.text || '';
return currentTextBlock;
};
const handleStepFinish = (
currentTextBlock: TextBlock,
flushTextBlock: () => void,
): TextBlock => {
if (currentTextBlock?.type === 'reasoning') {
currentTextBlock.isThinking = false;
}
flushTextBlock();
return null;
};
const handleError = (
event: ErrorEvent,
output: ParsedStep[],
flushTextBlock: () => void,
) => {
flushTextBlock();
output.push({
type: 'error',
message: event.message || 'An error occurred',
error: event.error,
});
};
export const parseStream = (streamText: string): ParsedStep[] => {
const lines = streamText.trim().split('\n');
const lines = splitStreamIntoLines(streamText);
const output: ParsedStep[] = [];
let currentTextBlock: TextBlock = null;
@@ -25,100 +136,50 @@ export const parseStream = (streamText: string): ParsedStep[] => {
};
for (const line of lines) {
let event;
try {
event = JSON.parse(line);
} catch {
const event = parseStreamLine(line);
if (!event) {
continue;
}
switch (event.type) {
case 'tool-call':
flushTextBlock();
output.push({
type: 'tool',
events: [
{
type: 'tool-call',
toolCallId: event.toolCallId,
toolName: event.toolName,
args: event.args,
},
] as ToolEvent[],
});
handleToolCall(event, output, flushTextBlock);
break;
case 'tool-result': {
flushTextBlock();
case 'tool-result':
handleToolResult(event, output, flushTextBlock);
break;
const toolEntry = output.find(
(item): item is { type: 'tool'; events: ToolEvent[] } =>
item.type === 'tool' &&
item.events.some(
(e) =>
e.type === 'tool-call' && e.toolCallId === event.toolCallId,
),
case 'reasoning-start':
currentTextBlock = handleReasoningStart(flushTextBlock);
break;
case 'reasoning-delta':
currentTextBlock = handleReasoningDelta(
event,
currentTextBlock,
flushTextBlock,
);
const resultEvent: ToolResultEvent = {
type: 'tool-result',
toolCallId: event.toolCallId,
toolName: event.toolName,
result: event.result,
message: event.message,
};
if (isDefined(toolEntry)) {
toolEntry.events.push(resultEvent);
} else {
output.push({
type: 'tool',
events: [resultEvent],
});
}
break;
}
case 'reasoning':
if (!currentTextBlock || currentTextBlock.type !== 'reasoning') {
flushTextBlock();
currentTextBlock = {
type: 'reasoning',
content: '',
isThinking: true,
};
}
currentTextBlock.content += event.textDelta || '';
case 'reasoning-end':
currentTextBlock = handleReasoningEnd(currentTextBlock);
break;
case 'text-delta':
if (!currentTextBlock || currentTextBlock.type !== 'text') {
flushTextBlock();
currentTextBlock = { type: 'text', content: '' };
}
currentTextBlock.content += event.textDelta || '';
break;
case 'reasoning-signature':
if (currentTextBlock?.type === 'reasoning') {
currentTextBlock.isThinking = false;
}
currentTextBlock = handleTextDelta(
event,
currentTextBlock,
flushTextBlock,
);
break;
case 'step-finish':
if (currentTextBlock?.type === 'reasoning') {
currentTextBlock.isThinking = false;
}
flushTextBlock();
currentTextBlock = handleStepFinish(currentTextBlock, flushTextBlock);
break;
case 'error':
flushTextBlock();
output.push({
type: 'error',
message: event.message || 'An error occurred',
error: event.error,
});
handleError(event, output, flushTextBlock);
break;
}
}