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:
+2
@@ -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;
|
||||
};
|
||||
+3
-2
@@ -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),
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+4
-1
@@ -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,
|
||||
},
|
||||
|
||||
+1
@@ -164,6 +164,7 @@ export class WorkspaceSetupChatService {
|
||||
companyEnrichment: companyContext,
|
||||
locale,
|
||||
}),
|
||||
modelId: workspace.fastModel,
|
||||
});
|
||||
|
||||
if (!isDefined(kickoffResult)) {
|
||||
|
||||
Reference in New Issue
Block a user