diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index d6159bd2cd..e541a2b0f6 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -2656,6 +2656,7 @@ type Webhook { type ToolIndexEntry { name: String! + label: String! description: String! category: String! objectName: String diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index d3d56f9544..ddb1a2332e 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -2304,6 +2304,7 @@ export interface Webhook { export interface ToolIndexEntry { name: Scalars['String'] + label: Scalars['String'] description: Scalars['String'] category: Scalars['String'] objectName?: Scalars['String'] @@ -5424,6 +5425,7 @@ export interface WebhookGenqlSelection{ export interface ToolIndexEntryGenqlSelection{ name?: boolean | number + label?: boolean | number description?: boolean | number category?: boolean | number objectName?: boolean | number diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index f95724db18..20201d4b12 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -5203,6 +5203,9 @@ export default { "name": [ 1 ], + "label": [ + 1 + ], "description": [ 1 ], diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 71b16415c1..26b247a135 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -5170,6 +5170,7 @@ export type ToolIndexEntry = { description: Scalars['String']['output']; icon?: Maybe; inputSchema?: Maybe; + label: Scalars['String']['output']; name: Scalars['String']['output']; objectName?: Maybe; }; @@ -6557,7 +6558,7 @@ export type GetChatThreadsQuery = { __typename?: 'Query', chatThreads: Array<{ _ export type GetToolIndexQueryVariables = Exact<{ [key: string]: never; }>; -export type GetToolIndexQuery = { __typename?: 'Query', getToolIndex: Array<{ __typename?: 'ToolIndexEntry', name: string, description: string, category: string, objectName?: string | null, icon?: string | null }> }; +export type GetToolIndexQuery = { __typename?: 'Query', getToolIndex: Array<{ __typename?: 'ToolIndexEntry', name: string, label: string, description: string, category: string, objectName?: string | null, icon?: string | null }> }; export type GetToolInputSchemaQueryVariables = Exact<{ toolName: Scalars['String']['input']; @@ -8714,7 +8715,7 @@ export const FindWorkspaceAiStatsDocument = {"kind":"Document","definitions":[{" export const GetAgentTurnsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAgentTurns"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"agentTurns"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"agentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"agentId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"evaluations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"score"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"reasoningContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}},{"kind":"Field","name":{"kind":"Name","value":"toolCallId"}},{"kind":"Field","name":{"kind":"Name","value":"toolInput"}},{"kind":"Field","name":{"kind":"Name","value":"toolOutput"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"providerExecuted"}},{"kind":"Field","name":{"kind":"Name","value":"errorDetails"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlUrl"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"fileFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileUrl"}},{"kind":"Field","name":{"kind":"Name","value":"providerMetadata"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetChatMessagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChatMessages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chatMessages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"turnId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"messageId"}},{"kind":"Field","name":{"kind":"Name","value":"orderIndex"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"reasoningContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}},{"kind":"Field","name":{"kind":"Name","value":"toolCallId"}},{"kind":"Field","name":{"kind":"Name","value":"toolInput"}},{"kind":"Field","name":{"kind":"Name","value":"toolOutput"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"providerExecuted"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"errorDetails"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlUrl"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"fileFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileUrl"}},{"kind":"Field","name":{"kind":"Name","value":"fileId"}},{"kind":"Field","name":{"kind":"Name","value":"providerMetadata"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"chatStreamCatchupChunks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chunks"}},{"kind":"Field","name":{"kind":"Name","value":"maxSeq"}}]}}]}}]} as unknown as DocumentNode; export const GetChatThreadsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChatThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chatThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"contextWindowTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputCredits"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputCredits"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; -export const GetToolIndexDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetToolIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getToolIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"objectName"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}}]}}]}}]} as unknown as DocumentNode; +export const GetToolIndexDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetToolIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getToolIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"objectName"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}}]}}]}}]} as unknown as DocumentNode; export const GetToolInputSchemaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetToolInputSchema"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"toolName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getToolInputSchema"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"toolName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"toolName"}}}]}]}}]} as unknown as DocumentNode; export const OnAgentChatEventDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnAgentChatEvent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"onAgentChatEvent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"event"}}]}}]}}]} as unknown as DocumentNode; export const TrackAnalyticsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"TrackAnalytics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"type"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AnalyticsType"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"event"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"properties"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"trackAnalytics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"type"},"value":{"kind":"Variable","name":{"kind":"Name","value":"type"}}},{"kind":"Argument","name":{"kind":"Name","value":"event"},"value":{"kind":"Variable","name":{"kind":"Name","value":"event"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"properties"},"value":{"kind":"Variable","name":{"kind":"Name","value":"properties"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/ai/components/ThinkingStepsDisplay.tsx b/packages/twenty-front/src/modules/ai/components/ThinkingStepsDisplay.tsx index ee7e3efcb3..8712d2a5d6 100644 --- a/packages/twenty-front/src/modules/ai/components/ThinkingStepsDisplay.tsx +++ b/packages/twenty-front/src/modules/ai/components/ThinkingStepsDisplay.tsx @@ -14,11 +14,10 @@ import { AnimatedExpandableContainer } from 'twenty-ui/layout'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { type JsonValue } from 'type-fest'; +import { useToolDisplayContext } from '@/ai/hooks/useToolDisplayContext'; import { getToolIcon } from '@/ai/utils/getToolIcon'; -import { - getToolDisplayMessage, - resolveToolInput, -} from '@/ai/utils/getToolDisplayMessage'; +import { getToolDisplayMessage } from '@/ai/utils/tool-display/get-tool-display-message'; +import { unwrapToolInput } from '@/ai/utils/tool-display/unwrap-tool-input.util'; import { getActiveReasoningContent } from '@/ai/utils/getActiveReasoningContent'; import { getLastReasoningContent } from '@/ai/utils/getLastReasoningContent'; import { isThinkingStepPartActive } from '@/ai/utils/isThinkingStepPartActive'; @@ -263,13 +262,20 @@ const ThinkingToolStepRow = ({ const { copyToClipboard } = useCopyToClipboard(); const [isExpanded, setIsExpanded] = useState(false); const rawToolName = getToolName(part); - const { resolvedInput: toolInput, resolvedToolName } = resolveToolInput( - part.input, - rawToolName, - ); + const { toolInput, toolName } = unwrapToolInput({ + input: part.input, + toolName: rawToolName, + }); - const ToolIcon = getToolIcon(resolvedToolName); - const label = getToolDisplayMessage(part.input, rawToolName, !isActive); + const displayContext = useToolDisplayContext(); + const ToolIcon = getToolIcon(toolName); + const displayMessage = getToolDisplayMessage({ + input: part.input, + toolName: rawToolName, + isFinished: !isActive, + displayContext, + output: part.output, + }); const hasError = isDefined(part.errorText); const isExpandable = isDefined(part.output) || hasError; @@ -312,7 +318,7 @@ const ThinkingToolStepRow = ({ diff --git a/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx b/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx index f2869bbf31..2e5fcd5d5b 100644 --- a/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx @@ -8,10 +8,9 @@ import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; import { CodeExecutionDisplay } from '@/ai/components/CodeExecutionDisplay'; import { ShimmeringText } from '@/ai/components/ShimmeringText'; -import { - getToolDisplayMessage, - resolveToolInput, -} from '@/ai/utils/getToolDisplayMessage'; +import { useToolDisplayContext } from '@/ai/hooks/useToolDisplayContext'; +import { getToolDisplayMessage } from '@/ai/utils/tool-display/get-tool-display-message'; +import { unwrapToolInput } from '@/ai/utils/tool-display/unwrap-tool-input.util'; import { getToolIcon } from '@/ai/utils/getToolIcon'; import { useLingui } from '@lingui/react/macro'; import { type DynamicToolUIPart, getToolName, type ToolUIPart } from 'ai'; @@ -143,11 +142,15 @@ export const ToolStepRenderer = ({ const { input, output, errorText } = toolPart; const rawToolName = getToolName(toolPart); - const { resolvedInput: toolInput, resolvedToolName: toolName } = - resolveToolInput(input, rawToolName); + const { toolInput, toolName } = unwrapToolInput({ + input, + toolName: rawToolName, + }); + const displayContext = useToolDisplayContext(); const hasError = isDefined(errorText); - const isExpandable = isDefined(output) || hasError; + const isCodeInterpreter = toolName === 'code_interpreter'; + const isExpandable = isDefined(output) || hasError || isCodeInterpreter; const ToolIcon = getToolIcon(toolName); const outputObj = @@ -159,42 +162,40 @@ export const ToolStepRenderer = ({ const toolError = typeof outputObj?.error === 'string' ? outputObj.error : null; - if (toolName === 'code_interpreter') { - const codeInput = toolInput as { code?: string } | undefined; - const codeOutput = outputObj as { - stdout?: string; - stderr?: string; - exitCode?: number; - files?: Array<{ - fileId: string; - filename: string; - url: string; - mimeType?: string; - }>; - } | null; - - const isRunning = !outputObj && !hasError && isStreaming; - - return ( - - ); - } + const codeInput = isCodeInterpreter + ? (toolInput as { code?: string } | undefined) + : null; + const codeOutput = isCodeInterpreter + ? (outputObj as { + stdout?: string; + stderr?: string; + exitCode?: number; + files?: Array<{ + fileId: string; + filename: string; + url: string; + mimeType?: string; + }>; + } | null) + : null; if (!output && !hasError) { - const displayText = isStreaming - ? getToolDisplayMessage(input, rawToolName, false) - : getToolDisplayMessage(input, rawToolName, true); + const displayText = getToolDisplayMessage({ + input, + toolName: rawToolName, + isFinished: !isStreaming, + displayContext, + output, + }); return ( - + setIsExpanded(!isExpanded) : undefined + } + > @@ -209,8 +210,27 @@ export const ToolStepRenderer = ({ {toolName} + {isCodeInterpreter && + (isExpanded ? ( + + ) : ( + + ))} + {isCodeInterpreter && ( + + + + )} ); } @@ -220,11 +240,74 @@ export const ToolStepRenderer = ({ : rawToolName === 'learn_tools' || rawToolName === 'execute_tool' || rawToolName === 'load_skills' - ? getToolDisplayMessage(input, rawToolName, true) - : (toolMessage ?? getToolDisplayMessage(input, rawToolName, true)); + ? getToolDisplayMessage({ + input, + toolName: rawToolName, + isFinished: true, + displayContext, + output, + }) + : (toolMessage ?? + getToolDisplayMessage({ + input, + toolName: rawToolName, + isFinished: true, + displayContext, + output, + })); const result = toolError ? { error: toolError } : outputObj; + const renderExpandedContent = () => { + if (isCodeInterpreter) { + return ( + + ); + } + + if (hasError) { + return errorText; + } + + return ( + <> + + setActiveTab('output')} + > + {t`Output`} + + setActiveTab('input')} + > + {t`Input`} + + + + + false} + emptyArrayLabel={t`Empty Array`} + emptyObjectLabel={t`Empty Object`} + emptyStringLabel={t`[empty string]`} + arrowButtonCollapsedLabel={t`Expand`} + arrowButtonExpandedLabel={t`Collapse`} + onNodeValueClick={copyToClipboard} + /> + + + ); + }; + return ( - - {hasError ? ( - errorText - ) : ( - <> - - setActiveTab('output')} - > - {t`Output`} - - setActiveTab('input')} - > - {t`Input`} - - - - - false} - emptyArrayLabel={t`Empty Array`} - emptyObjectLabel={t`Empty Object`} - emptyStringLabel={t`[empty string]`} - arrowButtonCollapsedLabel={t`Expand`} - arrowButtonExpandedLabel={t`Collapse`} - onNodeValueClick={copyToClipboard} - /> - - - )} - + {isCodeInterpreter ? ( + renderExpandedContent() + ) : ( + + {renderExpandedContent()} + + )} )} diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/AiChatAssistantMessageRenderer.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/AiChatAssistantMessageRenderer.test.tsx index 5408684f08..a7f22ec15c 100644 --- a/packages/twenty-front/src/modules/ai/components/__tests__/AiChatAssistantMessageRenderer.test.tsx +++ b/packages/twenty-front/src/modules/ai/components/__tests__/AiChatAssistantMessageRenderer.test.tsx @@ -139,7 +139,7 @@ describe('AiChatAssistantMessageRenderer', () => { expect(screen.getByTestId('code-execution-display')).toBeInTheDocument(); }); - it('should render tool-execute_tool wrapping code_interpreter via ToolStepRenderer after refetch', () => { + it('should render tool-execute_tool wrapping code_interpreter via ThinkingStepsDisplay after refetch', () => { const messageParts = [ { type: 'tool-execute_tool', @@ -157,10 +157,10 @@ describe('AiChatAssistantMessageRenderer', () => { renderAssistantRenderer(messageParts); - expect(screen.queryByTestId('thinking-steps-display')).toBeNull(); - expect(screen.getByTestId('tool-step-renderer')).toHaveTextContent( - 'tool-execute_tool', + expect(screen.getByTestId('thinking-steps-display')).toHaveTextContent( + 'thinking-1-answer-pending', ); + expect(screen.queryByTestId('tool-step-renderer')).toBeNull(); }); it('should hide execute_tool wrapping code_interpreter when data-code-execution parts exist', () => { diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/ThinkingStepsDisplay.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/ThinkingStepsDisplay.test.tsx index 19a3a1d329..2e4d68695b 100644 --- a/packages/twenty-front/src/modules/ai/components/__tests__/ThinkingStepsDisplay.test.tsx +++ b/packages/twenty-front/src/modules/ai/components/__tests__/ThinkingStepsDisplay.test.tsx @@ -11,6 +11,14 @@ jest.mock('~/hooks/useCopyToClipboard', () => ({ }), })); +jest.mock('@/ai/hooks/useGetToolIndex', () => ({ + useGetToolIndex: () => ({ + toolIndex: [], + loading: false, + error: undefined, + }), +})); + jest.mock( '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue', () => ({ diff --git a/packages/twenty-front/src/modules/ai/constants/action-tool-status-labels.constant.ts b/packages/twenty-front/src/modules/ai/constants/action-tool-status-labels.constant.ts new file mode 100644 index 0000000000..0fff676f35 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/action-tool-status-labels.constant.ts @@ -0,0 +1,22 @@ +import { msg } from '@lingui/core/macro'; + +import { type ToolStatusLabels } from '@/ai/types/tool-status-labels.type'; + +export const ACTION_TOOL_STATUS_LABELS: Record = { + send_email: { + loading: msg`Sending email`, + completed: msg`Sent email`, + }, + draft_email: { + loading: msg`Drafting email`, + completed: msg`Drafted email`, + }, + search_help_center: { + loading: msg`Searching the help center`, + completed: msg`Searched the help center`, + }, + navigate_app: { + loading: msg`Navigating in the app`, + completed: msg`Navigated in the app`, + }, +}; diff --git a/packages/twenty-front/src/modules/ai/constants/crud-tool-operation-verbs.constant.ts b/packages/twenty-front/src/modules/ai/constants/crud-tool-operation-verbs.constant.ts new file mode 100644 index 0000000000..44c6ba9cb8 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/crud-tool-operation-verbs.constant.ts @@ -0,0 +1,52 @@ +import { msg } from '@lingui/core/macro'; +import { type DatabaseCrudOperation } from 'twenty-shared/ai'; + +import { type ToolStatusLabels } from '@/ai/types/tool-status-labels.type'; + +export type CrudToolOperation = DatabaseCrudOperation; + +export const CRUD_TOOL_OPERATION_VERBS: Record< + CrudToolOperation, + ToolStatusLabels +> = { + find_many: { + loading: msg`Searching {objectLabel}`, + completed: msg`Searched {objectLabel}`, + }, + find_one: { + loading: msg`Finding {objectLabel}`, + completed: msg`Found {objectLabel}`, + }, + group_by: { + loading: msg`Grouping {objectLabel}`, + completed: msg`Grouped {objectLabel}`, + }, + create_one: { + loading: msg`Creating {objectLabel}`, + completed: msg`Created {objectLabel}`, + }, + create_many: { + loading: msg`Creating {objectLabel}`, + completed: msg`Created {objectLabel}`, + }, + update_one: { + loading: msg`Updating {objectLabel}`, + completed: msg`Updated {objectLabel}`, + }, + update_many: { + loading: msg`Updating {objectLabel}`, + completed: msg`Updated {objectLabel}`, + }, + upsert_many: { + loading: msg`Upserting {objectLabel}`, + completed: msg`Upserted {objectLabel}`, + }, + delete_one: { + loading: msg`Deleting {objectLabel}`, + completed: msg`Deleted {objectLabel}`, + }, + delete_many: { + loading: msg`Deleting {objectLabel}`, + completed: msg`Deleted {objectLabel}`, + }, +}; diff --git a/packages/twenty-front/src/modules/ai/graphql/queries/getToolIndex.ts b/packages/twenty-front/src/modules/ai/graphql/queries/getToolIndex.ts index b19e94e723..1d6fb7b8de 100644 --- a/packages/twenty-front/src/modules/ai/graphql/queries/getToolIndex.ts +++ b/packages/twenty-front/src/modules/ai/graphql/queries/getToolIndex.ts @@ -4,6 +4,7 @@ export const GET_TOOL_INDEX = gql` query GetToolIndex { getToolIndex { name + label description category objectName diff --git a/packages/twenty-front/src/modules/ai/hooks/useGetToolIndex.ts b/packages/twenty-front/src/modules/ai/hooks/useGetToolIndex.ts index 4b30323714..806d634c3c 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useGetToolIndex.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useGetToolIndex.ts @@ -3,11 +3,13 @@ import { useQuery } from '@apollo/client/react'; import { type GetToolIndexQuery } from '~/generated-metadata/graphql'; +const EMPTY_TOOL_INDEX: NonNullable = []; + export const useGetToolIndex = () => { const { data, loading, error } = useQuery(GET_TOOL_INDEX); return { - toolIndex: data?.getToolIndex ?? [], + toolIndex: data?.getToolIndex ?? EMPTY_TOOL_INDEX, loading, error, }; diff --git a/packages/twenty-front/src/modules/ai/hooks/useToolDisplayContext.ts b/packages/twenty-front/src/modules/ai/hooks/useToolDisplayContext.ts new file mode 100644 index 0000000000..8c07d3bb6f --- /dev/null +++ b/packages/twenty-front/src/modules/ai/hooks/useToolDisplayContext.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; + +import { useGetToolIndex } from '@/ai/hooks/useGetToolIndex'; +import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { type ToolCategory } from 'twenty-shared/ai'; + +const EMPTY_TOOL_DISPLAY_CONTEXT: ToolDisplayContext = { + labelByName: new Map(), + indexByName: new Map(), + objectMetadataItems: [], +}; + +export const useToolDisplayContext = (): ToolDisplayContext => { + const { toolIndex } = useGetToolIndex(); + const { objectMetadataItems } = useObjectMetadataItems(); + + return useMemo(() => { + if (toolIndex.length === 0 && objectMetadataItems.length === 0) { + return EMPTY_TOOL_DISPLAY_CONTEXT; + } + + return { + labelByName: new Map(toolIndex.map((entry) => [entry.name, entry.label])), + indexByName: new Map( + toolIndex.map((entry) => [ + entry.name, + { + category: entry.category as ToolCategory, + objectName: entry.objectName, + }, + ]), + ), + objectMetadataItems, + }; + }, [toolIndex, objectMetadataItems]); +}; diff --git a/packages/twenty-front/src/modules/ai/types/tool-display-context.type.ts b/packages/twenty-front/src/modules/ai/types/tool-display-context.type.ts new file mode 100644 index 0000000000..b7c6087b0b --- /dev/null +++ b/packages/twenty-front/src/modules/ai/types/tool-display-context.type.ts @@ -0,0 +1,14 @@ +import { type ToolCategory } from 'twenty-shared/ai'; + +import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; + +type ToolIndexInfo = { + category: ToolCategory; + objectName?: string | null; +}; + +export type ToolDisplayContext = { + labelByName: Map; + indexByName: Map; + objectMetadataItems: EnrichedObjectMetadataItem[]; +}; diff --git a/packages/twenty-front/src/modules/ai/types/tool-status-labels.type.ts b/packages/twenty-front/src/modules/ai/types/tool-status-labels.type.ts new file mode 100644 index 0000000000..59217164ff --- /dev/null +++ b/packages/twenty-front/src/modules/ai/types/tool-status-labels.type.ts @@ -0,0 +1,6 @@ +import { type MessageDescriptor } from '@lingui/core'; + +export type ToolStatusLabels = { + loading: MessageDescriptor; + completed: MessageDescriptor; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/getToolDisplayMessage.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/getToolDisplayMessage.test.ts deleted file mode 100644 index 32d149f77b..0000000000 --- a/packages/twenty-front/src/modules/ai/utils/__tests__/getToolDisplayMessage.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { i18n } from '@lingui/core'; - -import { - getToolDisplayMessage, - resolveToolInput, -} from '@/ai/utils/getToolDisplayMessage'; - -beforeEach(() => { - i18n.load('en', {}); - i18n.activate('en'); -}); - -describe('resolveToolInput', () => { - it('should pass through non-execute_tool inputs unchanged', () => { - const input = { query: 'test' }; - const result = resolveToolInput(input, 'web_search'); - - expect(result).toEqual({ - resolvedInput: input, - resolvedToolName: 'web_search', - }); - }); - - it('should unwrap execute_tool input', () => { - const input = { - toolName: 'find_many_companies', - arguments: { filter: { name: 'Acme' } }, - }; - const result = resolveToolInput(input, 'execute_tool'); - - expect(result).toEqual({ - resolvedInput: { filter: { name: 'Acme' } }, - resolvedToolName: 'find_many_companies', - }); - }); - - it('should return original input for non-execute_tool even with toolName field', () => { - const input = { toolName: 'inner', arguments: {} }; - const result = resolveToolInput(input, 'web_search'); - - expect(result).toEqual({ - resolvedInput: input, - resolvedToolName: 'web_search', - }); - }); -}); - -describe('getToolDisplayMessage', () => { - describe('web_search', () => { - it('should show finished message with query', () => { - const message = getToolDisplayMessage( - { query: 'CRM tools' }, - 'web_search', - true, - ); - - expect(message).toContain('Searched'); - expect(message).toContain('CRM tools'); - }); - - it('should show in-progress message with query', () => { - const message = getToolDisplayMessage( - { query: 'CRM tools' }, - 'web_search', - false, - ); - - expect(message).toContain('Searching'); - expect(message).toContain('CRM tools'); - }); - - it('should handle nested query format', () => { - const message = getToolDisplayMessage( - { action: { query: 'nested query' } }, - 'web_search', - true, - ); - - expect(message).toContain('nested query'); - }); - - it('should handle missing query', () => { - const message = getToolDisplayMessage({}, 'web_search', true); - - expect(message).toContain('Searched the web'); - }); - }); - - describe('app_exa_web_search', () => { - it('should show the same searching-the-web message as native web_search', () => { - const message = getToolDisplayMessage( - { query: 'CRM tools' }, - 'app_exa_web_search', - false, - ); - - expect(message).toContain('Searching'); - expect(message).toContain('CRM tools'); - }); - - it('should handle missing query', () => { - const message = getToolDisplayMessage({}, 'app_exa_web_search', true); - - expect(message).toContain('Searched the web'); - }); - }); - - describe('learn_tools', () => { - it('should show tool names when provided', () => { - const message = getToolDisplayMessage( - { toolNames: ['find_many_companies', 'create_one_task'] }, - 'learn_tools', - true, - ); - - expect(message).toContain('Learned'); - expect(message).toContain('find_many_companies, create_one_task'); - }); - - it('should show generic message without tool names', () => { - const message = getToolDisplayMessage({}, 'learn_tools', true); - - expect(message).toContain('Learned tools'); - }); - }); - - describe('load_skills', () => { - it('should show skill names when provided', () => { - const message = getToolDisplayMessage( - { skillNames: ['data-manipulation'] }, - 'load_skills', - false, - ); - - expect(message).toContain('Loading'); - expect(message).toContain('data-manipulation'); - }); - - it('should show generic message without skill names', () => { - const message = getToolDisplayMessage({}, 'load_skills', true); - - expect(message).toContain('Loaded skills'); - }); - }); - - describe('custom loading message', () => { - it('should use loadingMessage when provided', () => { - const message = getToolDisplayMessage( - { loadingMessage: 'Building dashboard...' }, - 'some_tool', - false, - ); - - expect(message).toBe('Building dashboard...'); - }); - }); - - describe('generic tools', () => { - it('should format tool name with spaces for finished state', () => { - const message = getToolDisplayMessage( - {}, - 'create_complete_dashboard', - true, - ); - - expect(message).toContain('Ran'); - expect(message).toContain('create complete dashboard'); - }); - - it('should format tool name with spaces for in-progress state', () => { - const message = getToolDisplayMessage( - {}, - 'create_complete_dashboard', - false, - ); - - expect(message).toContain('Running'); - expect(message).toContain('create complete dashboard'); - }); - }); - - describe('execute_tool wrapper', () => { - it('should unwrap execute_tool and display inner tool name', () => { - const message = getToolDisplayMessage( - { toolName: 'find_many_companies', arguments: { limit: 10 } }, - 'execute_tool', - true, - ); - - expect(message).toContain('Ran'); - expect(message).toContain('find many companies'); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/getToolOutputLabelEntries.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/getToolOutputLabelEntries.test.ts new file mode 100644 index 0000000000..a0629be87a --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/getToolOutputLabelEntries.test.ts @@ -0,0 +1,49 @@ +import { getToolOutputLabelEntries } from '@/ai/utils/getToolOutputLabelEntries'; + +describe('getToolOutputLabelEntries', () => { + it('should extract label entries from a learn_tools output', () => { + const entries = getToolOutputLabelEntries({ + tools: [ + { name: 'find_many_companies', label: 'Search companies' }, + { name: 'create_one_task', label: 'Create task' }, + ], + notFound: [], + message: 'Learned 2 tools', + }); + + expect(entries).toEqual([ + { name: 'find_many_companies', label: 'Search companies' }, + { name: 'create_one_task', label: 'Create task' }, + ]); + }); + + it('should extract label entries from a load_skills output', () => { + const entries = getToolOutputLabelEntries({ + skills: [ + { name: 'data-manipulation', label: 'Data Manipulation' }, + { name: 'workflow-building', label: 'Workflow Building' }, + ], + message: 'Loaded Data Manipulation, Workflow Building', + }); + + expect(entries).toEqual([ + { name: 'data-manipulation', label: 'Data Manipulation' }, + { name: 'workflow-building', label: 'Workflow Building' }, + ]); + }); + + it('should skip entries without a label', () => { + const entries = getToolOutputLabelEntries({ + skills: [{ name: 'data-manipulation' }], + }); + + expect(entries).toEqual([]); + }); + + it('should return an empty array for unrelated outputs', () => { + expect(getToolOutputLabelEntries({ records: [], count: '0' })).toEqual([]); + expect(getToolOutputLabelEntries(null)).toEqual([]); + expect(getToolOutputLabelEntries(undefined)).toEqual([]); + expect(getToolOutputLabelEntries('not an object')).toEqual([]); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/thinkingStepsDisplayState.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/thinkingStepsDisplayState.test.ts index c546f51bd9..b094a8114b 100644 --- a/packages/twenty-front/src/modules/ai/utils/__tests__/thinkingStepsDisplayState.test.ts +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/thinkingStepsDisplayState.test.ts @@ -41,7 +41,7 @@ const createToolPart = ({ describe('thinkingStepsDisplayState', () => { describe('groupContiguousThinkingStepParts', () => { - it('should group contiguous reasoning and non-code-interpreter tool parts', () => { + it('should group contiguous reasoning and tool parts', () => { const parts = [ { type: 'text', text: 'hello' } as ExtendedUIMessagePart, { type: 'step-start' } as ExtendedUIMessagePart, @@ -77,9 +77,9 @@ describe('thinkingStepsDisplayState', () => { type: 'part', part: parts[6], }); - expect(groupedParts[3]).toEqual({ - type: 'part', - part: parts[7], + expect(groupedParts[3]).toMatchObject({ + type: 'thinking-steps', + parts: [parts[7]], }); }); }); diff --git a/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts b/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts deleted file mode 100644 index 51f8f48f53..0000000000 --- a/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { t } from '@lingui/core/macro'; - -import { isNonEmptyString } from '@sniptt/guards'; -import { z } from 'zod'; - -import { type ToolInput } from '@/ai/types/ToolInput'; -import { isDefined } from 'twenty-shared/utils'; - -const DirectQuerySchema = z.object({ query: z.string() }); -const NestedQuerySchema = z.object({ - action: z.object({ query: z.string() }), -}); -const CustomLoadingMessageSchema = z.object({ loadingMessage: z.string() }); -const ExecuteToolSchema = z.object({ - toolName: z.coerce.string(), - arguments: z.unknown(), -}); -const LearnToolsSchema = z.object({ toolNames: z.array(z.string()) }); -const LoadSkillsSchema = z.object({ skillNames: z.array(z.string()) }); - -const extractSearchQuery = (input: ToolInput): string => { - const direct = DirectQuerySchema.safeParse(input); - - if (direct.success) { - return direct.data.query; - } - - const nested = NestedQuerySchema.safeParse(input); - - if (nested.success) { - return nested.data.action.query; - } - - return ''; -}; - -const extractCustomLoadingMessage = (input: ToolInput): string | null => { - const parsed = CustomLoadingMessageSchema.safeParse(input); - - return parsed.success ? parsed.data.loadingMessage : null; -}; - -export const resolveToolInput = ( - input: ToolInput, - toolName: string, -): { resolvedInput: ToolInput; resolvedToolName: string } => { - if (toolName !== 'execute_tool') { - return { resolvedInput: input, resolvedToolName: toolName }; - } - - const parsed = ExecuteToolSchema.safeParse(input); - - if (!parsed.success) { - return { resolvedInput: input, resolvedToolName: toolName }; - } - - return { - resolvedInput: parsed.data.arguments as ToolInput, - resolvedToolName: parsed.data.toolName, - }; -}; - -const extractLearnToolNames = (input: ToolInput): string => { - const parsed = LearnToolsSchema.safeParse(input); - - return parsed.success ? parsed.data.toolNames.join(', ') : ''; -}; - -const extractSkillNames = (input: ToolInput): string => { - const parsed = LoadSkillsSchema.safeParse(input); - - return parsed.success ? parsed.data.skillNames.join(', ') : ''; -}; - -const formatToolName = (toolName: string): string => { - return toolName.replace(/_/g, ' '); -}; - -export const getToolDisplayMessage = ( - input: ToolInput, - toolName: string, - isFinished?: boolean, -): string => { - const { resolvedInput, resolvedToolName } = resolveToolInput(input, toolName); - - const byStatus = (finished: string, inProgress: string): string => - isFinished ? finished : inProgress; - - if ( - resolvedToolName === 'web_search' || - resolvedToolName === 'app_exa_web_search' - ) { - const query = extractSearchQuery(resolvedInput); - - if (isNonEmptyString(query)) { - return byStatus( - t`Searched the web for ${query}`, - t`Searching the web for ${query}`, - ); - } - - return byStatus(t`Searched the web`, t`Searching the web`); - } - - if (resolvedToolName === 'learn_tools') { - const names = extractLearnToolNames(resolvedInput); - - if (isNonEmptyString(names)) { - return byStatus(t`Learned ${names}`, t`Learning ${names}`); - } - - return byStatus(t`Learned tools`, t`Learning tools...`); - } - - if (resolvedToolName === 'load_skills') { - const names = extractSkillNames(resolvedInput); - - if (isNonEmptyString(names)) { - return byStatus(t`Loaded ${names}`, t`Loading ${names}`); - } - - return byStatus(t`Loaded skills`, t`Loading skills...`); - } - - const customMessage = extractCustomLoadingMessage(resolvedInput); - - if (isDefined(customMessage)) { - return customMessage; - } - - const formattedName = formatToolName(resolvedToolName); - - return byStatus(t`Ran ${formattedName}`, t`Running ${formattedName}`); -}; diff --git a/packages/twenty-front/src/modules/ai/utils/getToolOutputLabelEntries.ts b/packages/twenty-front/src/modules/ai/utils/getToolOutputLabelEntries.ts new file mode 100644 index 0000000000..a1b45f7dc6 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/getToolOutputLabelEntries.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +const LabelEntrySchema = z.object({ + name: z.string(), + label: z.string().optional(), +}); + +const ToolOutputWithLabelsSchema = z.object({ + tools: z.array(LabelEntrySchema).optional(), + skills: z.array(LabelEntrySchema).optional(), +}); + +export const getToolOutputLabelEntries = ( + output: unknown, +): Array<{ name: string; label: string }> => { + const parsed = ToolOutputWithLabelsSchema.safeParse(output); + + if (!parsed.success) { + return []; + } + + return [...(parsed.data.tools ?? []), ...(parsed.data.skills ?? [])].flatMap( + (entry) => (entry.label ? [{ name: entry.name, label: entry.label }] : []), + ); +}; diff --git a/packages/twenty-front/src/modules/ai/utils/isThinkingStepPart.ts b/packages/twenty-front/src/modules/ai/utils/isThinkingStepPart.ts index 399f684edb..244861ef8c 100644 --- a/packages/twenty-front/src/modules/ai/utils/isThinkingStepPart.ts +++ b/packages/twenty-front/src/modules/ai/utils/isThinkingStepPart.ts @@ -1,7 +1,6 @@ import { isToolUIPart } from 'ai'; import { type ExtendedUIMessagePart } from 'twenty-shared/ai'; -import { isCodeInterpreterToolPart } from '@/ai/utils/isCodeInterpreterToolPart'; import { type ThinkingStepPart } from '@/ai/utils/thinkingStepPart'; export const isThinkingStepPart = ( @@ -11,5 +10,5 @@ export const isThinkingStepPart = ( return true; } - return isToolUIPart(part) && !isCodeInterpreterToolPart(part); + return isToolUIPart(part); }; diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/__tests__/build-action-tool-status-message.util.test.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/__tests__/build-action-tool-status-message.util.test.ts new file mode 100644 index 0000000000..b31ac19600 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/__tests__/build-action-tool-status-message.util.test.ts @@ -0,0 +1,58 @@ +import { i18n } from '@lingui/core'; + +import { buildActionToolStatusMessage } from '@/ai/utils/tool-display/build-action-tool-status-message.util'; +import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type'; + +beforeEach(() => { + i18n.load('en', {}); + i18n.activate('en'); +}); + +describe('buildActionToolStatusMessage', () => { + it('should use custom status labels when defined', () => { + const displayContext: ToolDisplayContext = { + labelByName: new Map([['send_email', 'Send Email']]), + indexByName: new Map(), + objectMetadataItems: [], + }; + + expect( + buildActionToolStatusMessage({ + toolName: 'send_email', + isFinished: false, + displayContext, + }), + ).toBe('Sending email'); + + expect( + buildActionToolStatusMessage({ + toolName: 'send_email', + isFinished: true, + displayContext, + }), + ).toBe('Sent email'); + }); + + it('should fall back to default Ran/Running when no custom status labels exist', () => { + const displayContext: ToolDisplayContext = { + labelByName: new Map([['http_request', 'HTTP Request']]), + indexByName: new Map(), + objectMetadataItems: [], + }; + + expect( + buildActionToolStatusMessage({ + toolName: 'http_request', + isFinished: false, + displayContext, + }), + ).toContain('Running'); + expect( + buildActionToolStatusMessage({ + toolName: 'http_request', + isFinished: true, + displayContext, + }), + ).toContain('Ran'); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/__tests__/build-crud-tool-status-message.util.test.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/__tests__/build-crud-tool-status-message.util.test.ts new file mode 100644 index 0000000000..62da41f3f3 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/__tests__/build-crud-tool-status-message.util.test.ts @@ -0,0 +1,58 @@ +import { i18n } from '@lingui/core'; +import { ToolCategory } from 'twenty-shared/ai'; + +import { buildCrudToolStatusMessage } from '@/ai/utils/tool-display/build-crud-tool-status-message.util'; +import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type'; + +beforeEach(() => { + i18n.load('en', {}); + i18n.activate('en'); +}); + +describe('buildCrudToolStatusMessage', () => { + const displayContext: ToolDisplayContext = { + labelByName: new Map([['find_many_people', 'Search people']]), + indexByName: new Map([ + [ + 'find_many_people', + { category: ToolCategory.DATABASE_CRUD, objectName: 'person' }, + ], + ]), + objectMetadataItems: [ + { + nameSingular: 'person', + namePlural: 'people', + labelSingular: 'Contact', + labelPlural: 'Contacts', + }, + ] as ToolDisplayContext['objectMetadataItems'], + }; + + it('should build in-progress and completed labels from metadata', () => { + expect( + buildCrudToolStatusMessage({ + toolName: 'find_many_people', + isFinished: false, + displayContext, + }), + ).toBe('Searching contacts'); + + expect( + buildCrudToolStatusMessage({ + toolName: 'find_many_people', + isFinished: true, + displayContext, + }), + ).toBe('Searched contacts'); + }); + + it('should return null for non-CRUD tool names', () => { + expect( + buildCrudToolStatusMessage({ + toolName: 'send_email', + isFinished: true, + displayContext, + }), + ).toBeNull(); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/__tests__/get-tool-display-message.util.test.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/__tests__/get-tool-display-message.util.test.ts new file mode 100644 index 0000000000..9071037ddc --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/__tests__/get-tool-display-message.util.test.ts @@ -0,0 +1,589 @@ +import { i18n } from '@lingui/core'; +import { ToolCategory } from 'twenty-shared/ai'; + +import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { getToolDisplayMessage } from '@/ai/utils/tool-display/get-tool-display-message'; +import { unwrapToolInput } from '@/ai/utils/tool-display/unwrap-tool-input.util'; +import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type'; + +const emptyDisplayContext: ToolDisplayContext = { + labelByName: new Map(), + indexByName: new Map(), + objectMetadataItems: [], +}; + +const makeDisplayContext = ({ + labels = [], + indexEntries = [], + objectMetadataItems = [], +}: { + labels?: Array<[string, string]>; + indexEntries?: Array< + [string, { category: ToolCategory; objectName?: string | null }] + >; + objectMetadataItems?: Array< + Pick< + EnrichedObjectMetadataItem, + 'nameSingular' | 'namePlural' | 'labelSingular' | 'labelPlural' + > + >; +}): ToolDisplayContext => ({ + labelByName: new Map(labels), + indexByName: new Map(indexEntries), + objectMetadataItems: objectMetadataItems as EnrichedObjectMetadataItem[], +}); + +const personMetadata = { + nameSingular: 'person', + namePlural: 'people', + labelSingular: 'Person', + labelPlural: 'People', +}; + +beforeEach(() => { + i18n.load('en', {}); + i18n.activate('en'); +}); + +describe('unwrapToolInput', () => { + it('should pass through non-execute_tool inputs unchanged', () => { + const input = { query: 'test' }; + const result = unwrapToolInput({ input, toolName: 'web_search' }); + + expect(result).toEqual({ + toolInput: input, + toolName: 'web_search', + }); + }); + + it('should unwrap execute_tool input', () => { + const input = { + toolName: 'find_many_companies', + arguments: { filter: { name: 'Acme' } }, + }; + const result = unwrapToolInput({ input, toolName: 'execute_tool' }); + + expect(result).toEqual({ + toolInput: { filter: { name: 'Acme' } }, + toolName: 'find_many_companies', + }); + }); + + it('should return original input for non-execute_tool even with toolName field', () => { + const input = { toolName: 'inner', arguments: {} }; + const result = unwrapToolInput({ input, toolName: 'web_search' }); + + expect(result).toEqual({ + toolInput: input, + toolName: 'web_search', + }); + }); +}); + +describe('getToolDisplayMessage', () => { + describe('web_search', () => { + it('should show finished message with query', () => { + const message = getToolDisplayMessage({ + input: { query: 'CRM tools' }, + toolName: 'web_search', + isFinished: true, + displayContext: emptyDisplayContext, + }); + + expect(message).toContain('Searched'); + expect(message).toContain('CRM tools'); + }); + + it('should show in-progress message with query', () => { + const message = getToolDisplayMessage({ + input: { query: 'CRM tools' }, + toolName: 'web_search', + isFinished: false, + displayContext: emptyDisplayContext, + }); + + expect(message).toContain('Searching'); + expect(message).toContain('CRM tools'); + }); + + it('should handle nested query format', () => { + const message = getToolDisplayMessage({ + input: { action: { query: 'nested query' } }, + toolName: 'web_search', + isFinished: true, + displayContext: emptyDisplayContext, + }); + + expect(message).toContain('nested query'); + }); + + it('should handle missing query', () => { + const message = getToolDisplayMessage({ + input: {}, + toolName: 'web_search', + isFinished: true, + displayContext: emptyDisplayContext, + }); + + expect(message).toContain('Searched the web'); + }); + }); + + describe('app_exa_web_search', () => { + it('should show the same searching-the-web message as native web_search', () => { + const message = getToolDisplayMessage({ + input: { query: 'CRM tools' }, + toolName: 'app_exa_web_search', + isFinished: false, + displayContext: emptyDisplayContext, + }); + + expect(message).toContain('Searching'); + expect(message).toContain('CRM tools'); + }); + + it('should handle missing query', () => { + const message = getToolDisplayMessage({ + input: {}, + toolName: 'app_exa_web_search', + isFinished: true, + displayContext: emptyDisplayContext, + }); + + expect(message).toContain('Searched the web'); + }); + }); + + describe('learn_tools', () => { + it('should show tool names when provided', () => { + const message = getToolDisplayMessage({ + input: { toolNames: ['find_many_companies', 'create_one_task'] }, + toolName: 'learn_tools', + isFinished: true, + displayContext: emptyDisplayContext, + }); + + expect(message).toContain('Learned'); + expect(message).toContain('find_many_companies'); + expect(message).toContain('create_one_task'); + }); + + it('should show generic message without tool names', () => { + const message = getToolDisplayMessage({ + input: {}, + toolName: 'learn_tools', + isFinished: true, + displayContext: emptyDisplayContext, + }); + + expect(message).toContain('Learned tools'); + }); + + it('should resolve inner tool names to their labels from the label map', () => { + const displayContext = makeDisplayContext({ + labels: [ + ['find_many_companies', 'Search companies'], + ['create_one_task', 'Create task'], + ], + }); + + const message = getToolDisplayMessage({ + input: { toolNames: ['find_many_companies', 'create_one_task'] }, + toolName: 'learn_tools', + isFinished: true, + displayContext, + }); + + expect(message).toContain('Search companies'); + expect(message).toContain('Create task'); + }); + + it('should fall back to the raw tool name when the label map has no entry', () => { + const displayContext = makeDisplayContext({ + labels: [['find_many_companies', 'Search companies']], + }); + + const message = getToolDisplayMessage({ + input: { toolNames: ['find_many_companies', 'app_unknown_tool'] }, + toolName: 'learn_tools', + isFinished: true, + displayContext, + }); + + expect(message).toContain('Search companies'); + expect(message).toContain('app_unknown_tool'); + }); + + it('should resolve labels from output when not in the label map', () => { + const output = { + tools: [{ name: 'app_unknown_tool', label: 'Unknown Tool Label' }], + }; + + const message = getToolDisplayMessage({ + input: { toolNames: ['app_unknown_tool'] }, + toolName: 'learn_tools', + isFinished: true, + displayContext: emptyDisplayContext, + output, + }); + + expect(message).toContain('Unknown Tool Label'); + }); + }); + + describe('load_skills', () => { + it('should show skill names when provided', () => { + const message = getToolDisplayMessage({ + input: { skillNames: ['data-manipulation'] }, + toolName: 'load_skills', + isFinished: false, + displayContext: emptyDisplayContext, + }); + + expect(message).toContain('Loading'); + expect(message).toContain('data-manipulation'); + }); + + it('should show generic message without skill names', () => { + const message = getToolDisplayMessage({ + input: {}, + toolName: 'load_skills', + isFinished: true, + displayContext: emptyDisplayContext, + }); + + expect(message).toContain('Loaded skills'); + }); + + it('should resolve inner skill names from output labels', () => { + const output = { + skills: [{ name: 'data-manipulation', label: 'Data manipulation' }], + }; + + const message = getToolDisplayMessage({ + input: { skillNames: ['data-manipulation'] }, + toolName: 'load_skills', + isFinished: true, + displayContext: emptyDisplayContext, + output, + }); + + expect(message).toContain('Data manipulation'); + }); + }); + + describe('code_interpreter (model-generated labels)', () => { + it('should use loadingMessage when in progress', () => { + const message = getToolDisplayMessage({ + input: { code: 'print(1)', loadingMessage: 'Analyzing sales data' }, + toolName: 'code_interpreter', + isFinished: false, + displayContext: emptyDisplayContext, + }); + + expect(message).toBe('Analyzing sales data'); + }); + + it('should use completedMessage when finished', () => { + const message = getToolDisplayMessage({ + input: { + code: 'print(1)', + loadingMessage: 'Analyzing sales data', + completedMessage: 'Analyzed sales data', + }, + toolName: 'code_interpreter', + isFinished: true, + displayContext: emptyDisplayContext, + }); + + expect(message).toBe('Analyzed sales data'); + }); + + it('should fall back to loadingMessage when completedMessage is missing', () => { + const message = getToolDisplayMessage({ + input: { code: 'print(1)', loadingMessage: 'Analyzing sales data' }, + toolName: 'code_interpreter', + isFinished: true, + displayContext: emptyDisplayContext, + }); + + expect(message).toBe('Analyzing sales data'); + }); + + it('should fall back to generic label when loadingMessage is absent', () => { + const message = getToolDisplayMessage({ + input: { code: 'print(1)' }, + toolName: 'code_interpreter', + isFinished: false, + displayContext: emptyDisplayContext, + }); + + expect(message).toContain('Running code'); + }); + + it('should fall back to generic label when loadingMessage is empty', () => { + const message = getToolDisplayMessage({ + input: { code: 'print(1)', loadingMessage: '' }, + toolName: 'code_interpreter', + isFinished: false, + displayContext: emptyDisplayContext, + }); + + expect(message).toContain('Running code'); + }); + + it('should not use loadingMessage for non-code_interpreter tools', () => { + const message = getToolDisplayMessage({ + input: { loadingMessage: 'Some status' }, + toolName: 'some_tool', + isFinished: false, + displayContext: emptyDisplayContext, + }); + + expect(message).not.toBe('Some status'); + expect(message).toContain('Running'); + expect(message).toContain('some_tool'); + }); + }); + + describe('default tool labels', () => { + it('should use default Ran/Running for non-CRUD tools', () => { + const displayContext = makeDisplayContext({ + labels: [['create_complete_dashboard', 'Create Dashboard']], + }); + + const message = getToolDisplayMessage({ + input: {}, + toolName: 'create_complete_dashboard', + isFinished: true, + displayContext, + }); + + expect(message).toContain('Ran'); + expect(message).toContain('Create Dashboard'); + }); + + it('should fall back to tool name when label map has no entry', () => { + const message = getToolDisplayMessage({ + input: {}, + toolName: 'create_complete_dashboard', + isFinished: true, + displayContext: emptyDisplayContext, + }); + + expect(message).toContain('Ran'); + expect(message).toContain('create_complete_dashboard'); + }); + + it('should use default Ran/Running for action tools without custom status labels', () => { + const displayContext = makeDisplayContext({ + labels: [['http_request', 'HTTP Request']], + indexEntries: [['http_request', { category: ToolCategory.ACTION }]], + }); + + const finished = getToolDisplayMessage({ + input: {}, + toolName: 'http_request', + isFinished: true, + displayContext, + }); + const inProgress = getToolDisplayMessage({ + input: {}, + toolName: 'http_request', + isFinished: false, + displayContext, + }); + + expect(finished).toContain('Ran'); + expect(finished).toContain('HTTP Request'); + expect(inProgress).toContain('Running'); + expect(inProgress).toContain('HTTP Request'); + }); + }); + + describe('CRUD status labels', () => { + it('should build completed label from object metadata', () => { + const displayContext = makeDisplayContext({ + labels: [['create_one_person', 'Create person']], + indexEntries: [ + [ + 'create_one_person', + { category: ToolCategory.DATABASE_CRUD, objectName: 'person' }, + ], + ], + objectMetadataItems: [personMetadata], + }); + + const message = getToolDisplayMessage({ + input: {}, + toolName: 'create_one_person', + isFinished: true, + displayContext, + }); + + expect(message).toBe('Created person'); + }); + + it('should build in-progress label from object metadata', () => { + const displayContext = makeDisplayContext({ + labels: [['create_one_person', 'Create person']], + indexEntries: [ + [ + 'create_one_person', + { category: ToolCategory.DATABASE_CRUD, objectName: 'person' }, + ], + ], + objectMetadataItems: [personMetadata], + }); + + const message = getToolDisplayMessage({ + input: {}, + toolName: 'create_one_person', + isFinished: false, + displayContext, + }); + + expect(message).toBe('Creating person'); + }); + + it('should fall back to generic Ran/Running when object metadata is missing', () => { + const displayContext = makeDisplayContext({ + labels: [['create_one_person', 'Create person']], + }); + + const finished = getToolDisplayMessage({ + input: {}, + toolName: 'create_one_person', + isFinished: true, + displayContext, + }); + const inProgress = getToolDisplayMessage({ + input: {}, + toolName: 'create_one_person', + isFinished: false, + displayContext, + }); + + expect(finished).toBe('Ran Create person'); + expect(inProgress).toBe('Running Create person'); + }); + }); + + describe('action status labels', () => { + it('should use custom action status labels when available', () => { + const displayContext = makeDisplayContext({ + labels: [['send_email', 'Send Email']], + indexEntries: [['send_email', { category: ToolCategory.ACTION }]], + }); + + const finished = getToolDisplayMessage({ + input: {}, + toolName: 'send_email', + isFinished: true, + displayContext, + }); + const inProgress = getToolDisplayMessage({ + input: {}, + toolName: 'send_email', + isFinished: false, + displayContext, + }); + + expect(finished).toBe('Sent email'); + expect(inProgress).toBe('Sending email'); + }); + }); + + describe('execute_tool wrapper', () => { + it('should unwrap execute_tool and display inner tool label from map', () => { + const displayContext = makeDisplayContext({ + labels: [['find_many_companies', 'Search Companies']], + }); + + const message = getToolDisplayMessage({ + input: { toolName: 'find_many_companies', arguments: { limit: 10 } }, + toolName: 'execute_tool', + isFinished: true, + displayContext, + }); + + expect(message).toBe('Ran Search Companies'); + }); + + it('should unwrap execute_tool and fall back to tool name without label', () => { + const message = getToolDisplayMessage({ + input: { toolName: 'find_many_companies', arguments: { limit: 10 } }, + toolName: 'execute_tool', + isFinished: true, + displayContext: emptyDisplayContext, + }); + + expect(message).toBe('Ran find_many_companies'); + }); + + it('should not recurse infinitely on nested execute_tool payloads', () => { + const displayContext = makeDisplayContext({ + labels: [['execute_tool', 'Execute Tool']], + }); + + const message = getToolDisplayMessage({ + input: { + toolName: 'execute_tool', + arguments: { toolName: 'inner', arguments: {} }, + }, + toolName: 'execute_tool', + isFinished: true, + displayContext, + }); + + expect(message).toContain('Ran'); + expect(message).toContain('Execute Tool'); + }); + + it('should unwrap execute_tool and use meta-tool handlers for web_search', () => { + const message = getToolDisplayMessage({ + input: { + toolName: 'web_search', + arguments: { query: 'CRM tools' }, + }, + toolName: 'execute_tool', + isFinished: false, + displayContext: emptyDisplayContext, + }); + + expect(message).toBe('Searching the web for CRM tools'); + }); + + it('should unwrap execute_tool and use meta-tool handlers for code_interpreter', () => { + const message = getToolDisplayMessage({ + input: { + toolName: 'code_interpreter', + arguments: { loadingMessage: 'Analyzing spreadsheet' }, + }, + toolName: 'execute_tool', + isFinished: false, + displayContext: emptyDisplayContext, + }); + + expect(message).toBe('Analyzing spreadsheet'); + }); + + it('should unwrap execute_tool and use meta-tool handlers for learn_tools', () => { + const displayContext = makeDisplayContext({ + labels: [['send_email', 'Send Email']], + }); + + const message = getToolDisplayMessage({ + input: { + toolName: 'learn_tools', + arguments: { toolNames: ['send_email'] }, + }, + toolName: 'execute_tool', + isFinished: true, + displayContext, + }); + + expect(message).toBe('Learned Send Email'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/__tests__/parse-crud-tool-name.util.test.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/__tests__/parse-crud-tool-name.util.test.ts new file mode 100644 index 0000000000..b232296557 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/__tests__/parse-crud-tool-name.util.test.ts @@ -0,0 +1,22 @@ +import { parseCrudToolName } from '@/ai/utils/tool-display/parse-crud-tool-name.util'; + +describe('parseCrudToolName', () => { + it('should parse create_one tools', () => { + expect(parseCrudToolName('create_one_company')).toEqual({ + operation: 'create_one', + objectSlug: 'company', + }); + }); + + it('should parse find_many tools', () => { + expect(parseCrudToolName('find_many_companies')).toEqual({ + operation: 'find_many', + objectSlug: 'companies', + }); + }); + + it('should return null for non-CRUD tools', () => { + expect(parseCrudToolName('send_email')).toBeNull(); + expect(parseCrudToolName('web_search')).toBeNull(); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/build-action-tool-status-message.util.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/build-action-tool-status-message.util.ts new file mode 100644 index 0000000000..3ac83f50f7 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/build-action-tool-status-message.util.ts @@ -0,0 +1,30 @@ +import { i18n } from '@lingui/core'; +import { isDefined } from 'twenty-shared/utils'; + +import { ACTION_TOOL_STATUS_LABELS } from '@/ai/constants/action-tool-status-labels.constant'; +import { buildGenericToolStatusMessage } from '@/ai/utils/tool-display/build-generic-tool-status-message.util'; +import { pickStatusLabel } from '@/ai/utils/tool-display/pick-status-label.util'; +import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type'; + +export const buildActionToolStatusMessage = ({ + toolName, + isFinished, + displayContext, +}: { + toolName: string; + isFinished: boolean; + displayContext: ToolDisplayContext; +}): string => { + const label = displayContext.labelByName.get(toolName) ?? toolName; + const statusLabels = ACTION_TOOL_STATUS_LABELS[toolName]; + + if (isDefined(statusLabels)) { + return pickStatusLabel({ + isFinished, + loadingLabel: i18n._(statusLabels.loading), + completedLabel: i18n._(statusLabels.completed), + }); + } + + return buildGenericToolStatusMessage({ label, isFinished }); +}; diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/build-crud-tool-status-message.util.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/build-crud-tool-status-message.util.ts new file mode 100644 index 0000000000..b153a208b4 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/build-crud-tool-status-message.util.ts @@ -0,0 +1,43 @@ +import { i18n } from '@lingui/core'; + +import { CRUD_TOOL_OPERATION_VERBS } from '@/ai/constants/crud-tool-operation-verbs.constant'; +import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type'; +import { getObjectLabelForCrudOperation } from '@/ai/utils/tool-display/get-object-label-for-crud-operation.util'; +import { parseCrudToolName } from '@/ai/utils/tool-display/parse-crud-tool-name.util'; +import { pickStatusLabel } from '@/ai/utils/tool-display/pick-status-label.util'; +import { isDefined } from 'twenty-shared/utils'; + +export const buildCrudToolStatusMessage = ({ + toolName, + isFinished, + displayContext, +}: { + toolName: string; + isFinished: boolean; + displayContext: ToolDisplayContext; +}): string | null => { + const parsedCrudToolName = parseCrudToolName(toolName); + + if (!parsedCrudToolName) { + return null; + } + + const indexEntry = displayContext.indexByName.get(toolName); + const objectLabel = getObjectLabelForCrudOperation({ + operation: parsedCrudToolName.operation, + objectName: indexEntry?.objectName, + objectSlug: parsedCrudToolName.objectSlug, + objectMetadataItems: displayContext.objectMetadataItems, + }); + const verbs = CRUD_TOOL_OPERATION_VERBS[parsedCrudToolName.operation]; + + if (!isDefined(objectLabel)) { + return null; + } + + return pickStatusLabel({ + isFinished, + loadingLabel: i18n._({ ...verbs.loading, values: { objectLabel } }), + completedLabel: i18n._({ ...verbs.completed, values: { objectLabel } }), + }); +}; diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/build-generic-tool-status-message.util.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/build-generic-tool-status-message.util.ts new file mode 100644 index 0000000000..13ebe21a7d --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/build-generic-tool-status-message.util.ts @@ -0,0 +1,16 @@ +import { t } from '@lingui/core/macro'; + +import { pickStatusLabel } from '@/ai/utils/tool-display/pick-status-label.util'; + +export const buildGenericToolStatusMessage = ({ + label, + isFinished, +}: { + label: string; + isFinished: boolean; +}): string => + pickStatusLabel({ + isFinished, + loadingLabel: t`Running ${label}`, + completedLabel: t`Ran ${label}`, + }); diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/build-named-items-status-message.util.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/build-named-items-status-message.util.ts new file mode 100644 index 0000000000..4f6ff8012e --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/build-named-items-status-message.util.ts @@ -0,0 +1,48 @@ +import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type'; +import { formatDisplayList } from '@/ai/utils/tool-display/format-display-list.util'; +import { getInnerToolName } from '@/ai/utils/tool-display/get-inner-tool-name.util'; +import { pickStatusLabel } from '@/ai/utils/tool-display/pick-status-label.util'; + +export const buildNamedItemsStatusMessage = ({ + names, + isFinished, + displayContext, + output, + loadingLabel, + completedLabel, + loadingFallback, + completedFallback, +}: { + names: string[]; + isFinished: boolean; + displayContext: ToolDisplayContext; + output?: unknown; + loadingLabel: (formattedNames: string) => string; + completedLabel: (formattedNames: string) => string; + loadingFallback: string; + completedFallback: string; +}): string => { + if (names.length === 0) { + return pickStatusLabel({ + isFinished, + loadingLabel: loadingFallback, + completedLabel: completedFallback, + }); + } + + const labels = names.map((name) => + getInnerToolName({ + toolName: name, + labelByName: displayContext.labelByName, + output, + }), + ); + + const formattedNames = formatDisplayList(labels); + + return pickStatusLabel({ + isFinished, + loadingLabel: loadingLabel(formattedNames), + completedLabel: completedLabel(formattedNames), + }); +}; diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/build-tool-status-message-by-category.util.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/build-tool-status-message-by-category.util.ts new file mode 100644 index 0000000000..f0e06a765a --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/build-tool-status-message-by-category.util.ts @@ -0,0 +1,48 @@ +import { ToolCategory } from 'twenty-shared/ai'; +import { isDefined } from 'twenty-shared/utils'; + +import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type'; +import { buildActionToolStatusMessage } from '@/ai/utils/tool-display/build-action-tool-status-message.util'; +import { buildCrudToolStatusMessage } from '@/ai/utils/tool-display/build-crud-tool-status-message.util'; +import { buildGenericToolStatusMessage } from '@/ai/utils/tool-display/build-generic-tool-status-message.util'; +import { parseCrudToolName } from '@/ai/utils/tool-display/parse-crud-tool-name.util'; + +export const buildToolStatusMessageByCategory = ({ + toolName, + isFinished, + displayContext, +}: { + toolName: string; + isFinished: boolean; + displayContext: ToolDisplayContext; +}): string => { + const indexEntry = displayContext.indexByName.get(toolName); + const category = indexEntry?.category; + const isCrudTool = + category === ToolCategory.DATABASE_CRUD || + isDefined(parseCrudToolName(toolName)); + + if (isCrudTool) { + const crudMessage = buildCrudToolStatusMessage({ + toolName, + isFinished, + displayContext, + }); + + if (isDefined(crudMessage)) { + return crudMessage; + } + } + + if (category === ToolCategory.ACTION) { + return buildActionToolStatusMessage({ + toolName, + isFinished, + displayContext, + }); + } + + const label = displayContext.labelByName.get(toolName) ?? toolName; + + return buildGenericToolStatusMessage({ label, isFinished }); +}; diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/extract-search-query.util.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/extract-search-query.util.ts new file mode 100644 index 0000000000..129e9fd0c5 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/extract-search-query.util.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +import { type ToolInput } from '@/ai/types/ToolInput'; + +const DirectQuerySchema = z.object({ query: z.string() }); +const NestedQuerySchema = z.object({ + action: z.object({ query: z.string() }), +}); + +export const extractSearchQuery = (input: ToolInput): string | null => { + const direct = DirectQuerySchema.safeParse(input); + + if (direct.success) { + return direct.data.query; + } + + const nested = NestedQuerySchema.safeParse(input); + + if (nested.success) { + return nested.data.action.query; + } + + return null; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/format-display-list.util.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/format-display-list.util.ts new file mode 100644 index 0000000000..529abe4020 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/format-display-list.util.ts @@ -0,0 +1,7 @@ +import { i18n } from '@lingui/core'; + +export const formatDisplayList = (items: string[]): string => + new Intl.ListFormat(i18n.locale, { + style: 'long', + type: 'conjunction', + }).format(items); diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/get-inner-tool-name.util.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/get-inner-tool-name.util.ts new file mode 100644 index 0000000000..e8abcf0990 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/get-inner-tool-name.util.ts @@ -0,0 +1,24 @@ +import { getToolOutputLabelEntries } from '@/ai/utils/getToolOutputLabelEntries'; +import { isDefined } from 'twenty-shared/utils'; + +export const getInnerToolName = ({ + toolName, + labelByName, + output, +}: { + toolName: string; + labelByName: Map; + output?: unknown; +}): string => { + const indexLabel = labelByName.get(toolName); + + if (isDefined(indexLabel)) { + return indexLabel; + } + + const outputEntry = getToolOutputLabelEntries(output).find( + (entry) => entry.name === toolName, + ); + + return outputEntry?.label ?? toolName; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/get-object-label-for-crud-operation.util.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/get-object-label-for-crud-operation.util.ts new file mode 100644 index 0000000000..219f7b8038 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/get-object-label-for-crud-operation.util.ts @@ -0,0 +1,38 @@ +import { i18n } from '@lingui/core'; + +import { type CrudToolOperation } from '@/ai/constants/crud-tool-operation-verbs.constant'; +import { isCrudPluralOperation } from '@/ai/utils/tool-display/is-crud-plural-operation.util'; +import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { isDefined } from 'twenty-shared/utils'; + +export const getObjectLabelForCrudOperation = ({ + operation, + objectName, + objectSlug, + objectMetadataItems, +}: { + operation: CrudToolOperation; + objectName?: string | null; + objectSlug: string; + objectMetadataItems: EnrichedObjectMetadataItem[]; +}): string | undefined => { + const objectMetadata = isDefined(objectName) + ? objectMetadataItems.find( + (metadataItem) => metadataItem.nameSingular === objectName, + ) + : objectMetadataItems.find( + (metadataItem) => + metadataItem.nameSingular === objectSlug || + metadataItem.namePlural === objectSlug, + ); + + if (!isDefined(objectMetadata)) { + return undefined; + } + + const objectLabel = isCrudPluralOperation(operation) + ? objectMetadata.labelPlural + : objectMetadata.labelSingular; + + return objectLabel.toLocaleLowerCase(i18n.locale); +}; diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/get-tool-display-message.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/get-tool-display-message.ts new file mode 100644 index 0000000000..ac660f988d --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/get-tool-display-message.ts @@ -0,0 +1,135 @@ +import { t } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { z } from 'zod'; + +import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type'; +import { type ToolInput } from '@/ai/types/ToolInput'; +import { buildNamedItemsStatusMessage } from '@/ai/utils/tool-display/build-named-items-status-message.util'; +import { buildToolStatusMessageByCategory } from '@/ai/utils/tool-display/build-tool-status-message-by-category.util'; +import { extractSearchQuery } from '@/ai/utils/tool-display/extract-search-query.util'; +import { pickStatusLabel } from '@/ai/utils/tool-display/pick-status-label.util'; +import { unwrapToolInput } from '@/ai/utils/tool-display/unwrap-tool-input.util'; + +const ModelGeneratedLabelSchema = z.object({ + loadingMessage: z.string(), + completedMessage: z.string().optional(), +}); +const LearnToolsSchema = z.object({ toolNames: z.array(z.string()) }); +const LoadSkillsSchema = z.object({ skillNames: z.array(z.string()) }); + +export const getToolDisplayMessage = ({ + input, + toolName, + isFinished, + displayContext, + output, +}: { + input: ToolInput; + toolName: string; + isFinished: boolean; + displayContext: ToolDisplayContext; + output?: unknown; +}): string => { + const { toolInput, toolName: resolvedToolName } = unwrapToolInput({ + input, + toolName, + }); + + return buildToolDisplayMessage({ + input: toolInput, + toolName: resolvedToolName, + isFinished, + displayContext, + output, + }); +}; + +const buildToolDisplayMessage = ({ + input, + toolName, + isFinished, + displayContext, + output, +}: { + input: ToolInput; + toolName: string; + isFinished: boolean; + displayContext: ToolDisplayContext; + output?: unknown; +}): string => { + switch (toolName) { + case 'web_search': + case 'app_exa_web_search': { + const query = extractSearchQuery(input); + + if (isNonEmptyString(query)) { + return pickStatusLabel({ + isFinished, + completedLabel: t`Searched the web for ${query}`, + loadingLabel: t`Searching the web for ${query}`, + }); + } + + return pickStatusLabel({ + isFinished, + completedLabel: t`Searched the web`, + loadingLabel: t`Searching the web`, + }); + } + case 'learn_tools': { + const parsed = LearnToolsSchema.safeParse(input); + + return buildNamedItemsStatusMessage({ + names: parsed.success ? parsed.data.toolNames : [], + isFinished, + displayContext, + output, + loadingLabel: (formattedNames) => t`Learning ${formattedNames}`, + completedLabel: (formattedNames) => t`Learned ${formattedNames}`, + loadingFallback: t`Learning tools...`, + completedFallback: t`Learned tools`, + }); + } + case 'load_skills': { + const parsed = LoadSkillsSchema.safeParse(input); + + return buildNamedItemsStatusMessage({ + names: parsed.success ? parsed.data.skillNames : [], + isFinished, + displayContext, + output, + loadingLabel: (formattedNames) => t`Loading ${formattedNames}`, + completedLabel: (formattedNames) => t`Loaded ${formattedNames}`, + loadingFallback: t`Loading skills...`, + completedFallback: t`Loaded skills`, + }); + } + case 'code_interpreter': { + const parsed = ModelGeneratedLabelSchema.safeParse(input); + + if (parsed.success && isNonEmptyString(parsed.data.loadingMessage)) { + const completedMessage = isNonEmptyString(parsed.data.completedMessage) + ? parsed.data.completedMessage + : parsed.data.loadingMessage; + + return pickStatusLabel({ + isFinished, + completedLabel: completedMessage, + loadingLabel: parsed.data.loadingMessage, + }); + } + + return pickStatusLabel({ + isFinished, + completedLabel: t`Ran code`, + loadingLabel: t`Running code`, + }); + } + default: + return buildToolStatusMessageByCategory({ + toolName, + isFinished, + displayContext, + }); + } +}; diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/is-crud-plural-operation.util.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/is-crud-plural-operation.util.ts new file mode 100644 index 0000000000..57b802b096 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/is-crud-plural-operation.util.ts @@ -0,0 +1,4 @@ +import { type CrudToolOperation } from '@/ai/constants/crud-tool-operation-verbs.constant'; + +export const isCrudPluralOperation = (operation: CrudToolOperation): boolean => + operation.endsWith('_many') || operation === 'group_by'; diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/parse-crud-tool-name.util.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/parse-crud-tool-name.util.ts new file mode 100644 index 0000000000..cd6e552b28 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/parse-crud-tool-name.util.ts @@ -0,0 +1,22 @@ +import { type CrudToolOperation } from '@/ai/constants/crud-tool-operation-verbs.constant'; +import { DATABASE_CRUD_OPERATIONS } from 'twenty-shared/ai'; + +export const parseCrudToolName = ( + toolName: string, +): { + operation: CrudToolOperation; + objectSlug: string; +} | null => { + for (const operation of DATABASE_CRUD_OPERATIONS) { + const prefix = `${operation}_`; + + if (toolName.startsWith(prefix)) { + return { + operation, + objectSlug: toolName.slice(prefix.length), + }; + } + } + + return null; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/pick-status-label.util.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/pick-status-label.util.ts new file mode 100644 index 0000000000..1e6336ad3c --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/pick-status-label.util.ts @@ -0,0 +1,9 @@ +export const pickStatusLabel = ({ + isFinished, + loadingLabel, + completedLabel, +}: { + isFinished: boolean; + loadingLabel: string; + completedLabel: string; +}): string => (isFinished ? completedLabel : loadingLabel); diff --git a/packages/twenty-front/src/modules/ai/utils/tool-display/unwrap-tool-input.util.ts b/packages/twenty-front/src/modules/ai/utils/tool-display/unwrap-tool-input.util.ts new file mode 100644 index 0000000000..05252a9c1d --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/tool-display/unwrap-tool-input.util.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +import { type ToolInput } from '@/ai/types/ToolInput'; + +const ExecuteToolSchema = z.object({ + toolName: z.coerce.string(), + arguments: z.unknown(), +}); + +export const unwrapToolInput = ({ + input, + toolName, +}: { + input: ToolInput; + toolName: string; +}): { + toolInput: ToolInput; + toolName: string; +} => { + if (toolName !== 'execute_tool') { + return { toolInput: input, toolName }; + } + + const parsed = ExecuteToolSchema.safeParse(input); + + if (!parsed.success) { + return { toolInput: input, toolName }; + } + + return { + toolInput: parsed.data.arguments as ToolInput, + toolName: parsed.data.toolName, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsToolCallRow.tsx b/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsToolCallRow.tsx index 2a9183e408..e07a78bb23 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsToolCallRow.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-run/observability/WorkflowRunStepLogsToolCallRow.tsx @@ -2,7 +2,8 @@ import { styled } from '@linaria/react'; import { useContext, useState } from 'react'; import { type AiToolCallLog } from 'twenty-shared/workflow'; -import { getToolDisplayMessage } from '@/ai/utils/getToolDisplayMessage'; +import { useToolDisplayContext } from '@/ai/hooks/useToolDisplayContext'; +import { getToolDisplayMessage } from '@/ai/utils/tool-display/get-tool-display-message'; import { getToolIcon } from '@/ai/utils/getToolIcon'; import { useLingui } from '@lingui/react/macro'; import { type JsonValue } from 'type-fest'; @@ -160,11 +161,14 @@ export const WorkflowRunStepLogsToolCallRow = ({ ? themeCssVariables.color.red : themeCssVariables.color.green; - const displayMessage = getToolDisplayMessage( - toolCall.input ?? {}, - toolCall.toolName, - true, - ); + const displayContext = useToolDisplayContext(); + const displayMessage = getToolDisplayMessage({ + input: toolCall.input ?? {}, + toolName: toolCall.toolName, + isFinished: true, + displayContext, + output: toolCall.output, + }); return ( diff --git a/packages/twenty-front/src/pages/settings/ai/SettingsToolDetail.tsx b/packages/twenty-front/src/pages/settings/ai/SettingsToolDetail.tsx index cecc20ae33..960370d915 100644 --- a/packages/twenty-front/src/pages/settings/ai/SettingsToolDetail.tsx +++ b/packages/twenty-front/src/pages/settings/ai/SettingsToolDetail.tsx @@ -87,7 +87,9 @@ export const SettingsToolDetail = () => { const isReadOnly = !isCustomTool || isManaged; - const name = isCustomTool ? logicFunction?.name : toolIdentifier; + const displayName = isCustomTool + ? logicFunction?.name + : (systemTool?.label ?? toolIdentifier); const description = isCustomTool ? logicFunction?.description : systemTool?.description; @@ -122,9 +124,9 @@ export const SettingsToolDetail = () => { } }, 1_000); - const handleNameChange = (value: string) => { - setEditedName(value); - debouncedSaveName(value); + const handleNameChange = (newName: string) => { + setEditedName(newName); + debouncedSaveName(newName); }; const debouncedSaveDescription = useDebouncedCallback( @@ -174,11 +176,11 @@ export const SettingsToolDetail = () => { title={ isCustomTool ? ( ) : ( - (name ?? '') + (displayName ?? '') ) } links={[ @@ -190,7 +192,7 @@ export const SettingsToolDetail = () => { children: t`AI`, href: getSettingsPath(SettingsPath.AI, undefined, undefined, 'tools'), }, - { children: editedName ?? name ?? '' }, + { children: editedName ?? displayName ?? '' }, ]} > diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTab.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTab.tsx index 9b85dfd787..06a5a907fd 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTab.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTab.tsx @@ -50,7 +50,9 @@ export const SettingsAgentToolsTab = () => { const searchNormalized = normalizeSearchText(searchTerm); const matchesSearch = - normalizeSearchText(tool.name).includes(searchNormalized) || + normalizeSearchText(tool.label ?? tool.name).includes( + searchNormalized, + ) || normalizeSearchText(tool.description ?? '').includes(searchNormalized); if (!matchesSearch) { @@ -67,7 +69,7 @@ export const SettingsAgentToolsTab = () => { return showCustomTools; }) - .sort((a, b) => a.name.localeCompare(b.name)); + .sort((a, b) => (a.label ?? a.name).localeCompare(b.label ?? b.name)); return (
diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTable.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTable.tsx index 11be78213c..9c031254d8 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTable.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentToolsTable.tsx @@ -88,7 +88,7 @@ export const SettingsAgentToolsTable = ({ marketplaceApp={marketplaceApp} /> } - name={tool.name} + name={tool.label ?? tool.name} applicationId={getToolApplicationId(tool, currentWorkspace)} action={ { .map((tool) => ({ identifier: tool.name, name: tool.name, + label: tool.label, description: tool.description, category: tool.category, objectName: tool.objectName, diff --git a/packages/twenty-front/src/pages/settings/ai/types/SettingsAgentToolItem.ts b/packages/twenty-front/src/pages/settings/ai/types/SettingsAgentToolItem.ts index e77b3a3191..9ad33e9d01 100644 --- a/packages/twenty-front/src/pages/settings/ai/types/SettingsAgentToolItem.ts +++ b/packages/twenty-front/src/pages/settings/ai/types/SettingsAgentToolItem.ts @@ -1,6 +1,7 @@ export type SettingsAgentToolItem = { identifier: string; name: string; + label?: string; description?: string | null; category?: string; objectName?: string | null; diff --git a/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts b/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts index 875852ea40..1f6e22ebc3 100644 --- a/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts +++ b/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts @@ -164,7 +164,6 @@ export class McpProtocolService { const preloadedTools = await this.toolRegistry.getToolsByName( COMMON_PRELOAD_TOOLS, toolContext, - { includeLoadingMessage: false }, ); return { diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts new file mode 100644 index 0000000000..0bed0a1502 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts @@ -0,0 +1,36 @@ +import { msg } from '@lingui/core/macro'; + +import { type ActionToolLabel } from 'src/engine/core-modules/tool-provider/types/action-tool-label.type'; +import { i18nLabel } from 'src/engine/workspace-manager/twenty-standard-application/utils/i18n-label.util'; + +export const ACTION_TOOL_IDS = [ + 'http_request', + 'send_email', + 'draft_email', + 'search_help_center', + 'code_interpreter', + 'navigate_app', +] as const; + +export type ActionToolId = (typeof ACTION_TOOL_IDS)[number]; + +export const ACTION_TOOL_LABELS: Record = { + http_request: { + label: i18nLabel(msg`HTTP Request`), + }, + send_email: { + label: i18nLabel(msg`Send Email`), + }, + draft_email: { + label: i18nLabel(msg`Draft Email`), + }, + search_help_center: { + label: i18nLabel(msg`Search Help Center`), + }, + code_interpreter: { + label: i18nLabel(msg`Code Interpreter`), + }, + navigate_app: { + label: i18nLabel(msg`Navigate App`), + }, +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/constants/database-crud-operation.const.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/constants/database-crud-operation.const.ts index 6e2e516727..0a4ef8e6b2 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/constants/database-crud-operation.const.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/constants/database-crud-operation.const.ts @@ -1,14 +1,4 @@ -export const DATABASE_CRUD_OPERATIONS = [ - 'find_many', - 'find_one', - 'create_one', - 'create_many', - 'update_one', - 'update_many', - 'upsert_many', - 'delete_one', - 'delete_many', - 'group_by', -] as const; - -export type DatabaseCrudOperation = (typeof DATABASE_CRUD_OPERATIONS)[number]; +export { + DATABASE_CRUD_OPERATIONS, + type DatabaseCrudOperation, +} from 'twenty-shared/ai'; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type.ts index 11eab6e673..720faa20b3 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type.ts @@ -1,4 +1,5 @@ import { type ActorMetadata } from 'twenty-shared/types'; +import { type APP_LOCALES } from 'twenty-shared/translations'; import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/code-execution-stream-emitter.type'; @@ -13,5 +14,6 @@ export type ToolProviderContext = { userId?: string; userWorkspaceId?: string; threadId?: string; + locale?: keyof typeof APP_LOCALES; onCodeExecutionUpdate?: CodeExecutionStreamEmitter; }; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-retrieval-options.type.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-retrieval-options.type.ts index e20bb0b3f2..a6639fbf44 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-retrieval-options.type.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-retrieval-options.type.ts @@ -4,7 +4,6 @@ export type ToolRetrievalOptions = { categories?: ToolCategory[]; excludeTools?: string[]; wrapWithErrorContext?: boolean; - includeLoadingMessage?: boolean; // Apply output compaction (strip nulls/empty values) to dispatch results // before returning. Chat enables this to reduce token usage in the // conversation context; MCP and workflow agents leave raw output intact. diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/__tests__/database-tool.provider.spec.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/__tests__/database-tool.provider.spec.ts index ef2578fa8c..7695c23a00 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/__tests__/database-tool.provider.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/__tests__/database-tool.provider.spec.ts @@ -1,7 +1,9 @@ import { type ObjectPermissions } from 'twenty-shared/types'; +import { type I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider'; import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; +import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type'; import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; import { type WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; @@ -34,7 +36,7 @@ const createFlatObject = ( }); describe('DatabaseToolProvider', () => { - const generateDescriptorNames = async (objects: FlatObjectMetadata[]) => { + const generateDescriptors = async (objects: FlatObjectMetadata[]) => { const flatObjectMetadataMaps = createEmptyFlatEntityMaps() as FlatEntityMaps; @@ -62,19 +64,39 @@ describe('DatabaseToolProvider', () => { }), } as unknown as WorkspaceManyOrAllFlatEntityMapsCacheService; + // Returns the messageId so the label util falls back to the English source, + // mirroring the runtime behavior when no translation exists for the locale. + // getI18nInstance resolves verb descriptors to their English source message. + const i18nService = { + translateMessage: jest.fn( + ({ messageId }: { messageId: string }) => messageId, + ), + getI18nInstance: jest.fn(() => ({ + _: (descriptor: string | { id: string; message?: string }) => + typeof descriptor === 'string' + ? descriptor + : (descriptor.message ?? descriptor.id), + })), + } as unknown as I18nService; + const provider = new DatabaseToolProvider( workspaceCacheService, flatEntityMapsCacheService, + i18nService, ); - const descriptors = (await provider.generateDescriptors( + return (await provider.generateDescriptors( { workspaceId, roleId, rolePermissionConfig: { unionOf: [roleId] }, }, { includeSchemas: false }, - )) as ToolDescriptor[]; + )) as (ToolIndexEntry | ToolDescriptor)[]; + }; + + const generateDescriptorNames = async (objects: FlatObjectMetadata[]) => { + const descriptors = await generateDescriptors(objects); return descriptors.map((descriptor) => descriptor.name); }; @@ -171,4 +193,62 @@ describe('DatabaseToolProvider', () => { ]), ); }); + + it('generates labels from operation verb and object metadata labels', async () => { + const descriptors = await generateDescriptors([ + createFlatObject({ + nameSingular: 'company', + namePlural: 'companies', + labelSingular: 'Company', + labelPlural: 'Companies', + }), + ]); + + const labelByName = new Map(descriptors.map((d) => [d.name, d.label])); + + expect(labelByName.get('find_many_companies')).toBe('Search companies'); + expect(labelByName.get('find_one_company')).toBe('Find company'); + expect(labelByName.get('group_by_companies')).toBe('Group companies'); + expect(labelByName.get('create_one_company')).toBe('Create company'); + expect(labelByName.get('create_many_companies')).toBe('Create companies'); + expect(labelByName.get('update_one_company')).toBe('Update company'); + expect(labelByName.get('update_many_companies')).toBe('Update companies'); + expect(labelByName.get('upsert_many_companies')).toBe('Upsert companies'); + expect(labelByName.get('delete_one_company')).toBe('Delete company'); + expect(labelByName.get('delete_many_companies')).toBe('Delete companies'); + }); + + it('uses the object labelSingular/labelPlural from metadata, not the programmatic name', async () => { + const descriptors = await generateDescriptors([ + createFlatObject({ + nameSingular: 'person', + namePlural: 'people', + labelSingular: 'Contact', + labelPlural: 'Contacts', + }), + ]); + + const labelByName = new Map(descriptors.map((d) => [d.name, d.label])); + + expect(labelByName.get('find_many_people')).toBe('Search contacts'); + expect(labelByName.get('find_one_person')).toBe('Find contact'); + expect(labelByName.get('create_one_person')).toBe('Create contact'); + expect(labelByName.get('delete_one_person')).toBe('Delete contact'); + }); + + it('includes label on every generated descriptor', async () => { + const descriptors = await generateDescriptors([ + createFlatObject({ + nameSingular: 'task', + namePlural: 'tasks', + labelSingular: 'Task', + labelPlural: 'Tasks', + }), + ]); + + for (const descriptor of descriptors) { + expect(descriptor.label).toBeDefined(); + expect(descriptor.label.length).toBeGreaterThan(0); + } + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts index a1f5f769b4..521d090e6e 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts @@ -1,11 +1,20 @@ import { Injectable } from '@nestjs/common'; import { PermissionFlagType } from 'twenty-shared/constants'; +import { isDefined } from 'twenty-shared/utils'; import { z } from 'zod'; +import { + ACTION_TOOL_LABELS, + type ActionToolId, +} from 'src/engine/core-modules/tool-provider/constants/action-tool-label.constant'; +import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type'; import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; +import { type ActionToolLabel } from 'src/engine/core-modules/tool-provider/types/action-tool-label.type'; +import { translateToolLabel } from 'src/engine/core-modules/tool-provider/utils/translate-tool-label.util'; +import { humanizeToolName } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util'; import { ToolCategory } from 'twenty-shared/ai'; import { toToolJsonSchema } from 'src/engine/core-modules/record-crud/utils/to-tool-json-schema.util'; @@ -41,6 +50,7 @@ export class ActionToolProvider implements ToolProvider { private readonly searchOutputTool: SearchOutputTool, private readonly codeInterpreterService: CodeInterpreterService, private readonly permissionsService: PermissionsService, + private readonly i18nService: I18nService, ) { this.toolMap = new Map([ ['http_request', this.httpTool], @@ -73,7 +83,12 @@ export class ActionToolProvider implements ToolProvider { if (hasHttpPermission) { descriptors.push( - this.buildDescriptor('http_request', this.httpTool, includeSchemas), + this.buildDescriptor( + 'http_request', + this.httpTool, + includeSchemas, + context.locale, + ), ); } @@ -85,13 +100,19 @@ export class ActionToolProvider implements ToolProvider { if (hasEmailPermission) { descriptors.push( - this.buildDescriptor('send_email', this.sendEmailTool, includeSchemas), + this.buildDescriptor( + 'send_email', + this.sendEmailTool, + includeSchemas, + context.locale, + ), ); descriptors.push( this.buildDescriptor( 'draft_email', this.draftEmailTool, includeSchemas, + context.locale, ), ); } @@ -101,6 +122,7 @@ export class ActionToolProvider implements ToolProvider { 'search_help_center', this.searchHelpCenterTool, includeSchemas, + context.locale, ), ); @@ -109,6 +131,7 @@ export class ActionToolProvider implements ToolProvider { 'navigate_app', this.navigateAppTool, includeSchemas, + context.locale, ), ); @@ -142,6 +165,7 @@ export class ActionToolProvider implements ToolProvider { 'code_interpreter', this.codeInterpreterTool, includeSchemas, + context.locale, ), ); } @@ -175,9 +199,16 @@ export class ActionToolProvider implements ToolProvider { toolId: string, tool: Tool, includeSchemas: boolean, + locale?: ToolProviderContext['locale'], ): ToolIndexEntry | ToolDescriptor { + const labels: ActionToolLabel | undefined = + ACTION_TOOL_LABELS[toolId as ActionToolId]; + return { name: toolId, + label: isDefined(labels) + ? translateToolLabel(labels.label, this.i18nService, locale) + : humanizeToolName(toolId), description: tool.description, category: ToolCategory.ACTION, icon: 'IconPlayerPlay', diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts index 97c6c9391d..6422372623 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts @@ -7,9 +7,11 @@ import { import { camelToSnakeCase, isDefined } from 'twenty-shared/utils'; import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow'; +import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type'; import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; +import { getCrudToolLabels } from 'src/engine/core-modules/tool-provider/utils/get-crud-tool-label.util'; import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util'; import { generateCreateManyRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-many-record-input-schema.util'; @@ -41,6 +43,7 @@ export class DatabaseToolProvider implements ToolProvider { constructor( private readonly workspaceCacheService: WorkspaceCacheService, private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly i18nService: I18nService, ) {} async isAvailable(_context: ToolProviderContext): Promise { @@ -129,6 +132,12 @@ export class DatabaseToolProvider implements ToolProvider { if (permission.canReadObjectRecords) { descriptors.push({ name: `find_many_${snakePlural}`, + ...getCrudToolLabels( + 'find_many', + flatObject.labelPlural, + this.i18nService, + context.locale, + ), description: `Search for ${objectMetadata.labelPlural} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. Filter fields are top-level arguments — pass each field as its own key (e.g. { id: { eq: "record-id" } }, or { name: { firstName: { ilike: "%ada%" } } }); do NOT wrap them in a "filter" object and do NOT place a bare operator like "ilike"/"eq" at the top level. Combine conditions with and/or/not. Returns an array of matching records with their full data.`, category: ToolCategory.DATABASE_CRUD, ...(shouldIncludeSchema(`find_many_${snakePlural}`) && { @@ -148,6 +157,12 @@ export class DatabaseToolProvider implements ToolProvider { descriptors.push({ name: `find_one_${snakeSingular}`, + ...getCrudToolLabels( + 'find_one', + flatObject.labelSingular, + this.i18nService, + context.locale, + ), description: `Retrieve a single ${objectMetadata.labelSingular} by ID.`, category: ToolCategory.DATABASE_CRUD, ...(shouldIncludeSchema(`find_one_${snakeSingular}`) && { @@ -177,6 +192,12 @@ export class DatabaseToolProvider implements ToolProvider { if (hasGroupBySchema) { descriptors.push({ name: groupByName, + ...getCrudToolLabels( + 'group_by', + flatObject.labelPlural, + this.i18nService, + context.locale, + ), description: `Group ${objectMetadata.labelPlural} records by one or two fields and compute an aggregate (COUNT, SUM, AVG, MIN, MAX, etc.). Use for questions like "how many deals per stage?" or "total revenue by company". Returns groups with dimension values and aggregate results, ordered by the aggregate value.`, category: ToolCategory.DATABASE_CRUD, ...(shouldGenerateGroupBy && @@ -198,6 +219,12 @@ export class DatabaseToolProvider implements ToolProvider { if (permission.canUpdateObjectRecords && canBeManagedByAutomation) { descriptors.push({ name: `create_one_${snakeSingular}`, + ...getCrudToolLabels( + 'create_one', + flatObject.labelSingular, + this.i18nService, + context.locale, + ), description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`, category: ToolCategory.DATABASE_CRUD, ...(shouldIncludeSchema(`create_one_${snakeSingular}`) && { @@ -217,6 +244,12 @@ export class DatabaseToolProvider implements ToolProvider { descriptors.push({ name: `create_many_${snakePlural}`, + ...getCrudToolLabels( + 'create_many', + flatObject.labelPlural, + this.i18nService, + context.locale, + ), description: `Create multiple ${objectMetadata.labelPlural} records in a single call. Provide an array of records, each containing the required fields. Maximum 20 records per call. Returns the created records.`, category: ToolCategory.DATABASE_CRUD, ...(shouldIncludeSchema(`create_many_${snakePlural}`) && { @@ -239,6 +272,12 @@ export class DatabaseToolProvider implements ToolProvider { descriptors.push({ name: `update_one_${snakeSingular}`, + ...getCrudToolLabels( + 'update_one', + flatObject.labelSingular, + this.i18nService, + context.locale, + ), description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`, category: ToolCategory.DATABASE_CRUD, ...(shouldIncludeSchema(`update_one_${snakeSingular}`) && { @@ -258,6 +297,12 @@ export class DatabaseToolProvider implements ToolProvider { descriptors.push({ name: `update_many_${snakePlural}`, + ...getCrudToolLabels( + 'update_many', + flatObject.labelPlural, + this.i18nService, + context.locale, + ), description: `Apply the SAME field values to all ${objectMetadata.labelPlural} records matching a filter. Use when every matched record gets identical changes (e.g. bulk status change). For records that each have different data to update, use upsert_many_${snakePlural} instead. WARNING: Use specific filters to avoid unintended mass updates. Always verify the filter scope with a find query first.`, category: ToolCategory.DATABASE_CRUD, ...(shouldIncludeSchema(`update_many_${snakePlural}`) && { @@ -280,6 +325,12 @@ export class DatabaseToolProvider implements ToolProvider { descriptors.push({ name: `upsert_many_${snakePlural}`, + ...getCrudToolLabels( + 'upsert_many', + flatObject.labelPlural, + this.i18nService, + context.locale, + ), description: `Insert or update multiple ${objectMetadata.labelPlural} records in a single call, where each record has its own individual data. Use this instead of update_many_${snakePlural} when records need different field values. Existing records are matched by unique fields and updated; records with no match are created. Maximum 20 records per call. Returns the upserted records.`, category: ToolCategory.DATABASE_CRUD, ...(shouldIncludeSchema(`upsert_many_${snakePlural}`) && { @@ -304,6 +355,12 @@ export class DatabaseToolProvider implements ToolProvider { if (permission.canSoftDeleteObjectRecords) { descriptors.push({ name: `delete_one_${snakeSingular}`, + ...getCrudToolLabels( + 'delete_one', + flatObject.labelSingular, + this.i18nService, + context.locale, + ), description: `Delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record is hidden from normal queries. This is reversible. Use this to remove records.`, category: ToolCategory.DATABASE_CRUD, ...(includeSchemas && { @@ -321,6 +378,12 @@ export class DatabaseToolProvider implements ToolProvider { descriptors.push({ name: `delete_many_${snakePlural}`, + ...getCrudToolLabels( + 'delete_many', + flatObject.labelPlural, + this.i18nService, + context.locale, + ), description: `Soft-delete multiple ${objectMetadata.labelPlural} records matching a filter in a single operation. Deleted records are hidden from normal queries and the operation is reversible. WARNING: Use specific filters to avoid unintended mass deletions.`, category: ToolCategory.DATABASE_CRUD, ...(includeSchemas && { diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts index 5ebbaeca4f..669f05fcc0 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts @@ -77,6 +77,7 @@ export class LogicFunctionToolProvider implements ToolProvider { const base: ToolIndexEntry = { name: toolName, + label: logicFunction.name, description: logicFunction.description || `Execute the ${logicFunction.name} logic function`, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/resolvers/tool-index.resolver.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/resolvers/tool-index.resolver.ts index 7b28c22112..515dcfd27f 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/resolvers/tool-index.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/resolvers/tool-index.resolver.ts @@ -2,6 +2,7 @@ import { UseGuards } from '@nestjs/common'; import { Args, Field, ObjectType, Query } from '@nestjs/graphql'; import graphqlTypeJson from 'graphql-type-json'; +import { type APP_LOCALES } from 'twenty-shared/translations'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; @@ -10,6 +11,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { RequestLocale } from 'src/engine/decorators/locale/request-locale.decorator'; import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; @@ -19,6 +21,9 @@ export class ToolIndexEntryDTO { @Field() name: string; + @Field() + label: string; + @Field() description: string; @@ -49,6 +54,7 @@ export class ToolIndexResolver { @AuthUser({ allowUndefined: true }) user: UserEntity | undefined, @AuthWorkspace() workspace: WorkspaceEntity, @AuthUserWorkspaceId() userWorkspaceId: string, + @RequestLocale() locale: keyof typeof APP_LOCALES | undefined, ): Promise { const roleId = await this.userRoleService.getRoleIdForUserWorkspace({ userWorkspaceId, @@ -62,6 +68,7 @@ export class ToolIndexResolver { return this.toolRegistryService.buildToolIndex(workspace.id, roleId, { userId: user?.id, userWorkspaceId, + locale, }); } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts index 382515b24a..a569212605 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { type ToolSet, jsonSchema } from 'ai'; +import { type APP_LOCALES } from 'twenty-shared/translations'; import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; @@ -9,18 +10,14 @@ import { type ToolRetrievalOptions } from 'src/engine/core-modules/tool-provider import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token'; import { compactToolOutput } from 'src/engine/core-modules/tool-provider/output-transforms/compact-tool-output.util'; import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service'; -import { ToolOutputSpillService } from 'src/engine/core-modules/tool/services/tool-output-spill.service'; import { type LearnToolsAspect } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool'; import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type'; import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type'; import { findSimilarToolNames } from 'src/engine/core-modules/tool-provider/utils/find-similar-tool-names.util'; import { wrapWithErrorHandler } from 'src/engine/core-modules/tool-provider/utils/tool-error.util'; +import { ToolOutputSpillService } from 'src/engine/core-modules/tool/services/tool-output-spill.service'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; -import { - stripLoadingMessage, - wrapJsonSchemaForExecution, -} from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util'; import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; @Injectable() @@ -110,32 +107,23 @@ export class ToolRegistryService { context: ToolProviderContext, options?: { wrapWithErrorContext?: boolean; - includeLoadingMessage?: boolean; compactOutput?: boolean; spillLargeOutput?: boolean; }, ): ToolSet { const toolSet: ToolSet = {}; - const includeLoadingMessage = options?.includeLoadingMessage ?? true; const compactOutput = options?.compactOutput ?? false; const spillLargeOutput = options?.spillLargeOutput ?? false; for (const descriptor of descriptors) { - const baseSchema = descriptor.inputSchema as Record; - const schema = includeLoadingMessage - ? wrapJsonSchemaForExecution(baseSchema) - : baseSchema; + const schema = descriptor.inputSchema as Record; const executeFn = async ( args: Record, ): Promise => { - const cleanArgs = includeLoadingMessage - ? stripLoadingMessage(args ?? {}) - : (args ?? {}); - const result = await this.toolExecutorService.dispatch( descriptor, - cleanArgs, + args, context, ); @@ -167,13 +155,18 @@ export class ToolRegistryService { async buildToolIndex( workspaceId: string, roleId: string, - options?: { userId?: string; userWorkspaceId?: string }, + options?: { + userId?: string; + userWorkspaceId?: string; + locale?: keyof typeof APP_LOCALES; + }, ): Promise { const context = this.buildContextFromToolContext({ workspaceId, roleId, userId: options?.userId, userWorkspaceId: options?.userWorkspaceId, + locale: options?.locale, }); return this.getCatalog(context); @@ -183,7 +176,6 @@ export class ToolRegistryService { names: string[], context: ToolContext, options?: { - includeLoadingMessage?: boolean; compactOutput?: boolean; spillLargeOutput?: boolean; }, @@ -208,7 +200,6 @@ export class ToolRegistryService { })); return this.hydrateToolSet(descriptors, fullContext, { - includeLoadingMessage: options?.includeLoadingMessage, compactOutput: options?.compactOutput, spillLargeOutput: options?.spillLargeOutput, }); @@ -219,7 +210,11 @@ export class ToolRegistryService { context: ToolContext, aspects: LearnToolsAspect[] = ['description', 'schema'], ): Promise< - Array<{ name: string; description?: string; inputSchema?: object }> + Array<{ + name: string; + description?: string; + inputSchema?: object; + }> > { const fullContext = this.buildContextFromToolContext(context); @@ -351,7 +346,6 @@ export class ToolRegistryService { categories, excludeTools, wrapWithErrorContext, - includeLoadingMessage, compactOutput, spillLargeOutput, } = options; @@ -387,7 +381,6 @@ export class ToolRegistryService { const toolSet = this.hydrateToolSet(filteredDescriptors, context, { wrapWithErrorContext, - includeLoadingMessage, compactOutput, spillLargeOutput, }); @@ -414,6 +407,7 @@ export class ToolRegistryService { userId: context.userId, userWorkspaceId: context.userWorkspaceId, threadId: context.threadId, + locale: context.locale, onCodeExecutionUpdate: context.onCodeExecutionUpdate, }; } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/types/action-tool-label.type.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/types/action-tool-label.type.ts new file mode 100644 index 0000000000..426195b86f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/types/action-tool-label.type.ts @@ -0,0 +1,3 @@ +export type ActionToolLabel = { + label: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-context.type.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-context.type.ts index da88923e91..73664e7d64 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-context.type.ts @@ -1,4 +1,5 @@ import { type ActorMetadata } from 'twenty-shared/types'; +import { type APP_LOCALES } from 'twenty-shared/translations'; import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/code-execution-stream-emitter.type'; @@ -12,5 +13,6 @@ export type ToolContext = { userId?: string; userWorkspaceId?: string; threadId?: string; + locale?: keyof typeof APP_LOCALES; onCodeExecutionUpdate?: CodeExecutionStreamEmitter; }; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-index-entry.type.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-index-entry.type.ts index 3706b01295..4993bbc708 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-index-entry.type.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-index-entry.type.ts @@ -4,6 +4,7 @@ import { type ToolExecutionRef } from 'src/engine/core-modules/tool-provider/typ export type ToolIndexEntry = { name: string; + label: string; description: string; category: ToolCategory; executionRef: ToolExecutionRef; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/__tests__/tool-set-to-descriptors.util.spec.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/__tests__/tool-set-to-descriptors.util.spec.ts new file mode 100644 index 0000000000..10ed91ed71 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/__tests__/tool-set-to-descriptors.util.spec.ts @@ -0,0 +1,59 @@ +import { type ToolSet } from 'ai'; +import { z } from 'zod'; + +import { ToolCategory } from 'twenty-shared/ai'; + +import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util'; + +const createMockToolSet = ( + tools: Record, +): ToolSet => { + const toolSet: ToolSet = {}; + + for (const [name, def] of Object.entries(tools)) { + toolSet[name] = { + description: def.description, + inputSchema: def.inputSchema ?? z.object({}), + execute: async () => ({}), + }; + } + + return toolSet; +}; + +describe('toolSetToDescriptors', () => { + it('generates a humanized label when no labels map is provided', () => { + const toolSet = createMockToolSet({ + create_complete_workflow: { description: 'Create a workflow' }, + get_object_metadata: { description: 'Get object metadata' }, + }); + + const descriptors = toolSetToDescriptors(toolSet, ToolCategory.WORKFLOW, { + includeSchemas: false, + }); + + const labelByName = new Map(descriptors.map((d) => [d.name, d.label])); + + expect(labelByName.get('create_complete_workflow')).toBe( + 'Create Complete Workflow', + ); + expect(labelByName.get('get_object_metadata')).toBe('Get Object Metadata'); + }); + + it('includes label on every descriptor', () => { + const toolSet = createMockToolSet({ + tool_a: { description: 'A' }, + tool_b: { description: 'B' }, + tool_c: { description: 'C' }, + }); + + const descriptors = toolSetToDescriptors(toolSet, ToolCategory.ACTION, { + includeSchemas: false, + }); + + for (const descriptor of descriptors) { + expect(descriptor.label).toBeDefined(); + expect(descriptor.label.length).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/execute-tool-from-tool-set.util.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/execute-tool-from-tool-set.util.ts index 9018224c17..aee8a73063 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/execute-tool-from-tool-set.util.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/execute-tool-from-tool-set.util.ts @@ -7,10 +7,6 @@ import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output. // whose tools are produced as opaque AI-SDK ToolSet closures (view, metadata, // workflow, dashboard, view-field) and which therefore cannot dispatch by // executionRef alone. -// -// The factory closures expect a `loadingMessage` field (added by the chat UX -// wrapper) and a ToolExecutionOptions object; neither is meaningful when the -// executor is invoking them internally, so we pass empty defaults. export const executeToolFromToolSet = async ( toolSet: ToolSet, toolName: string, @@ -25,8 +21,8 @@ export const executeToolFromToolSet = async ( ); } - return tool.execute( - { loadingMessage: '', ...args }, - { toolCallId: '', messages: [] }, - ) as Promise; + return tool.execute(args, { + toolCallId: '', + messages: [], + }) as Promise; }; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/get-crud-tool-label.util.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/get-crud-tool-label.util.ts new file mode 100644 index 0000000000..7d47c80adb --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/get-crud-tool-label.util.ts @@ -0,0 +1,44 @@ +import { type MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; + +import { type I18nService } from 'src/engine/core-modules/i18n/i18n.service'; +import { type DatabaseCrudOperation } from 'src/engine/core-modules/tool-provider/constants/database-crud-operation.const'; +import { translateToolLabel } from 'src/engine/core-modules/tool-provider/utils/translate-tool-label.util'; + +const OPERATION_VERBS: Record = { + find_many: msg`Search`, + find_one: msg`Find`, + group_by: msg`Group`, + create_one: msg`Create`, + create_many: msg`Create`, + update_one: msg`Update`, + update_many: msg`Update`, + upsert_many: msg`Upsert`, + delete_one: msg`Delete`, + delete_many: msg`Delete`, +}; + +type CrudToolLabel = { + label: string; +}; + +export const getCrudToolLabels = ( + operation: DatabaseCrudOperation, + objectLabel: string, + i18nService: I18nService, + locale?: keyof typeof APP_LOCALES, +): CrudToolLabel => { + const i18n = i18nService.getI18nInstance(locale ?? SOURCE_LOCALE); + const verb = OPERATION_VERBS[operation]; + + const object = translateToolLabel( + objectLabel, + i18nService, + locale, + ).toLocaleLowerCase(locale); + + return { + label: `${i18n._(verb)} ${object}`, + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util.ts index b8c05ea10b..9fb217694c 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util.ts @@ -11,9 +11,13 @@ export type ToolSetToDescriptorsOptions = { icon?: string; }; -// Converts a ToolSet (with Zod schemas and closures) into an array of -// serializable ToolDescriptor objects. Used by providers that delegate to -// existing factory services (workflow, view, dashboard, metadata). +export const humanizeToolName = (name: string): string => + name + .split('_') + .filter((word) => word.length > 0) + .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) + .join(' '); + export const toolSetToDescriptors = ( toolSet: ToolSet, category: ToolCategory, @@ -24,6 +28,7 @@ export const toolSetToDescriptors = ( return Object.entries(toolSet).map(([name, tool]) => { const base: ToolIndexEntry = { name, + label: humanizeToolName(name), description: tool.description ?? '', category, executionRef: { kind: 'static' as const, toolId: name }, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/translate-tool-label.util.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/translate-tool-label.util.ts new file mode 100644 index 0000000000..615ab5d793 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/translate-tool-label.util.ts @@ -0,0 +1,22 @@ +import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; + +import { type I18nService } from 'src/engine/core-modules/i18n/i18n.service'; +import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; + +export const translateToolLabel = ( + source: string, + i18nService: I18nService, + locale?: keyof typeof APP_LOCALES, +): string => { + if (source.length === 0) { + return source; + } + + const messageId = generateMessageId(source); + const translated = i18nService.translateMessage({ + messageId, + locale: locale ?? SOURCE_LOCALE, + }); + + return translated === messageId ? source : translated; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.schema.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.schema.ts index 775b3d7eab..4c2dff312f 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.schema.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.schema.ts @@ -13,4 +13,15 @@ export const CodeInterpreterInputZodSchema = z.object({ ) .optional() .describe('Files to make available in the execution environment'), + loadingMessage: z + .string() + .describe( + "A brief, present-tense status message shown to the user while the code runs (e.g., 'Analyzing sales data').", + ), + completedMessage: z + .string() + .optional() + .describe( + "A brief, past-tense status message shown to the user after the code finishes (e.g., 'Analyzed sales data'). No exclamation marks. Don't be optimistic, stay neutral on completion state. Falls back to the loading message when omitted.", + ), }); diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/types/code-interpreter-input.type.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/types/code-interpreter-input.type.ts index 1d3fa26469..e6d58b69f9 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/types/code-interpreter-input.type.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/types/code-interpreter-input.type.ts @@ -6,4 +6,6 @@ export type CodeInterpreterFileInput = { export type CodeInterpreterInput = { code: string; files?: CodeInterpreterFileInput[]; + loadingMessage: string; + completedMessage?: string; }; diff --git a/packages/twenty-server/src/engine/core-modules/tool/utils/wrap-tool-for-execution.util.ts b/packages/twenty-server/src/engine/core-modules/tool/utils/wrap-tool-for-execution.util.ts deleted file mode 100644 index c2518a555d..0000000000 --- a/packages/twenty-server/src/engine/core-modules/tool/utils/wrap-tool-for-execution.util.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { z } from 'zod'; - -const DEFAULT_LOADING_MESSAGE_SCHEMA = z - .string() - .describe( - "A brief status message for the user describing what you're doing (e.g., 'Sending email to customer').", - ); - -// Wraps a flat Zod tool schema with loadingMessage for AI execution -export const wrapSchemaForExecution = ( - schema: z.ZodObject, - customLoadingMessageSchema?: z.ZodString, -): z.ZodObject => { - return z.object({ - loadingMessage: - customLoadingMessageSchema ?? DEFAULT_LOADING_MESSAGE_SCHEMA, - ...schema.shape, - }) as z.ZodObject; -}; - -// For non-Zod schemas (logic functions with JSON Schema) -export const wrapJsonSchemaForExecution = ( - schema: Record, -): Record => { - const properties = (schema.properties as Record) ?? {}; - const required = (schema.required as string[]) ?? []; - - return { - type: 'object', - properties: { - loadingMessage: { - type: 'string', - description: 'A brief status message for the user.', - }, - ...properties, - }, - required: ['loadingMessage', ...required], - }; -}; - -// Strips loadingMessage from parameters before passing to tool execute -export const stripLoadingMessage = >( - parameters: T, -): Omit => { - const { loadingMessage: _, ...rest } = parameters; - - return rest; -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index 1d304825e8..b09a33f987 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -13,6 +13,7 @@ import { type UIMessage, type UITools, } from 'ai'; +import { type APP_LOCALES } from 'twenty-shared/translations'; import { AppPath } from 'twenty-shared/types'; import { getAppPath, isDefined } from 'twenty-shared/utils'; @@ -125,6 +126,8 @@ export class ChatExecutionService { workspace.id, ); + const locale = userContext.locale as keyof typeof APP_LOCALES; + const toolContext = { workspaceId: workspace.id, roleId, @@ -132,13 +135,14 @@ export class ChatExecutionService { userId, userWorkspaceId, threadId, + locale, onCodeExecutionUpdate, }; const toolCatalog = await this.toolRegistry.buildToolIndex( workspace.id, roleId, - { userId, userWorkspaceId }, + { userId, userWorkspaceId, locale }, ); const skillCatalog = await this.skillService.findAllFlatSkills( diff --git a/packages/twenty-shared/src/ai/constants/database-crud-operation.const.ts b/packages/twenty-shared/src/ai/constants/database-crud-operation.const.ts new file mode 100644 index 0000000000..d7ca6e2bd5 --- /dev/null +++ b/packages/twenty-shared/src/ai/constants/database-crud-operation.const.ts @@ -0,0 +1,14 @@ +export const DATABASE_CRUD_OPERATIONS = [ + 'find_many', + 'find_one', + 'group_by', + 'create_one', + 'create_many', + 'update_one', + 'update_many', + 'upsert_many', + 'delete_one', + 'delete_many', +] as const; + +export type DatabaseCrudOperation = (typeof DATABASE_CRUD_OPERATIONS)[number]; diff --git a/packages/twenty-shared/src/ai/index.ts b/packages/twenty-shared/src/ai/index.ts index f61c50f153..bc4648ef7a 100644 --- a/packages/twenty-shared/src/ai/index.ts +++ b/packages/twenty-shared/src/ai/index.ts @@ -12,6 +12,8 @@ export type { AiSdkPackage } from './constants/ai-sdk-packages.const'; export { AI_SDK_PACKAGES } from './constants/ai-sdk-packages.const'; export type { DataResidency } from './constants/data-residency.const'; export { DATA_RESIDENCY_KEYS } from './constants/data-residency.const'; +export type { DatabaseCrudOperation } from './constants/database-crud-operation.const'; +export { DATABASE_CRUD_OPERATIONS } from './constants/database-crud-operation.const'; export type { NativeAiSdkProviderId } from './constants/native-ai-sdk-provider-ids.const'; export { NATIVE_AI_SDK_PROVIDER_IDS } from './constants/native-ai-sdk-provider-ids.const'; export { ToolCategory } from './constants/tool-category.const';