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.
This commit is contained in:
Raphaël Bosi
2026-07-31 10:21:02 +02:00
committed by GitHub
parent 8abc8f4bc9
commit a9084604b4
10 changed files with 214 additions and 13 deletions
@@ -10,6 +10,7 @@ import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { type AgentChatPendingQuestion } from '@/ai/types/AgentChatPendingQuestion'; import { type AgentChatPendingQuestion } from '@/ai/types/AgentChatPendingQuestion';
import { styled } from '@linaria/react'; import { styled } from '@linaria/react';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { RootDecorator } from '~/testing/decorators/RootDecorator'; import { RootDecorator } from '~/testing/decorators/RootDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
@@ -103,6 +104,7 @@ const meta: Meta<typeof AiChatQuestionCard> = {
), ),
SnackBarDecorator, SnackBarDecorator,
ComponentDecorator, ComponentDecorator,
MemoryRouterDecorator,
RootDecorator, RootDecorator,
], ],
}; };
@@ -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 }) => (
<MemoryRouter initialEntries={[pathname]}>
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
</MemoryRouter>
);
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');
});
});
@@ -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 }) => (
<MemoryRouter initialEntries={[pathname]}>
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
</MemoryRouter>
);
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');
});
});
@@ -1,7 +1,9 @@
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { useIsWorkspaceSetupChat } from '@/ai/hooks/useIsWorkspaceSetupChat';
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability'; import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState'; import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useAgentChatModelId = () => { export const useAgentChatModelId = () => {
@@ -9,6 +11,8 @@ export const useAgentChatModelId = () => {
const agentChatUserSelectedModel = useAtomStateValue( const agentChatUserSelectedModel = useAtomStateValue(
agentChatUserSelectedModelState, agentChatUserSelectedModelState,
); );
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const isWorkspaceSetupChat = useIsWorkspaceSetupChat();
const isUserModelAvailable = const isUserModelAvailable =
!isDefined(agentChatUserSelectedModel) || !isDefined(agentChatUserSelectedModel) ||
@@ -17,7 +21,13 @@ export const useAgentChatModelId = () => {
const selectedModelId = isUserModelAvailable const selectedModelId = isUserModelAvailable
? agentChatUserSelectedModel ? agentChatUserSelectedModel
: null; : null;
const modelIdForRequest = selectedModelId ?? undefined;
const workspaceSetupModelId = isWorkspaceSetupChat
? currentWorkspace?.fastModel
: null;
const modelIdForRequest =
selectedModelId ?? workspaceSetupModelId ?? undefined;
return { selectedModelId, modelIdForRequest }; return { selectedModelId, modelIdForRequest };
}; };
@@ -2,6 +2,7 @@ import { t } from '@lingui/core/macro';
import { isAutoSelectModelId } from 'twenty-shared/utils'; import { isAutoSelectModelId } from 'twenty-shared/utils';
import { type SelectOption } from 'twenty-ui/input'; import { type SelectOption } from 'twenty-ui/input';
import { useIsWorkspaceSetupChat } from '@/ai/hooks/useIsWorkspaceSetupChat';
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability'; import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { aiModelsState } from '@/client-config/states/aiModelsState'; import { aiModelsState } from '@/client-config/states/aiModelsState';
@@ -23,15 +24,20 @@ export const useAiModelOptions = ({
const aiModels = useAtomStateValue(aiModelsState); const aiModels = useAtomStateValue(aiModelsState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState); const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { enabledModels } = useWorkspaceAiModelAvailability(); const { enabledModels } = useWorkspaceAiModelAvailability();
const isWorkspaceSetupChat = useIsWorkspaceSetupChat();
const workspaceSmartModel = aiModels.find( const workspaceDefaultModelId = isWorkspaceSetupChat
(model) => model.modelId === currentWorkspace?.smartModel, ? currentWorkspace?.fastModel
: currentWorkspace?.smartModel;
const workspaceDefaultModel = aiModels.find(
(model) => model.modelId === workspaceDefaultModelId,
); );
const resolvedDefaultModelId = enabledModels.find( const resolvedDefaultModelId = enabledModels.find(
(model) => (model) =>
model.label === workspaceSmartModel?.label && model.label === workspaceDefaultModel?.label &&
model.providerName === workspaceSmartModel?.providerName, model.providerName === workspaceDefaultModel?.providerName,
)?.modelId; )?.modelId;
const allOptions = enabledModels const allOptions = enabledModels
@@ -42,13 +48,13 @@ export const useAiModelOptions = ({
})) }))
.sort((a, b) => a.label.localeCompare(b.label)); .sort((a, b) => a.label.localeCompare(b.label));
const pinnedOption = workspaceSmartModel const pinnedOption = workspaceDefaultModel
? { ? {
value: resolvedDefaultModelId ?? workspaceSmartModel.modelId, value: resolvedDefaultModelId ?? workspaceDefaultModel.modelId,
label: workspaceSmartModel.label, label: workspaceDefaultModel.label,
Icon: getModelIcon( Icon: getModelIcon(
workspaceSmartModel.modelFamily, workspaceDefaultModel.modelFamily,
workspaceSmartModel.providerName, workspaceDefaultModel.providerName,
), ),
contextualText: t`default`, contextualText: t`default`,
} }
@@ -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;
};
@@ -85,6 +85,7 @@ describe('AgentChatStreamingService.startHiddenKickoffStream', () => {
userWorkspaceId: 'user-workspace-id', userWorkspaceId: 'user-workspace-id',
workspace, workspace,
text: kickoffText, text: kickoffText,
modelId: 'default-fast-model',
}; };
it('should return null without queueing a visible copy when the claim is lost', async () => { 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 } = const { service, threadRepository, agentChatService, messageQueueService } =
buildService(); buildService();
@@ -143,13 +144,13 @@ describe('AgentChatStreamingService.startHiddenKickoffStream', () => {
expect.objectContaining({ expect.objectContaining({
threadId: 'thread-id', threadId: 'thread-id',
browsingContext: null, browsingContext: null,
modelId: 'default-fast-model',
lastUserMessageText: kickoffText, lastUserMessageText: kickoffText,
lastUserMessageParts: [{ type: 'text', text: kickoffText }], lastUserMessageParts: [{ type: 'text', text: kickoffText }],
hasTitle: true, hasTitle: true,
existingTurnId: 'kickoff-turn-id', existingTurnId: 'kickoff-turn-id',
}), }),
); );
expect(messageQueueService.add.mock.calls[0][1].modelId).toBeUndefined();
expect(agentChatService.notifyThreadActivityUpdated).not.toHaveBeenCalled(); expect(agentChatService.notifyThreadActivityUpdated).not.toHaveBeenCalled();
expect(result).toEqual({ expect(result).toEqual({
streamId: expect.any(String), streamId: expect.any(String),
@@ -28,6 +28,8 @@ const EXPECTED_THREAD_ID = v5(
describe('WorkspaceSetupChatService', () => { describe('WorkspaceSetupChatService', () => {
const workspace = { const workspace = {
id: 'workspace-id', id: 'workspace-id',
fastModel: 'fast-model-id',
smartModel: 'smart-model-id',
} as WorkspaceEntity; } as WorkspaceEntity;
const startArguments = { const startArguments = {
@@ -235,6 +237,7 @@ describe('WorkspaceSetupChatService', () => {
text: expect.stringContaining( text: expect.stringContaining(
'No information about the company that owns this workspace is available.', 'No information about the company that owns this workspace is available.',
), ),
modelId: 'fast-model-id',
}); });
expect(result).toEqual({ expect(result).toEqual({
outcome: WorkspaceSetupChatOutcome.STARTED, outcome: WorkspaceSetupChatOutcome.STARTED,
@@ -302,11 +302,13 @@ export class AgentChatStreamingService {
userWorkspaceId, userWorkspaceId,
workspace, workspace,
text, text,
modelId,
}: { }: {
thread: AgentChatThreadEntity; thread: AgentChatThreadEntity;
userWorkspaceId: string; userWorkspaceId: string;
workspace: WorkspaceEntity; workspace: WorkspaceEntity;
text: string; text: string;
modelId: string;
}): Promise<{ streamId: string; messageId: string; turnId: string } | null> { }): Promise<{ streamId: string; messageId: string; turnId: string } | null> {
const threadId = thread.id; const threadId = thread.id;
const streamId = generateId(); const streamId = generateId();
@@ -372,6 +374,7 @@ export class AgentChatStreamingService {
workspaceId: workspace.id, workspaceId: workspace.id,
messages, messages,
browsingContext: null, browsingContext: null,
modelId,
lastUserMessageText: text, lastUserMessageText: text,
lastUserMessageParts: [{ type: 'text' as const, text }], lastUserMessageParts: [{ type: 'text' as const, text }],
hasTitle: !!thread.title, hasTitle: !!thread.title,
@@ -389,7 +392,7 @@ export class AgentChatStreamingService {
key: MetricsKeys.AiChatTurnFailed, key: MetricsKeys.AiChatTurnFailed,
amount: 1, amount: 1,
attributes: { attributes: {
model: 'unknown', model: modelId,
failure_phase: 'enqueue', failure_phase: 'enqueue',
error_code: streamError.code, error_code: streamError.code,
}, },
@@ -164,6 +164,7 @@ export class WorkspaceSetupChatService {
companyEnrichment: companyContext, companyEnrichment: companyContext,
locale, locale,
}), }),
modelId: workspace.fastModel,
}); });
if (!isDefined(kickoffResult)) { if (!isDefined(kickoffResult)) {