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