AI SDK v5 migration (#14549)
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { ErrorStepRenderer } from '@/ai/components/ErrorStepRenderer';
|
||||
import { ReasoningSummaryDisplay } from '@/ai/components/ReasoningSummaryDisplay';
|
||||
import { ToolStepRenderer } from '@/ai/components/ToolStepRenderer';
|
||||
import type { ParsedStep } from '@/ai/types/streamTypes';
|
||||
import type { ParsedStep } from '@/ai/types/ParsedStep';
|
||||
import { hasStructuredStreamData } from '@/ai/utils/hasStructuredStreamData';
|
||||
import { parseStream } from '@/ai/utils/parseStream';
|
||||
import { IconDotsVertical } from 'twenty-ui/display';
|
||||
|
||||
@@ -64,25 +65,15 @@ export const AIChatAssistantMessageRenderer = ({
|
||||
streamData: string;
|
||||
}) => {
|
||||
const agentStreamingMessage = useRecoilValue(agentStreamingMessageState);
|
||||
const isStreaming =
|
||||
Boolean(agentStreamingMessage) && streamData === agentStreamingMessage;
|
||||
const isStreaming = streamData === agentStreamingMessage;
|
||||
|
||||
if (!streamData) {
|
||||
return <LoadingDotsIcon />;
|
||||
}
|
||||
|
||||
const isPlainString =
|
||||
!streamData.includes('\n') ||
|
||||
!streamData.split('\n').some((line) => {
|
||||
try {
|
||||
JSON.parse(line);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const hasStructuredData = hasStructuredStreamData(streamData);
|
||||
|
||||
if (isPlainString) {
|
||||
if (!hasStructuredData) {
|
||||
return <LazyMarkdownRenderer text={streamData} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { extractErrorMessage } from '@/ai/utils/extractErrorMessage';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { IconAlertCircle } from 'twenty-ui/display';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
@@ -53,7 +54,7 @@ export const ErrorStepRenderer = ({
|
||||
<IconAlertCircle size={theme.icon.size.md} />
|
||||
</StyledIconContainer>
|
||||
<StyledContent>
|
||||
<StyledTitle>Error</StyledTitle>
|
||||
<StyledTitle>{t`Error`}</StyledTitle>
|
||||
<StyledMessage>{errorMessage}</StyledMessage>
|
||||
</StyledContent>
|
||||
</StyledContainer>
|
||||
|
||||
@@ -6,13 +6,13 @@ import { IconChevronDown, IconChevronUp } from 'twenty-ui/display';
|
||||
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
|
||||
|
||||
import { ShimmeringText } from '@/ai/components/ShimmeringText';
|
||||
import { getToolIcon } from '@/ai/utils/getToolIcon';
|
||||
import type {
|
||||
ToolCallEvent,
|
||||
ToolEvent,
|
||||
ToolResultEvent,
|
||||
} from '@/ai/types/streamTypes';
|
||||
import { extractErrorMessage } from '@/ai/utils/extractErrorMessage';
|
||||
import { getToolIcon } from '@/ai/utils/getToolIcon';
|
||||
} from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -74,32 +74,27 @@ export const ToolStepRenderer = ({ events }: { events: ToolEvent[] }) => {
|
||||
const theme = useTheme();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const toolCall = events[0] as ToolCallEvent | undefined;
|
||||
const toolResult = events.find(
|
||||
const toolCallEvent = events[0] as ToolCallEvent | undefined;
|
||||
const toolResultEvent = events.find(
|
||||
(event): event is ToolResultEvent => event.type === 'tool-result',
|
||||
);
|
||||
|
||||
if (!toolCall) {
|
||||
if (!toolCallEvent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolOutput = toolResult?.result as ToolResultEvent['result'];
|
||||
const isStandardizedFormat =
|
||||
toolOutput && typeof toolOutput === 'object' && 'success' in toolOutput;
|
||||
const toolOutput =
|
||||
toolResultEvent?.output?.error ?? toolResultEvent?.output?.result;
|
||||
|
||||
const hasResult = isStandardizedFormat
|
||||
? Boolean(toolOutput.result)
|
||||
: Boolean(toolResult?.result);
|
||||
const hasError = isStandardizedFormat ? Boolean(toolOutput.error) : false;
|
||||
const isExpandable = hasResult || hasError;
|
||||
const isExpandable = isDefined(toolOutput);
|
||||
|
||||
if (!toolResult) {
|
||||
if (!toolResultEvent) {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledLoadingContainer>
|
||||
<ShimmeringText>
|
||||
<StyledDisplayMessage>
|
||||
{toolCall.args.loadingMessage}
|
||||
{toolCallEvent.input.loadingMessage}
|
||||
</StyledDisplayMessage>
|
||||
</ShimmeringText>
|
||||
</StyledLoadingContainer>
|
||||
@@ -108,13 +103,13 @@ export const ToolStepRenderer = ({ events }: { events: ToolEvent[] }) => {
|
||||
}
|
||||
|
||||
const displayMessage =
|
||||
toolResult?.result &&
|
||||
typeof toolResult.result === 'object' &&
|
||||
'message' in toolResult.result
|
||||
? (toolResult.result as { message: string }).message
|
||||
toolResultEvent?.output &&
|
||||
typeof toolResultEvent.output === 'object' &&
|
||||
'message' in toolResultEvent.output
|
||||
? (toolResultEvent.output as { message: string }).message
|
||||
: undefined;
|
||||
|
||||
const ToolIcon = getToolIcon(toolCall.toolName);
|
||||
const ToolIcon = getToolIcon(toolCallEvent.toolName);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
@@ -137,20 +132,7 @@ export const ToolStepRenderer = ({ events }: { events: ToolEvent[] }) => {
|
||||
{isExpandable && (
|
||||
<AnimatedExpandableContainer isExpanded={isExpanded}>
|
||||
<StyledContentContainer>
|
||||
{isStandardizedFormat ? (
|
||||
<>
|
||||
{hasError && <div>{extractErrorMessage(toolOutput.error)}</div>}
|
||||
{hasResult && (
|
||||
<div>
|
||||
<StyledPre>
|
||||
{JSON.stringify(toolOutput.result, null, 2)}
|
||||
</StyledPre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : toolResult?.result ? (
|
||||
JSON.stringify(toolResult.result, null, 2)
|
||||
) : undefined}
|
||||
<StyledPre>{JSON.stringify(toolOutput, null, 2)}</StyledPre>
|
||||
</StyledContentContainer>
|
||||
</AnimatedExpandableContainer>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ToolEvent } from 'twenty-shared/ai';
|
||||
|
||||
export type ParsedStep =
|
||||
| { type: 'tool'; events: ToolEvent[] }
|
||||
| { type: 'reasoning'; content: string; isThinking: boolean }
|
||||
| { type: 'text'; content: string }
|
||||
| { type: 'error'; message: string; error?: unknown };
|
||||
@@ -1,36 +0,0 @@
|
||||
export type ToolCallEvent = {
|
||||
type: 'tool-call';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: {
|
||||
loadingMessage: string;
|
||||
input: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type ToolResultEvent = {
|
||||
type: 'tool-result';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
result: {
|
||||
success: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
message: string;
|
||||
};
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ToolEvent = ToolCallEvent | ToolResultEvent;
|
||||
|
||||
export type ErrorEvent = {
|
||||
type: 'error';
|
||||
message: string;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
export type ParsedStep =
|
||||
| { type: 'tool'; events: ToolEvent[] }
|
||||
| { type: 'reasoning'; content: string; isThinking: boolean }
|
||||
| { type: 'text'; content: string }
|
||||
| { type: 'error'; message: string; error?: unknown };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { type WorkflowRunState } from '@/workflow/types/Workflow';
|
||||
import { workflowRunStateSchema } from '@/workflow/validation-schemas/workflowSchema';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { workflowRunStateSchema } from 'twenty-shared/workflow';
|
||||
import { type JsonValue } from 'type-fest';
|
||||
|
||||
export const orderWorkflowRunState = (value: JsonValue) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
||||
import { type WorkflowRun } from '@/workflow/types/Workflow';
|
||||
import { workflowRunSchema } from '@/workflow/validation-schemas/workflowSchema';
|
||||
import { useMemo } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { workflowRunSchema } from 'twenty-shared/workflow';
|
||||
|
||||
export const useWorkflowRun = ({
|
||||
workflowRunId,
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type workflowTriggerSchema,
|
||||
type workflowUpdateRecordActionSchema,
|
||||
type workflowWebhookTriggerSchema,
|
||||
} from 'twenty-shared/workflow';
|
||||
} from '@/workflow/validation-schemas/workflowSchema';
|
||||
import { type z } from 'zod';
|
||||
|
||||
export type WorkflowCodeAction = z.infer<typeof workflowCodeActionSchema>;
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { StepStatus } from 'twenty-shared/workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const objectRecordSchema = z.record(z.any());
|
||||
|
||||
export const baseWorkflowActionSettingsSchema = z.object({
|
||||
input: z.object({}).passthrough(),
|
||||
outputSchema: z.object({}).passthrough(),
|
||||
errorHandlingOptions: z.object({
|
||||
retryOnFailure: z.object({
|
||||
value: z.boolean(),
|
||||
}),
|
||||
continueOnFailure: z.object({
|
||||
value: z.boolean(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const baseWorkflowActionSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
valid: z.boolean(),
|
||||
nextStepIds: z.array(z.string()).optional().nullable(),
|
||||
position: z.object({ x: z.number(), y: z.number() }).optional().nullable(),
|
||||
});
|
||||
|
||||
export const baseTriggerSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
type: z.string(),
|
||||
position: z.object({ x: z.number(), y: z.number() }).optional().nullable(),
|
||||
nextStepIds: z.array(z.string()).optional().nullable(),
|
||||
});
|
||||
|
||||
export const workflowCodeActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
input: z.object({
|
||||
serverlessFunctionId: z.string(),
|
||||
serverlessFunctionVersion: z.string(),
|
||||
serverlessFunctionInput: z.record(z.any()),
|
||||
}),
|
||||
});
|
||||
|
||||
export const workflowSendEmailActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
input: z.object({
|
||||
connectedAccountId: z.string(),
|
||||
email: z.string(),
|
||||
subject: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const workflowCreateRecordActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
input: z.object({
|
||||
objectName: z.string(),
|
||||
objectRecord: objectRecordSchema,
|
||||
}),
|
||||
});
|
||||
|
||||
export const workflowUpdateRecordActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
input: z.object({
|
||||
objectName: z.string(),
|
||||
objectRecord: objectRecordSchema,
|
||||
objectRecordId: z.string(),
|
||||
fieldsToUpdate: z.array(z.string()),
|
||||
}),
|
||||
});
|
||||
|
||||
export const workflowDeleteRecordActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
input: z.object({
|
||||
objectName: z.string(),
|
||||
objectRecordId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const workflowFindRecordsActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
input: z.object({
|
||||
objectName: z.string(),
|
||||
limit: z.number().optional(),
|
||||
filter: z
|
||||
.object({
|
||||
recordFilterGroups: z.array(z.object({})).optional(),
|
||||
recordFilters: z.array(z.object({})).optional(),
|
||||
gqlOperationFilter: z.object({}).optional().nullable(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const workflowFormActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
input: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
type: z.union([
|
||||
z.literal(FieldMetadataType.TEXT),
|
||||
z.literal(FieldMetadataType.NUMBER),
|
||||
z.literal(FieldMetadataType.DATE),
|
||||
z.literal(FieldMetadataType.SELECT),
|
||||
z.literal('RECORD'),
|
||||
]),
|
||||
placeholder: z.string().optional(),
|
||||
settings: z.record(z.any()).optional(),
|
||||
value: z.any().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const workflowHttpRequestActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
input: z.object({
|
||||
url: z.string(),
|
||||
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
|
||||
headers: z.record(z.string()).optional(),
|
||||
body: z
|
||||
.record(
|
||||
z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])),
|
||||
]),
|
||||
)
|
||||
.or(z.string())
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const workflowAiAgentActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
input: z.object({
|
||||
agentId: z.string().optional(),
|
||||
prompt: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const workflowFilterActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
input: z.object({
|
||||
stepFilterGroups: z.array(z.any()),
|
||||
stepFilters: z.array(z.any()),
|
||||
}),
|
||||
});
|
||||
|
||||
export const workflowIteratorActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
input: z.object({
|
||||
items: z
|
||||
.union([
|
||||
z.array(
|
||||
z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
z.record(z.any()),
|
||||
z.any(),
|
||||
]),
|
||||
),
|
||||
z.string(),
|
||||
])
|
||||
.optional(),
|
||||
initialLoopStepIds: z.array(z.string()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const workflowEmptyActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
input: z.object({}),
|
||||
});
|
||||
|
||||
export const workflowCodeActionSchema = baseWorkflowActionSchema.extend({
|
||||
type: z.literal('CODE'),
|
||||
settings: workflowCodeActionSettingsSchema,
|
||||
});
|
||||
|
||||
export const workflowSendEmailActionSchema = baseWorkflowActionSchema.extend({
|
||||
type: z.literal('SEND_EMAIL'),
|
||||
settings: workflowSendEmailActionSettingsSchema,
|
||||
});
|
||||
|
||||
export const workflowCreateRecordActionSchema = baseWorkflowActionSchema.extend(
|
||||
{
|
||||
type: z.literal('CREATE_RECORD'),
|
||||
settings: workflowCreateRecordActionSettingsSchema,
|
||||
},
|
||||
);
|
||||
|
||||
export const workflowUpdateRecordActionSchema = baseWorkflowActionSchema.extend(
|
||||
{
|
||||
type: z.literal('UPDATE_RECORD'),
|
||||
settings: workflowUpdateRecordActionSettingsSchema,
|
||||
},
|
||||
);
|
||||
|
||||
export const workflowDeleteRecordActionSchema = baseWorkflowActionSchema.extend(
|
||||
{
|
||||
type: z.literal('DELETE_RECORD'),
|
||||
settings: workflowDeleteRecordActionSettingsSchema,
|
||||
},
|
||||
);
|
||||
|
||||
export const workflowFindRecordsActionSchema = baseWorkflowActionSchema.extend({
|
||||
type: z.literal('FIND_RECORDS'),
|
||||
settings: workflowFindRecordsActionSettingsSchema,
|
||||
});
|
||||
|
||||
export const workflowFormActionSchema = baseWorkflowActionSchema.extend({
|
||||
type: z.literal('FORM'),
|
||||
settings: workflowFormActionSettingsSchema,
|
||||
});
|
||||
|
||||
export const workflowHttpRequestActionSchema = baseWorkflowActionSchema.extend({
|
||||
type: z.literal('HTTP_REQUEST'),
|
||||
settings: workflowHttpRequestActionSettingsSchema,
|
||||
});
|
||||
|
||||
export const workflowAiAgentActionSchema = baseWorkflowActionSchema.extend({
|
||||
type: z.literal('AI_AGENT'),
|
||||
settings: workflowAiAgentActionSettingsSchema,
|
||||
});
|
||||
|
||||
export const workflowFilterActionSchema = baseWorkflowActionSchema.extend({
|
||||
type: z.literal('FILTER'),
|
||||
settings: workflowFilterActionSettingsSchema,
|
||||
});
|
||||
|
||||
export const workflowIteratorActionSchema = baseWorkflowActionSchema.extend({
|
||||
type: z.literal('ITERATOR'),
|
||||
settings: workflowIteratorActionSettingsSchema,
|
||||
});
|
||||
|
||||
export const workflowEmptyActionSchema = baseWorkflowActionSchema.extend({
|
||||
type: z.literal('EMPTY'),
|
||||
settings: workflowEmptyActionSettingsSchema,
|
||||
});
|
||||
|
||||
export const workflowActionSchema = z.discriminatedUnion('type', [
|
||||
workflowCodeActionSchema,
|
||||
workflowSendEmailActionSchema,
|
||||
workflowCreateRecordActionSchema,
|
||||
workflowUpdateRecordActionSchema,
|
||||
workflowDeleteRecordActionSchema,
|
||||
workflowFindRecordsActionSchema,
|
||||
workflowFormActionSchema,
|
||||
workflowHttpRequestActionSchema,
|
||||
workflowAiAgentActionSchema,
|
||||
workflowFilterActionSchema,
|
||||
workflowIteratorActionSchema,
|
||||
workflowEmptyActionSchema,
|
||||
]);
|
||||
|
||||
export const workflowDatabaseEventTriggerSchema = baseTriggerSchema.extend({
|
||||
type: z.literal('DATABASE_EVENT'),
|
||||
settings: z.object({
|
||||
eventName: z.string(),
|
||||
input: z.object({}).passthrough().optional(),
|
||||
outputSchema: z.object({}).passthrough(),
|
||||
objectType: z.string().optional(),
|
||||
fields: z.array(z.string()).optional().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const workflowManualTriggerSchema = baseTriggerSchema
|
||||
.extend({
|
||||
type: z.literal('MANUAL'),
|
||||
settings: z.object({
|
||||
objectType: z.string().optional(),
|
||||
outputSchema: z
|
||||
.object({})
|
||||
.passthrough()
|
||||
.describe(
|
||||
'Schema defining the output data structure. When a record is selected, it is accessible via {{trigger.record.fieldName}}. When no record is selected, no data is available.',
|
||||
),
|
||||
icon: z.string().optional(),
|
||||
isPinned: z.boolean().optional(),
|
||||
}),
|
||||
})
|
||||
.describe(
|
||||
'Manual trigger that can be launched by the user. If a record is selected when launched, it is accessible via {{trigger.record.fieldName}}. If no record is selected, no data context is available.',
|
||||
);
|
||||
|
||||
export const workflowCronTriggerSchema = baseTriggerSchema.extend({
|
||||
type: z.literal('CRON'),
|
||||
settings: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('DAYS'),
|
||||
schedule: z.object({
|
||||
day: z.number().min(1),
|
||||
hour: z.number().min(0).max(23),
|
||||
minute: z.number().min(0).max(59),
|
||||
}),
|
||||
outputSchema: z.object({}).passthrough(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('HOURS'),
|
||||
schedule: z.object({
|
||||
hour: z.number().min(1),
|
||||
minute: z.number().min(0).max(59),
|
||||
}),
|
||||
outputSchema: z.object({}).passthrough(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('MINUTES'),
|
||||
schedule: z.object({ minute: z.number().min(1) }),
|
||||
outputSchema: z.object({}).passthrough(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('CUSTOM'),
|
||||
pattern: z.string(),
|
||||
outputSchema: z.object({}).passthrough(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
export const workflowWebhookTriggerSchema = baseTriggerSchema.extend({
|
||||
type: z.literal('WEBHOOK'),
|
||||
settings: z.discriminatedUnion('httpMethod', [
|
||||
z.object({
|
||||
outputSchema: z.object({}).passthrough(),
|
||||
httpMethod: z.literal('GET'),
|
||||
authentication: z.literal('API_KEY').nullable(),
|
||||
}),
|
||||
z.object({
|
||||
outputSchema: z.object({}).passthrough(),
|
||||
httpMethod: z.literal('POST'),
|
||||
expectedBody: z.object({}).passthrough(),
|
||||
authentication: z.literal('API_KEY').nullable(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
export const workflowTriggerSchema = z.discriminatedUnion('type', [
|
||||
workflowDatabaseEventTriggerSchema,
|
||||
workflowManualTriggerSchema,
|
||||
workflowCronTriggerSchema,
|
||||
workflowWebhookTriggerSchema,
|
||||
]);
|
||||
|
||||
export const workflowRunStepStatusSchema = z.nativeEnum(StepStatus);
|
||||
|
||||
export const workflowRunStateStepInfoSchema = z.object({
|
||||
result: z.any().optional(),
|
||||
error: z.any().optional(),
|
||||
status: workflowRunStepStatusSchema,
|
||||
});
|
||||
|
||||
export const workflowRunStateStepInfosSchema = z.record(
|
||||
workflowRunStateStepInfoSchema,
|
||||
);
|
||||
|
||||
export const workflowRunStateSchema = z.object({
|
||||
flow: z.object({
|
||||
trigger: workflowTriggerSchema,
|
||||
steps: z.array(workflowActionSchema),
|
||||
}),
|
||||
stepInfos: workflowRunStateStepInfosSchema,
|
||||
workflowRunError: z.any().optional(),
|
||||
});
|
||||
|
||||
export const workflowRunStatusSchema = z.enum([
|
||||
'NOT_STARTED',
|
||||
'RUNNING',
|
||||
'COMPLETED',
|
||||
'FAILED',
|
||||
'ENQUEUED',
|
||||
]);
|
||||
|
||||
export const workflowRunSchema = z
|
||||
.object({
|
||||
__typename: z.literal('WorkflowRun'),
|
||||
id: z.string(),
|
||||
workflowVersionId: z.string(),
|
||||
workflowId: z.string(),
|
||||
state: workflowRunStateSchema.nullable(),
|
||||
status: workflowRunStatusSchema,
|
||||
createdAt: z.string(),
|
||||
deletedAt: z.string().nullable(),
|
||||
endedAt: z.string().nullable(),
|
||||
name: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
+3
-3
@@ -3,13 +3,13 @@ import {
|
||||
type WorkflowHttpRequestAction,
|
||||
type WorkflowSendEmailAction,
|
||||
} from '@/workflow/types/Workflow';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import {
|
||||
workflowFormActionSettingsSchema,
|
||||
workflowHttpRequestActionSettingsSchema,
|
||||
workflowSendEmailActionSettingsSchema,
|
||||
} from 'twenty-shared/workflow';
|
||||
} from '@/workflow/validation-schemas/workflowSchema';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { useWorkflowActionHeader } from '../useWorkflowActionHeader';
|
||||
|
||||
jest.mock('../useActionIconColorOrThrow', () => ({
|
||||
|
||||
Reference in New Issue
Block a user