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 { 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<typeof AiChatQuestionCard> = {
),
SnackBarDecorator,
ComponentDecorator,
MemoryRouterDecorator,
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 { 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 };
};
@@ -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`,
}
@@ -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',
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),
@@ -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,
@@ -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,
},
@@ -164,6 +164,7 @@ export class WorkspaceSetupChatService {
companyEnrichment: companyContext,
locale,
}),
modelId: workspace.fastModel,
});
if (!isDefined(kickoffResult)) {