From a9084604b496f34fd8e6f021a537a97b197f3acf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:21:02 +0200 Subject: [PATCH] Use the fast model for the onboarding setup chat (#23586) The workspace setup chat ran on the smart model. The hidden kickoff turn enqueued its job without a `modelId`, and the frontend sends none unless the user picks one, so every turn fell through to `modelId ?? workspace.smartModel` in `chat-execution.service.ts`. Two halves, since the kickoff is server-initiated and the frontend never sends it: - `startHiddenKickoffStream` takes a `modelId` and the setup chat passes `workspace.fastModel`. - `useAgentChatModelId` requests `workspace.fastModel` on the setup page, so user turns follow. Everywhere else it still sends nothing and the server fallback is unchanged. `workspace.fastModel` defaults to the `default-fast-model` sentinel, so the model still resolves through the registry and stays admin-overridable. An explicit pick from the model picker still wins. --- .../AiChatQuestionCard.stories.tsx | 2 + .../__tests__/useAgentChatModelId.test.tsx | 90 +++++++++++++++++++ .../__tests__/useAiModelOptions.test.tsx | 77 ++++++++++++++++ .../modules/ai/hooks/useAgentChatModelId.ts | 12 ++- .../src/modules/ai/hooks/useAiModelOptions.ts | 24 +++-- .../ai/hooks/useIsWorkspaceSetupChat.ts | 8 ++ ...ent-chat-streaming.service.kickoff.spec.ts | 5 +- .../workspace-setup-chat.service.spec.ts | 3 + .../services/agent-chat-streaming.service.ts | 5 +- .../services/workspace-setup-chat.service.ts | 1 + 10 files changed, 214 insertions(+), 13 deletions(-) create mode 100644 packages/twenty-front/src/modules/ai/hooks/__tests__/useAgentChatModelId.test.tsx create mode 100644 packages/twenty-front/src/modules/ai/hooks/__tests__/useAiModelOptions.test.tsx create mode 100644 packages/twenty-front/src/modules/ai/hooks/useIsWorkspaceSetupChat.ts diff --git a/packages/twenty-front/src/modules/ai/components/__stories__/AiChatQuestionCard.stories.tsx b/packages/twenty-front/src/modules/ai/components/__stories__/AiChatQuestionCard.stories.tsx index 670e0d526d..035968750d 100644 --- a/packages/twenty-front/src/modules/ai/components/__stories__/AiChatQuestionCard.stories.tsx +++ b/packages/twenty-front/src/modules/ai/components/__stories__/AiChatQuestionCard.stories.tsx @@ -10,6 +10,7 @@ import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState'; import { type AgentChatPendingQuestion } from '@/ai/types/AgentChatPendingQuestion'; import { styled } from '@linaria/react'; +import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator'; import { RootDecorator } from '~/testing/decorators/RootDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; @@ -103,6 +104,7 @@ const meta: Meta = { ), SnackBarDecorator, ComponentDecorator, + MemoryRouterDecorator, RootDecorator, ], }; diff --git a/packages/twenty-front/src/modules/ai/hooks/__tests__/useAgentChatModelId.test.tsx b/packages/twenty-front/src/modules/ai/hooks/__tests__/useAgentChatModelId.test.tsx new file mode 100644 index 0000000000..bc776a9c22 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/hooks/__tests__/useAgentChatModelId.test.tsx @@ -0,0 +1,90 @@ +import { act, renderHook } from '@testing-library/react'; +import { Provider as JotaiProvider } from 'jotai'; +import { MemoryRouter } from 'react-router-dom'; +import { AppPath } from 'twenty-shared/types'; + +import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId'; +import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { aiModelsState } from '@/client-config/states/aiModelsState'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; +import { + jotaiStore, + resetJotaiStore, +} from '@/ui/utilities/state/jotai/jotaiStore'; + +const getWrapper = + (pathname: string) => + ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + +const renderHooks = ({ + pathname, + userSelectedModel = null, +}: { + pathname: string; + userSelectedModel?: string | null; +}) => { + const { result } = renderHook( + () => { + const setCurrentWorkspace = useSetAtomState(currentWorkspaceState); + const setAiModels = useSetAtomState(aiModelsState); + const setAgentChatUserSelectedModel = useSetAtomState( + agentChatUserSelectedModelState, + ); + + return { + setCurrentWorkspace, + setAiModels, + setAgentChatUserSelectedModel, + ...useAgentChatModelId(), + }; + }, + { wrapper: getWrapper(pathname) }, + ); + + act(() => { + result.current.setCurrentWorkspace({ + fastModel: 'openai/gpt-5-mini', + smartModel: 'openai/gpt-5.2', + useRecommendedModels: false, + enabledAiModelIds: ['openai/gpt-4.1'], + } as never); + result.current.setAiModels([ + { modelId: 'openai/gpt-4.1', isDeprecated: false }, + ] as never); + result.current.setAgentChatUserSelectedModel(userSelectedModel); + }); + + return result; +}; + +describe('useAgentChatModelId', () => { + beforeEach(() => { + resetJotaiStore(); + }); + + it('should request the workspace fast model on the workspace setup page', () => { + const result = renderHooks({ pathname: AppPath.WorkspaceSetup }); + + expect(result.current.modelIdForRequest).toBe('openai/gpt-5-mini'); + }); + + it('should request no model elsewhere so the server falls back to the smart model', () => { + const result = renderHooks({ pathname: '/objects/companies' }); + + expect(result.current.modelIdForRequest).toBeUndefined(); + }); + + it('should let a user selected model win on the workspace setup page', () => { + const result = renderHooks({ + pathname: AppPath.WorkspaceSetup, + userSelectedModel: 'openai/gpt-4.1', + }); + + expect(result.current.modelIdForRequest).toBe('openai/gpt-4.1'); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/hooks/__tests__/useAiModelOptions.test.tsx b/packages/twenty-front/src/modules/ai/hooks/__tests__/useAiModelOptions.test.tsx new file mode 100644 index 0000000000..3668262447 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/hooks/__tests__/useAiModelOptions.test.tsx @@ -0,0 +1,77 @@ +import { act, renderHook } from '@testing-library/react'; +import { Provider as JotaiProvider } from 'jotai'; +import { MemoryRouter } from 'react-router-dom'; +import { AppPath } from 'twenty-shared/types'; + +import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { aiModelsState } from '@/client-config/states/aiModelsState'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; +import { + jotaiStore, + resetJotaiStore, +} from '@/ui/utilities/state/jotai/jotaiStore'; + +const getWrapper = + (pathname: string) => + ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + +const renderHooks = (pathname: string) => { + const { result } = renderHook( + () => { + const setCurrentWorkspace = useSetAtomState(currentWorkspaceState); + const setAiModels = useSetAtomState(aiModelsState); + + return { + setCurrentWorkspace, + setAiModels, + ...useAiModelOptions({ variant: 'pinned-default' }), + }; + }, + { wrapper: getWrapper(pathname) }, + ); + + act(() => { + result.current.setCurrentWorkspace({ + fastModel: 'default-fast-model', + smartModel: 'default-smart-model', + useRecommendedModels: true, + } as never); + result.current.setAiModels([ + { + modelId: 'default-smart-model', + label: 'GPT-5.2', + providerName: 'openai', + }, + { + modelId: 'default-fast-model', + label: 'GPT-5.6 Luna', + providerName: 'openai', + }, + ] as never); + }); + + return result; +}; + +describe('useAiModelOptions', () => { + beforeEach(() => { + resetJotaiStore(); + }); + + it('should pin the workspace fast model on the workspace setup page', () => { + const result = renderHooks(AppPath.WorkspaceSetup); + + expect(result.current.pinnedOption?.label).toBe('GPT-5.6 Luna'); + }); + + it('should pin the workspace smart model elsewhere', () => { + const result = renderHooks('/objects/companies'); + + expect(result.current.pinnedOption?.label).toBe('GPT-5.2'); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChatModelId.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChatModelId.ts index 661965d63c..62ccb6e22b 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAgentChatModelId.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChatModelId.ts @@ -1,7 +1,9 @@ import { isDefined } from 'twenty-shared/utils'; +import { useIsWorkspaceSetupChat } from '@/ai/hooks/useIsWorkspaceSetupChat'; import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability'; import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; export const useAgentChatModelId = () => { @@ -9,6 +11,8 @@ export const useAgentChatModelId = () => { const agentChatUserSelectedModel = useAtomStateValue( agentChatUserSelectedModelState, ); + const currentWorkspace = useAtomStateValue(currentWorkspaceState); + const isWorkspaceSetupChat = useIsWorkspaceSetupChat(); const isUserModelAvailable = !isDefined(agentChatUserSelectedModel) || @@ -17,7 +21,13 @@ export const useAgentChatModelId = () => { const selectedModelId = isUserModelAvailable ? agentChatUserSelectedModel : null; - const modelIdForRequest = selectedModelId ?? undefined; + + const workspaceSetupModelId = isWorkspaceSetupChat + ? currentWorkspace?.fastModel + : null; + + const modelIdForRequest = + selectedModelId ?? workspaceSetupModelId ?? undefined; return { selectedModelId, modelIdForRequest }; }; diff --git a/packages/twenty-front/src/modules/ai/hooks/useAiModelOptions.ts b/packages/twenty-front/src/modules/ai/hooks/useAiModelOptions.ts index 5ce1c5aff4..945f96c3ea 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAiModelOptions.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAiModelOptions.ts @@ -2,6 +2,7 @@ import { t } from '@lingui/core/macro'; import { isAutoSelectModelId } from 'twenty-shared/utils'; import { type SelectOption } from 'twenty-ui/input'; +import { useIsWorkspaceSetupChat } from '@/ai/hooks/useIsWorkspaceSetupChat'; import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { aiModelsState } from '@/client-config/states/aiModelsState'; @@ -23,15 +24,20 @@ export const useAiModelOptions = ({ const aiModels = useAtomStateValue(aiModelsState); const currentWorkspace = useAtomStateValue(currentWorkspaceState); const { enabledModels } = useWorkspaceAiModelAvailability(); + const isWorkspaceSetupChat = useIsWorkspaceSetupChat(); - const workspaceSmartModel = aiModels.find( - (model) => model.modelId === currentWorkspace?.smartModel, + const workspaceDefaultModelId = isWorkspaceSetupChat + ? currentWorkspace?.fastModel + : currentWorkspace?.smartModel; + + const workspaceDefaultModel = aiModels.find( + (model) => model.modelId === workspaceDefaultModelId, ); const resolvedDefaultModelId = enabledModels.find( (model) => - model.label === workspaceSmartModel?.label && - model.providerName === workspaceSmartModel?.providerName, + model.label === workspaceDefaultModel?.label && + model.providerName === workspaceDefaultModel?.providerName, )?.modelId; const allOptions = enabledModels @@ -42,13 +48,13 @@ export const useAiModelOptions = ({ })) .sort((a, b) => a.label.localeCompare(b.label)); - const pinnedOption = workspaceSmartModel + const pinnedOption = workspaceDefaultModel ? { - value: resolvedDefaultModelId ?? workspaceSmartModel.modelId, - label: workspaceSmartModel.label, + value: resolvedDefaultModelId ?? workspaceDefaultModel.modelId, + label: workspaceDefaultModel.label, Icon: getModelIcon( - workspaceSmartModel.modelFamily, - workspaceSmartModel.providerName, + workspaceDefaultModel.modelFamily, + workspaceDefaultModel.providerName, ), contextualText: t`default`, } diff --git a/packages/twenty-front/src/modules/ai/hooks/useIsWorkspaceSetupChat.ts b/packages/twenty-front/src/modules/ai/hooks/useIsWorkspaceSetupChat.ts new file mode 100644 index 0000000000..ac76538722 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/hooks/useIsWorkspaceSetupChat.ts @@ -0,0 +1,8 @@ +import { useLocation } from 'react-router-dom'; +import { AppPath } from 'twenty-shared/types'; + +export const useIsWorkspaceSetupChat = () => { + const { pathname } = useLocation(); + + return pathname === AppPath.WorkspaceSetup; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/agent-chat-streaming.service.kickoff.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/agent-chat-streaming.service.kickoff.spec.ts index ee09c51008..fb71a39073 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/agent-chat-streaming.service.kickoff.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/agent-chat-streaming.service.kickoff.spec.ts @@ -85,6 +85,7 @@ describe('AgentChatStreamingService.startHiddenKickoffStream', () => { userWorkspaceId: 'user-workspace-id', workspace, text: kickoffText, + modelId: 'default-fast-model', }; it('should return null without queueing a visible copy when the claim is lost', async () => { @@ -124,7 +125,7 @@ describe('AgentChatStreamingService.startHiddenKickoffStream', () => { }); }); - it('should enqueue the hidden kickoff turn without a pinned model and without notifying thread activity', async () => { + it('should enqueue the hidden kickoff turn with the given model and without notifying thread activity', async () => { const { service, threadRepository, agentChatService, messageQueueService } = buildService(); @@ -143,13 +144,13 @@ describe('AgentChatStreamingService.startHiddenKickoffStream', () => { expect.objectContaining({ threadId: 'thread-id', browsingContext: null, + modelId: 'default-fast-model', lastUserMessageText: kickoffText, lastUserMessageParts: [{ type: 'text', text: kickoffText }], hasTitle: true, existingTurnId: 'kickoff-turn-id', }), ); - expect(messageQueueService.add.mock.calls[0][1].modelId).toBeUndefined(); expect(agentChatService.notifyThreadActivityUpdated).not.toHaveBeenCalled(); expect(result).toEqual({ streamId: expect.any(String), diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/workspace-setup-chat.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/workspace-setup-chat.service.spec.ts index 7707b7b0b0..6223bd89e6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/workspace-setup-chat.service.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/workspace-setup-chat.service.spec.ts @@ -28,6 +28,8 @@ const EXPECTED_THREAD_ID = v5( describe('WorkspaceSetupChatService', () => { const workspace = { id: 'workspace-id', + fastModel: 'fast-model-id', + smartModel: 'smart-model-id', } as WorkspaceEntity; const startArguments = { @@ -235,6 +237,7 @@ describe('WorkspaceSetupChatService', () => { text: expect.stringContaining( 'No information about the company that owns this workspace is available.', ), + modelId: 'fast-model-id', }); expect(result).toEqual({ outcome: WorkspaceSetupChatOutcome.STARTED, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts index 93b1958176..8ccbfe241c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts @@ -302,11 +302,13 @@ export class AgentChatStreamingService { userWorkspaceId, workspace, text, + modelId, }: { thread: AgentChatThreadEntity; userWorkspaceId: string; workspace: WorkspaceEntity; text: string; + modelId: string; }): Promise<{ streamId: string; messageId: string; turnId: string } | null> { const threadId = thread.id; const streamId = generateId(); @@ -372,6 +374,7 @@ export class AgentChatStreamingService { workspaceId: workspace.id, messages, browsingContext: null, + modelId, lastUserMessageText: text, lastUserMessageParts: [{ type: 'text' as const, text }], hasTitle: !!thread.title, @@ -389,7 +392,7 @@ export class AgentChatStreamingService { key: MetricsKeys.AiChatTurnFailed, amount: 1, attributes: { - model: 'unknown', + model: modelId, failure_phase: 'enqueue', error_code: streamError.code, }, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/workspace-setup-chat.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/workspace-setup-chat.service.ts index 1b5d9caea8..3c1e6a1ae2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/workspace-setup-chat.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/workspace-setup-chat.service.ts @@ -164,6 +164,7 @@ export class WorkspaceSetupChatService { companyEnrichment: companyContext, locale, }), + modelId: workspace.fastModel, }); if (!isDefined(kickoffResult)) {