Auto-start the workspace setup chat with a data model proposal (#23437)
https://github.com/user-attachments/assets/d447372b-c1b4-4c95-bac9-a8f8efa7d4a1 When the workspace creator lands on `/workspace-setup` after onboarding, the AI chat now starts on its own: an invisible first message, built server-side from the company enrichment collected in #23199, asks the assistant to propose a data model tailored to the business. The proposal streams in; the user never sees the prompt. - New `startWorkspaceSetupChat` mutation: creator only, gated on `IS_ONBOARDING_AI_CHAT_ENABLED`, available models and credits. Idempotent per user and workspace via a `keyValuePair` pointing at the thread, so a reload or a second tab joins the same conversation instead of starting a new one. - The thread holds exactly one hidden `USER` message combining the company context and the setup instructions, which keeps the one-hidden-message-per-thread index from #23199 satisfied. It goes through a dedicated streaming path that never queues, so the prompt cannot resurface as a visible message. - The assistant only proposes. It creates nothing until the user approves, then builds the model with the `metadata-building` skill. Objects and fields get English names with labels in the user's language, and the conversation continues in that language. - With no enrichment (consumer email domain, or the integration disabled) the kickoff still runs, and the assistant asks one short question about the business before proposing. - `findLatestSentUserMessage` no longer filters out hidden messages, so a failed kickoff turn stays retryable, and the no-message chat error surface now offers retry for stream errors. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23437?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -2955,6 +2955,17 @@ type AgentChatEvent {
|
||||
event: JSON!
|
||||
}
|
||||
|
||||
type StartWorkspaceSetupChatResult {
|
||||
outcome: WorkspaceSetupChatOutcome!
|
||||
thread: AgentChatThread
|
||||
}
|
||||
|
||||
enum WorkspaceSetupChatOutcome {
|
||||
STARTED
|
||||
ALREADY_STARTED
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
type AgentTurnEvaluation {
|
||||
id: UUID!
|
||||
turnId: UUID!
|
||||
@@ -3542,6 +3553,7 @@ type Mutation {
|
||||
unarchiveChatThread(id: UUID!): AgentChatThread!
|
||||
deleteChatThread(id: UUID!): Boolean!
|
||||
deleteQueuedChatMessage(messageId: UUID!): Boolean!
|
||||
startWorkspaceSetupChat(companyContext: JSON): StartWorkspaceSetupChatResult!
|
||||
createSkill(input: CreateSkillInput!): Skill!
|
||||
updateSkill(input: UpdateSkillInput!): Skill!
|
||||
deleteSkill(id: UUID!): Skill!
|
||||
|
||||
@@ -2648,6 +2648,14 @@ export interface AgentChatEvent {
|
||||
__typename: 'AgentChatEvent'
|
||||
}
|
||||
|
||||
export interface StartWorkspaceSetupChatResult {
|
||||
outcome: WorkspaceSetupChatOutcome
|
||||
thread?: AgentChatThread
|
||||
__typename: 'StartWorkspaceSetupChatResult'
|
||||
}
|
||||
|
||||
export type WorkspaceSetupChatOutcome = 'STARTED' | 'ALREADY_STARTED' | 'UNAVAILABLE'
|
||||
|
||||
export interface AgentTurnEvaluation {
|
||||
id: Scalars['UUID']
|
||||
turnId: Scalars['UUID']
|
||||
@@ -3064,6 +3072,7 @@ export interface Mutation {
|
||||
unarchiveChatThread: AgentChatThread
|
||||
deleteChatThread: Scalars['Boolean']
|
||||
deleteQueuedChatMessage: Scalars['Boolean']
|
||||
startWorkspaceSetupChat: StartWorkspaceSetupChatResult
|
||||
createSkill: Skill
|
||||
updateSkill: Skill
|
||||
deleteSkill: Skill
|
||||
@@ -5919,6 +5928,13 @@ export interface AgentChatEventGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface StartWorkspaceSetupChatResultGenqlSelection{
|
||||
outcome?: boolean | number
|
||||
thread?: AgentChatThreadGenqlSelection
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface AgentTurnEvaluationGenqlSelection{
|
||||
id?: boolean | number
|
||||
turnId?: boolean | number
|
||||
@@ -6370,6 +6386,7 @@ export interface MutationGenqlSelection{
|
||||
unarchiveChatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
deleteChatThread?: { __args: {id: Scalars['UUID']} }
|
||||
deleteQueuedChatMessage?: { __args: {messageId: Scalars['UUID']} }
|
||||
startWorkspaceSetupChat?: (StartWorkspaceSetupChatResultGenqlSelection & { __args?: {companyContext?: (Scalars['JSON'] | null)} })
|
||||
createSkill?: (SkillGenqlSelection & { __args: {input: CreateSkillInput} })
|
||||
updateSkill?: (SkillGenqlSelection & { __args: {input: UpdateSkillInput} })
|
||||
deleteSkill?: (SkillGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
@@ -8842,6 +8859,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const StartWorkspaceSetupChatResult_possibleTypes: string[] = ['StartWorkspaceSetupChatResult']
|
||||
export const isStartWorkspaceSetupChatResult = (obj?: { __typename?: any } | null): obj is StartWorkspaceSetupChatResult => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isStartWorkspaceSetupChatResult"')
|
||||
return StartWorkspaceSetupChatResult_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const AgentTurnEvaluation_possibleTypes: string[] = ['AgentTurnEvaluation']
|
||||
export const isAgentTurnEvaluation = (obj?: { __typename?: any } | null): obj is AgentTurnEvaluation => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isAgentTurnEvaluation"')
|
||||
@@ -9558,6 +9583,12 @@ export const enumUnsubscribeTopicVisibility = {
|
||||
PRIVATE: 'PRIVATE' as const
|
||||
}
|
||||
|
||||
export const enumWorkspaceSetupChatOutcome = {
|
||||
STARTED: 'STARTED' as const,
|
||||
ALREADY_STARTED: 'ALREADY_STARTED' as const,
|
||||
UNAVAILABLE: 'UNAVAILABLE' as const
|
||||
}
|
||||
|
||||
export const enumAppKeyValueScope = {
|
||||
WORKSPACE: 'WORKSPACE' as const,
|
||||
SERVER: 'SERVER' as const
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -6,6 +6,8 @@ import { AiChatSuggestedPrompts } from '@/ai/components/suggested-prompts/AiChat
|
||||
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
|
||||
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
|
||||
import { agentChatIsAwaitingFirstChunkComponentFamilyState } from '@/ai/states/agentChatIsAwaitingFirstChunkComponentFamilyState';
|
||||
import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState';
|
||||
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
|
||||
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
|
||||
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
||||
@@ -32,6 +34,14 @@ export const AiChatEmptyState = ({ editor }: AiChatEmptyStateProps) => {
|
||||
agentChatErrorComponentFamilyState,
|
||||
{ threadId: currentAiChatThread },
|
||||
);
|
||||
const agentChatIsAwaitingFirstChunk = useAtomComponentFamilyStateValue(
|
||||
agentChatIsAwaitingFirstChunkComponentFamilyState,
|
||||
{ threadId: currentAiChatThread },
|
||||
);
|
||||
const agentChatIsStreaming = useAtomComponentFamilyStateValue(
|
||||
agentChatIsStreamingComponentFamilyState,
|
||||
{ threadId: currentAiChatThread },
|
||||
);
|
||||
const agentChatThreadsLoading = useAtomStateValue(
|
||||
agentChatThreadsLoadingState,
|
||||
);
|
||||
@@ -54,7 +64,12 @@ export const AiChatEmptyState = ({ editor }: AiChatEmptyStateProps) => {
|
||||
(agentChatThreadsLoading && isOnNewChatSlot) ||
|
||||
(agentChatMessagesLoading && !skipMessagesSkeletonUntilLoaded);
|
||||
const shouldRender =
|
||||
!isMobile && !hasMessages && !isDefined(agentChatError) && !skeletonShowing;
|
||||
!isMobile &&
|
||||
!hasMessages &&
|
||||
!isDefined(agentChatError) &&
|
||||
!skeletonShowing &&
|
||||
!agentChatIsAwaitingFirstChunk &&
|
||||
!agentChatIsStreaming;
|
||||
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
|
||||
@@ -2,10 +2,11 @@ import { styled } from '@linaria/react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { AiChatErrorRenderer } from '@/ai/components/AiChatErrorRenderer';
|
||||
import { useRetryChatMessage } from '@/ai/hooks/useRetryChatMessage';
|
||||
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
|
||||
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
|
||||
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
|
||||
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
|
||||
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
@@ -22,11 +23,14 @@ const StyledErrorContainer = styled.div`
|
||||
|
||||
export const AiChatStandaloneError = () => {
|
||||
const agentChatIsLoading = useAtomStateValue(agentChatIsLoadingState);
|
||||
const { retryChatMessage } = useRetryChatMessage();
|
||||
|
||||
const currentAiChatThread = useAtomStateValue(currentAiChatThreadState);
|
||||
const agentChatDisplayedThread = useAtomStateValue(
|
||||
agentChatDisplayedThreadState,
|
||||
);
|
||||
const agentChatError = useAtomComponentFamilyStateValue(
|
||||
agentChatErrorComponentFamilyState,
|
||||
{ threadId: currentAiChatThread },
|
||||
{ threadId: agentChatDisplayedThread },
|
||||
);
|
||||
|
||||
const hasMessages = useAtomComponentSelectorValue(
|
||||
@@ -42,7 +46,7 @@ export const AiChatStandaloneError = () => {
|
||||
|
||||
return (
|
||||
<StyledErrorContainer>
|
||||
<AiChatErrorRenderer error={agentChatError} />
|
||||
<AiChatErrorRenderer error={agentChatError} onRetry={retryChatMessage} />
|
||||
</StyledErrorContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -58,6 +58,7 @@ export const AiChatTabMessageList = () => {
|
||||
return (
|
||||
<StyledPreambleOutsideScrollContainer>
|
||||
{messageListPreamble}
|
||||
<AiChatPendingResponseIndicator />
|
||||
</StyledPreambleOutsideScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { AiChatEmptyState } from '@/ai/components/AiChatEmptyState';
|
||||
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { agentChatIsAwaitingFirstChunkComponentFamilyState } from '@/ai/states/agentChatIsAwaitingFirstChunkComponentFamilyState';
|
||||
import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState';
|
||||
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
jest.mock('@/ai/components/suggested-prompts/AiChatSuggestedPrompts', () => ({
|
||||
AiChatSuggestedPrompts: () => <div data-testid="suggested-prompts" />,
|
||||
}));
|
||||
|
||||
const INSTANCE_ID = 'aiChatEmptyStateTest';
|
||||
const THREAD_ID = 'thread-1';
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<AgentChatComponentInstanceContext.Provider
|
||||
value={{ instanceId: INSTANCE_ID }}
|
||||
>
|
||||
{children}
|
||||
</AgentChatComponentInstanceContext.Provider>
|
||||
</JotaiProvider>
|
||||
);
|
||||
|
||||
describe('AiChatEmptyState', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetJotaiStore();
|
||||
jotaiStore.set(currentAiChatThreadState.atom, THREAD_ID);
|
||||
jotaiStore.set(agentChatDisplayedThreadState.atom, THREAD_ID);
|
||||
});
|
||||
|
||||
it('should render the suggested prompts when there is no message, no error and nothing loading', () => {
|
||||
const { getByTestId } = render(<AiChatEmptyState editor={null} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(getByTestId('suggested-prompts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render nothing when the current thread is awaiting its first chunk', () => {
|
||||
jotaiStore.set(
|
||||
agentChatIsAwaitingFirstChunkComponentFamilyState.atomFamily({
|
||||
instanceId: INSTANCE_ID,
|
||||
familyKey: { threadId: THREAD_ID },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
const { container } = render(<AiChatEmptyState editor={null} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should render nothing when the current thread is streaming', () => {
|
||||
jotaiStore.set(
|
||||
agentChatIsStreamingComponentFamilyState.atomFamily({
|
||||
instanceId: INSTANCE_ID,
|
||||
familyKey: { threadId: THREAD_ID },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
const { container } = render(<AiChatEmptyState editor={null} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { AiChatTabMessageList } from '@/ai/components/AiChatTabMessageList';
|
||||
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
|
||||
import { AiChatMessageListPreambleContext } from '@/ai/contexts/AiChatMessageListPreambleContext';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { agentChatIsAwaitingFirstChunkComponentFamilyState } from '@/ai/states/agentChatIsAwaitingFirstChunkComponentFamilyState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
jest.mock('@/ai/components/AiChatInitialLoadingIndicator', () => ({
|
||||
AiChatInitialLoadingIndicator: () => (
|
||||
<div data-testid="initial-loading-indicator" />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/ui/utilities/scroll/components/ScrollWrapper', () => ({
|
||||
ScrollWrapper: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="scroll-wrapper">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/ai/components/AiChatNonLastMessageIdsList', () => ({
|
||||
AiChatNonLastMessageIdsList: () => null,
|
||||
}));
|
||||
jest.mock('@/ai/components/AiChatLastMessageWithStreamingState', () => ({
|
||||
AiChatLastMessageWithStreamingState: () => null,
|
||||
}));
|
||||
jest.mock('@/ai/components/AiChatErrorUnderMessageList', () => ({
|
||||
AiChatErrorUnderMessageList: () => null,
|
||||
}));
|
||||
jest.mock('@/ai/components/AiChatScrollToBottomButton', () => ({
|
||||
AiChatScrollToBottomButton: () => null,
|
||||
}));
|
||||
jest.mock(
|
||||
'@/ai/components/AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect',
|
||||
() => ({
|
||||
AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect: () => null,
|
||||
}),
|
||||
);
|
||||
jest.mock('@/ai/components/AgentChatScrollToBottomOnMountLayoutEffect', () => ({
|
||||
AgentChatScrollToBottomOnMountLayoutEffect: () => null,
|
||||
}));
|
||||
|
||||
const INSTANCE_ID = 'aiChatTabMessageListPreambleTest';
|
||||
const THREAD_ID = 'thread-1';
|
||||
|
||||
const renderPreambleBranch = () =>
|
||||
render(
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<AgentChatComponentInstanceContext.Provider
|
||||
value={{ instanceId: INSTANCE_ID }}
|
||||
>
|
||||
<AiChatMessageListPreambleContext.Provider
|
||||
value={<div data-testid="preamble" />}
|
||||
>
|
||||
<AiChatTabMessageList />
|
||||
</AiChatMessageListPreambleContext.Provider>
|
||||
</AgentChatComponentInstanceContext.Provider>
|
||||
</JotaiProvider>,
|
||||
);
|
||||
|
||||
describe('AiChatTabMessageList preamble branch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetJotaiStore();
|
||||
jotaiStore.set(agentChatDisplayedThreadState.atom, THREAD_ID);
|
||||
});
|
||||
|
||||
it('should render the preamble with the pending response loader when the displayed thread is awaiting its first chunk', () => {
|
||||
jotaiStore.set(
|
||||
agentChatIsAwaitingFirstChunkComponentFamilyState.atomFamily({
|
||||
instanceId: INSTANCE_ID,
|
||||
familyKey: { threadId: THREAD_ID },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
const { getByTestId, queryByTestId } = renderPreambleBranch();
|
||||
|
||||
expect(getByTestId('preamble')).toBeInTheDocument();
|
||||
expect(getByTestId('initial-loading-indicator')).toBeInTheDocument();
|
||||
expect(queryByTestId('scroll-wrapper')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the preamble without the pending response loader when the displayed thread is not awaiting its first chunk', () => {
|
||||
const { getByTestId, queryByTestId } = renderPreambleBranch();
|
||||
|
||||
expect(getByTestId('preamble')).toBeInTheDocument();
|
||||
expect(queryByTestId('initial-loading-indicator')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const WORKSPACE_SETUP_CHAT_ENRICHMENT_MAX_WAIT_MS = 2500;
|
||||
+9
@@ -7,8 +7,10 @@ import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboard
|
||||
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
|
||||
import { companyEnrichmentState } from '@/onboarding/states/companyEnrichmentState';
|
||||
import { hasAttemptedCompanyEnrichmentFetchState } from '@/onboarding/states/hasAttemptedCompanyEnrichmentFetchState';
|
||||
import { isCompanyEnrichmentFetchInFlightState } from '@/onboarding/states/isCompanyEnrichmentFetchInFlightState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import {
|
||||
EnrichWorkspaceCompanyDocument,
|
||||
OnboardingStatus,
|
||||
@@ -25,6 +27,9 @@ export const CompanyEnrichmentOnboardingEffect = () => {
|
||||
setHasAttemptedCompanyEnrichmentFetch,
|
||||
] = useAtomState(hasAttemptedCompanyEnrichmentFetchState);
|
||||
const [enrichWorkspaceCompany] = useMutation(EnrichWorkspaceCompanyDocument);
|
||||
const setIsCompanyEnrichmentFetchInFlight = useSetAtomState(
|
||||
isCompanyEnrichmentFetchInFlightState,
|
||||
);
|
||||
const isOnboardingAiChatEnabled = useAtomStateValue(
|
||||
isOnboardingAiChatEnabledState,
|
||||
);
|
||||
@@ -45,6 +50,7 @@ export const CompanyEnrichmentOnboardingEffect = () => {
|
||||
}
|
||||
|
||||
setHasAttemptedCompanyEnrichmentFetch(true);
|
||||
setIsCompanyEnrichmentFetchInFlight(true);
|
||||
|
||||
const fetchCompanyEnrichment = async () => {
|
||||
try {
|
||||
@@ -65,6 +71,8 @@ export const CompanyEnrichmentOnboardingEffect = () => {
|
||||
setCompanyEnrichment(enrichment);
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
setIsCompanyEnrichmentFetchInFlight(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,6 +83,7 @@ export const CompanyEnrichmentOnboardingEffect = () => {
|
||||
isOnboardingInProgress,
|
||||
isOnboardingAiChatEnabled,
|
||||
setHasAttemptedCompanyEnrichmentFetch,
|
||||
setIsCompanyEnrichmentFetchInFlight,
|
||||
setCompanyEnrichment,
|
||||
enrichWorkspaceCompany,
|
||||
]);
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { useStore } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { AGENT_CHAT_INSTANCE_ID } from '@/ai/constants/AgentChatInstanceId';
|
||||
import { agentChatIsAwaitingFirstChunkComponentFamilyState } from '@/ai/states/agentChatIsAwaitingFirstChunkComponentFamilyState';
|
||||
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
||||
import { currentAiChatThreadTitleComponentFamilyState } from '@/ai/states/currentAiChatThreadTitleComponentFamilyState';
|
||||
import { hasInitializedAgentChatThreadsState } from '@/ai/states/hasInitializedAgentChatThreadsState';
|
||||
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
|
||||
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
|
||||
import { type FlatAgentChatThread } from '@/metadata-store/types/FlatAgentChatThread';
|
||||
import { WORKSPACE_SETUP_CHAT_ENRICHMENT_MAX_WAIT_MS } from '@/onboarding/constants/WorkspaceSetupChatEnrichmentMaxWaitMs';
|
||||
import { companyEnrichmentState } from '@/onboarding/states/companyEnrichmentState';
|
||||
import { hasRequestedWorkspaceSetupChatState } from '@/onboarding/states/hasRequestedWorkspaceSetupChatState';
|
||||
import { isCompanyEnrichmentFetchInFlightState } from '@/onboarding/states/isCompanyEnrichmentFetchInFlightState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import {
|
||||
StartWorkspaceSetupChatDocument,
|
||||
WorkspaceSetupChatOutcome,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const WorkspaceSetupChatKickoffEffect = () => {
|
||||
const [startWorkspaceSetupChatMutation] = useMutation(
|
||||
StartWorkspaceSetupChatDocument,
|
||||
);
|
||||
const store = useStore();
|
||||
const { addToDraft, applyChanges } = useUpdateMetadataStoreDraft();
|
||||
const isCompanyEnrichmentFetchInFlight = useAtomStateValue(
|
||||
isCompanyEnrichmentFetchInFlightState,
|
||||
);
|
||||
const [hasWaitedForCompanyEnrichment, setHasWaitedForCompanyEnrichment] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const waitTimer = setTimeout(
|
||||
() => setHasWaitedForCompanyEnrichment(true),
|
||||
WORKSPACE_SETUP_CHAT_ENRICHMENT_MAX_WAIT_MS,
|
||||
);
|
||||
|
||||
return () => clearTimeout(waitTimer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldWaitForCompanyEnrichment =
|
||||
isCompanyEnrichmentFetchInFlight && !hasWaitedForCompanyEnrichment;
|
||||
|
||||
if (
|
||||
shouldWaitForCompanyEnrichment ||
|
||||
store.get(hasRequestedWorkspaceSetupChatState.atom)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.set(hasRequestedWorkspaceSetupChatState.atom, true);
|
||||
|
||||
const startWorkspaceSetupChat = async () => {
|
||||
try {
|
||||
const { data } = await startWorkspaceSetupChatMutation({
|
||||
variables: {
|
||||
companyContext: store.get(companyEnrichmentState.atom) ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const result = data?.startWorkspaceSetupChat;
|
||||
const thread = result?.thread;
|
||||
|
||||
if (!isDefined(result)) {
|
||||
store.set(hasRequestedWorkspaceSetupChatState.atom, false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
result.outcome === WorkspaceSetupChatOutcome.UNAVAILABLE ||
|
||||
!isDefined(thread)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceSetupThread: FlatAgentChatThread = {
|
||||
id: thread.id,
|
||||
title: thread.title ?? null,
|
||||
createdAt: thread.createdAt,
|
||||
updatedAt: thread.updatedAt,
|
||||
conversationSize: thread.conversationSize,
|
||||
contextWindowTokens: thread.contextWindowTokens ?? null,
|
||||
totalInputTokens: thread.totalInputTokens,
|
||||
totalOutputTokens: thread.totalOutputTokens,
|
||||
totalInputCredits: thread.totalInputCredits,
|
||||
totalOutputCredits: thread.totalOutputCredits,
|
||||
};
|
||||
|
||||
addToDraft({ key: 'agentChatThreads', items: [workspaceSetupThread] });
|
||||
applyChanges();
|
||||
|
||||
store.set(
|
||||
currentAiChatThreadTitleComponentFamilyState.atomFamily({
|
||||
instanceId: AGENT_CHAT_INSTANCE_ID,
|
||||
familyKey: { threadId: thread.id },
|
||||
}),
|
||||
thread.title ?? null,
|
||||
);
|
||||
|
||||
if (result.outcome === WorkspaceSetupChatOutcome.STARTED) {
|
||||
store.set(
|
||||
agentChatIsAwaitingFirstChunkComponentFamilyState.atomFamily({
|
||||
instanceId: AGENT_CHAT_INSTANCE_ID,
|
||||
familyKey: { threadId: thread.id },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
store.set(hasInitializedAgentChatThreadsState.atom, true);
|
||||
store.set(skipMessagesSkeletonUntilLoadedState.atom, true);
|
||||
store.set(currentAiChatThreadState.atom, thread.id);
|
||||
} catch {
|
||||
store.set(hasRequestedWorkspaceSetupChatState.atom, false);
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
void startWorkspaceSetupChat();
|
||||
}, [
|
||||
startWorkspaceSetupChatMutation,
|
||||
store,
|
||||
addToDraft,
|
||||
applyChanges,
|
||||
isCompanyEnrichmentFetchInFlight,
|
||||
hasWaitedForCompanyEnrichment,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
import { MockedProvider } from '@apollo/client/testing/react';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { StrictMode } from 'react';
|
||||
import { type WorkspaceCompanyEnrichment } from 'twenty-shared/workspace';
|
||||
|
||||
import { AGENT_CHAT_INSTANCE_ID } from '@/ai/constants/AgentChatInstanceId';
|
||||
import { agentChatIsAwaitingFirstChunkComponentFamilyState } from '@/ai/states/agentChatIsAwaitingFirstChunkComponentFamilyState';
|
||||
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
||||
import { currentAiChatThreadTitleComponentFamilyState } from '@/ai/states/currentAiChatThreadTitleComponentFamilyState';
|
||||
import { hasInitializedAgentChatThreadsState } from '@/ai/states/hasInitializedAgentChatThreadsState';
|
||||
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
|
||||
import { WorkspaceSetupChatKickoffEffect } from '@/onboarding/effect-components/WorkspaceSetupChatKickoffEffect';
|
||||
import { companyEnrichmentState } from '@/onboarding/states/companyEnrichmentState';
|
||||
import { isCompanyEnrichmentFetchInFlightState } from '@/onboarding/states/isCompanyEnrichmentFetchInFlightState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
import { StartWorkspaceSetupChatDocument } from '~/generated-metadata/graphql';
|
||||
|
||||
const threadId = '20202020-aaaa-4aaa-8aaa-202020202020';
|
||||
const threadTitle = 'Configuration du workspace';
|
||||
|
||||
const enrichment: WorkspaceCompanyEnrichment = {
|
||||
domain: 'acme.com',
|
||||
enrichedAt: '2026-07-21T10:00:00.000Z',
|
||||
name: 'Acme Inc',
|
||||
website: null,
|
||||
industry: null,
|
||||
employeeCount: null,
|
||||
size: null,
|
||||
founded: null,
|
||||
headline: null,
|
||||
summary: null,
|
||||
tags: [],
|
||||
locality: null,
|
||||
region: null,
|
||||
country: null,
|
||||
};
|
||||
|
||||
const buildKickoffMock = ({
|
||||
outcome,
|
||||
countCall,
|
||||
captureVariables,
|
||||
}: {
|
||||
outcome: 'STARTED' | 'ALREADY_STARTED' | 'UNAVAILABLE';
|
||||
countCall?: () => void;
|
||||
captureVariables?: (variables: Record<string, unknown>) => void;
|
||||
}) => ({
|
||||
request: {
|
||||
query: StartWorkspaceSetupChatDocument,
|
||||
variables: (variables: Record<string, unknown>) => {
|
||||
captureVariables?.(variables);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
maxUsageCount: 2,
|
||||
result: () => {
|
||||
countCall?.();
|
||||
|
||||
return {
|
||||
data: {
|
||||
startWorkspaceSetupChat: {
|
||||
__typename: 'StartWorkspaceSetupChatResult',
|
||||
outcome,
|
||||
thread:
|
||||
outcome === 'UNAVAILABLE'
|
||||
? null
|
||||
: {
|
||||
__typename: 'AgentChatThread',
|
||||
id: threadId,
|
||||
title: threadTitle,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
contextWindowTokens: null,
|
||||
conversationSize: 0,
|
||||
totalInputCredits: 0,
|
||||
totalOutputCredits: 0,
|
||||
deletedAt: null,
|
||||
lastMessageAt: null,
|
||||
createdAt: '2026-07-21T10:00:00.000Z',
|
||||
updatedAt: '2026-07-21T10:00:00.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const renderKickoffEffect = (mocks: readonly unknown[]) =>
|
||||
render(
|
||||
<MockedProvider mocks={mocks as never}>
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<StrictMode>
|
||||
<WorkspaceSetupChatKickoffEffect />
|
||||
</StrictMode>
|
||||
</JotaiProvider>
|
||||
</MockedProvider>,
|
||||
);
|
||||
|
||||
const flushMutation = async () => {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
};
|
||||
|
||||
describe('WorkspaceSetupChatKickoffEffect', () => {
|
||||
beforeEach(() => {
|
||||
resetJotaiStore();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should wait for an in-flight company enrichment before starting the chat', async () => {
|
||||
jotaiStore.set(isCompanyEnrichmentFetchInFlightState.atom, true);
|
||||
|
||||
const capturedVariablesList: Record<string, unknown>[] = [];
|
||||
let callCount = 0;
|
||||
|
||||
renderKickoffEffect([
|
||||
buildKickoffMock({
|
||||
outcome: 'STARTED',
|
||||
countCall: () => {
|
||||
callCount += 1;
|
||||
},
|
||||
captureVariables: (variables) => {
|
||||
capturedVariablesList.push(variables);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(callCount).toBe(0);
|
||||
|
||||
await act(async () => {
|
||||
jotaiStore.set(companyEnrichmentState.atom, enrichment);
|
||||
jotaiStore.set(isCompanyEnrichmentFetchInFlightState.atom, false);
|
||||
});
|
||||
await flushMutation();
|
||||
|
||||
expect(callCount).toBe(1);
|
||||
expect(capturedVariablesList[0].companyContext).toEqual(enrichment);
|
||||
});
|
||||
|
||||
it('should start the workspace setup chat only once when the effect renders twice', async () => {
|
||||
let callCount = 0;
|
||||
const { rerender } = renderKickoffEffect([
|
||||
buildKickoffMock({
|
||||
outcome: 'STARTED',
|
||||
countCall: () => {
|
||||
callCount += 1;
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
rerender(
|
||||
<MockedProvider mocks={[] as never}>
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<StrictMode>
|
||||
<WorkspaceSetupChatKickoffEffect />
|
||||
</StrictMode>
|
||||
</JotaiProvider>
|
||||
</MockedProvider>,
|
||||
);
|
||||
await flushMutation();
|
||||
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should select the thread with its server title and mark it awaiting the first chunk when the chat is started', async () => {
|
||||
renderKickoffEffect([buildKickoffMock({ outcome: 'STARTED' })]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(jotaiStore.get(currentAiChatThreadState.atom)).toBe(threadId);
|
||||
expect(jotaiStore.get(hasInitializedAgentChatThreadsState.atom)).toBe(true);
|
||||
expect(jotaiStore.get(skipMessagesSkeletonUntilLoadedState.atom)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
jotaiStore.get(
|
||||
agentChatIsAwaitingFirstChunkComponentFamilyState.atomFamily({
|
||||
instanceId: AGENT_CHAT_INSTANCE_ID,
|
||||
familyKey: { threadId },
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
jotaiStore.get(
|
||||
currentAiChatThreadTitleComponentFamilyState.atomFamily({
|
||||
instanceId: AGENT_CHAT_INSTANCE_ID,
|
||||
familyKey: { threadId },
|
||||
}),
|
||||
),
|
||||
).toBe(threadTitle);
|
||||
});
|
||||
|
||||
it('should select the thread without awaiting a first chunk when the chat was already started', async () => {
|
||||
renderKickoffEffect([buildKickoffMock({ outcome: 'ALREADY_STARTED' })]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(jotaiStore.get(currentAiChatThreadState.atom)).toBe(threadId);
|
||||
expect(
|
||||
jotaiStore.get(
|
||||
agentChatIsAwaitingFirstChunkComponentFamilyState.atomFamily({
|
||||
instanceId: AGENT_CHAT_INSTANCE_ID,
|
||||
familyKey: { threadId },
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass the stored enrichment as the companyContext variable when one is stored', async () => {
|
||||
jotaiStore.set(companyEnrichmentState.atom, enrichment);
|
||||
|
||||
const capturedVariablesList: Record<string, unknown>[] = [];
|
||||
renderKickoffEffect([
|
||||
buildKickoffMock({
|
||||
outcome: 'STARTED',
|
||||
captureVariables: (variables) => {
|
||||
capturedVariablesList.push(variables);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(capturedVariablesList[0].companyContext).toEqual(enrichment);
|
||||
});
|
||||
|
||||
it('should send an undefined companyContext variable when no enrichment is stored', async () => {
|
||||
const capturedVariablesList: Record<string, unknown>[] = [];
|
||||
renderKickoffEffect([
|
||||
buildKickoffMock({
|
||||
outcome: 'STARTED',
|
||||
captureVariables: (variables) => {
|
||||
capturedVariablesList.push(variables);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(capturedVariablesList.length).toBeGreaterThan(0);
|
||||
expect(capturedVariablesList[0].companyContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not touch the chat state when the chat is unavailable', async () => {
|
||||
renderKickoffEffect([buildKickoffMock({ outcome: 'UNAVAILABLE' })]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(jotaiStore.get(currentAiChatThreadState.atom)).toBeNull();
|
||||
expect(jotaiStore.get(hasInitializedAgentChatThreadsState.atom)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(jotaiStore.get(skipMessagesSkeletonUntilLoadedState.atom)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should retry on a later effect run when the mutation fails', async () => {
|
||||
let callCount = 0;
|
||||
|
||||
renderKickoffEffect([
|
||||
{
|
||||
request: { query: StartWorkspaceSetupChatDocument },
|
||||
error: new Error('Network error'),
|
||||
},
|
||||
buildKickoffMock({
|
||||
outcome: 'STARTED',
|
||||
countCall: () => {
|
||||
callCount += 1;
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(jotaiStore.get(currentAiChatThreadState.atom)).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
jotaiStore.set(isCompanyEnrichmentFetchInFlightState.atom, true);
|
||||
});
|
||||
await act(async () => {
|
||||
jotaiStore.set(isCompanyEnrichmentFetchInFlightState.atom, false);
|
||||
});
|
||||
await flushMutation();
|
||||
|
||||
expect(callCount).toBe(1);
|
||||
expect(jotaiStore.get(currentAiChatThreadState.atom)).toBe(threadId);
|
||||
});
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const START_WORKSPACE_SETUP_CHAT = gql`
|
||||
mutation StartWorkspaceSetupChat($companyContext: JSON) {
|
||||
startWorkspaceSetupChat(companyContext: $companyContext) {
|
||||
outcome
|
||||
thread {
|
||||
id
|
||||
title
|
||||
totalInputTokens
|
||||
totalOutputTokens
|
||||
contextWindowTokens
|
||||
conversationSize
|
||||
totalInputCredits
|
||||
totalOutputCredits
|
||||
deletedAt
|
||||
lastMessageAt
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const hasRequestedWorkspaceSetupChatState = createAtomState<boolean>({
|
||||
key: 'hasRequestedWorkspaceSetupChatState',
|
||||
defaultValue: false,
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const isCompanyEnrichmentFetchInFlightState = createAtomState<boolean>({
|
||||
key: 'isCompanyEnrichmentFetchInFlightState',
|
||||
defaultValue: false,
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboard
|
||||
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
|
||||
import { WorkspaceSetupChatPreamble } from '@/onboarding/components/WorkspaceSetupChatPreamble';
|
||||
import { WorkspaceSetupHeader } from '@/onboarding/components/WorkspaceSetupHeader';
|
||||
import { WorkspaceSetupChatKickoffEffect } from '@/onboarding/effect-components/WorkspaceSetupChatKickoffEffect';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
@@ -58,6 +59,7 @@ export const WorkspaceSetup = () => {
|
||||
<StyledPanel>
|
||||
<WorkspaceSetupHeader title={title} />
|
||||
<StyledContent>
|
||||
{shouldOpenAiChatAfterOnboarding && <WorkspaceSetupChatKickoffEffect />}
|
||||
<AiChatMessageListPreambleContext.Provider value={preamble}>
|
||||
<AiChatTab />
|
||||
</AiChatMessageListPreambleContext.Provider>
|
||||
|
||||
@@ -47,6 +47,15 @@ jest.mock('@/onboarding/components/WorkspaceSetupChatPreamble', () => ({
|
||||
WorkspaceSetupChatPreamble: () => <div data-testid="preamble" />,
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'@/onboarding/effect-components/WorkspaceSetupChatKickoffEffect',
|
||||
() => ({
|
||||
WorkspaceSetupChatKickoffEffect: () => (
|
||||
<div data-testid="chat-kickoff-effect" />
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
Navigate: (props: { to: string }) => {
|
||||
@@ -95,6 +104,23 @@ describe('WorkspaceSetup', () => {
|
||||
expect(getByTestId('header-title')).toHaveTextContent('Ask AI');
|
||||
});
|
||||
|
||||
it('should mount the chat kickoff effect when the post-onboarding hint is set', () => {
|
||||
setIsOnboardingAiChatEnabled(true);
|
||||
jotaiStore.set(shouldOpenAiChatAfterOnboardingState.atom, true);
|
||||
|
||||
const { getByTestId } = render(<WorkspaceSetup />, { wrapper: Wrapper });
|
||||
|
||||
expect(getByTestId('chat-kickoff-effect')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not mount the chat kickoff effect when the post-onboarding hint is not set', () => {
|
||||
setIsOnboardingAiChatEnabled(true);
|
||||
|
||||
const { queryByTestId } = render(<WorkspaceSetup />, { wrapper: Wrapper });
|
||||
|
||||
expect(queryByTestId('chat-kickoff-effect')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should redirect home when the onboarding ai chat is disabled', () => {
|
||||
setIsOnboardingAiChatEnabled(false);
|
||||
|
||||
|
||||
+2
-3
@@ -1,5 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { CompanyEnrichmentResolver } from 'src/engine/core-modules/company-enrichment/resolvers/company-enrichment.resolver';
|
||||
import { CompanyEnrichmentService } from 'src/engine/core-modules/company-enrichment/services/company-enrichment.service';
|
||||
@@ -7,14 +6,14 @@ import { PeopleDataLabsCompanyClientService } from 'src/engine/core-modules/comp
|
||||
import { KeyValuePairModule } from 'src/engine/core-modules/key-value-pair/key-value-pair.module';
|
||||
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([UserWorkspaceEntity]),
|
||||
KeyValuePairModule,
|
||||
SecureHttpClientModule,
|
||||
ThrottlerModule,
|
||||
UserWorkspaceModule,
|
||||
],
|
||||
providers: [
|
||||
CompanyEnrichmentResolver,
|
||||
|
||||
+11
-8
@@ -1,5 +1,4 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { CompanyEnrichmentService } from 'src/engine/core-modules/company-enrichment/services/company-enrichment.service';
|
||||
import { PeopleDataLabsCompanyClientService } from 'src/engine/core-modules/company-enrichment/services/people-data-labs-company-client.service';
|
||||
@@ -12,11 +11,11 @@ import {
|
||||
} from 'src/engine/core-modules/throttler/throttler.exception';
|
||||
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
|
||||
describe('CompanyEnrichmentService', () => {
|
||||
let service: CompanyEnrichmentService;
|
||||
let userWorkspaceRepository: { findOne: jest.Mock };
|
||||
let userWorkspaceService: { isWorkspaceCreator: jest.Mock };
|
||||
let peopleDataLabsCompanyClientService: {
|
||||
enrichCompanyByDomain: jest.Mock;
|
||||
isEnabled: jest.Mock;
|
||||
@@ -29,8 +28,12 @@ describe('CompanyEnrichmentService', () => {
|
||||
const creatorUserId = 'creator-user-id';
|
||||
|
||||
beforeEach(async () => {
|
||||
userWorkspaceRepository = {
|
||||
findOne: jest.fn().mockResolvedValue({ userId: creatorUserId }),
|
||||
userWorkspaceService = {
|
||||
isWorkspaceCreator: jest
|
||||
.fn()
|
||||
.mockImplementation(({ userId }) =>
|
||||
Promise.resolve(userId === creatorUserId),
|
||||
),
|
||||
};
|
||||
peopleDataLabsCompanyClientService = {
|
||||
enrichCompanyByDomain: jest.fn(),
|
||||
@@ -46,8 +49,8 @@ describe('CompanyEnrichmentService', () => {
|
||||
providers: [
|
||||
CompanyEnrichmentService,
|
||||
{
|
||||
provide: getRepositoryToken(UserWorkspaceEntity),
|
||||
useValue: userWorkspaceRepository,
|
||||
provide: UserWorkspaceService,
|
||||
useValue: userWorkspaceService,
|
||||
},
|
||||
{
|
||||
provide: PeopleDataLabsCompanyClientService,
|
||||
@@ -214,7 +217,7 @@ describe('CompanyEnrichmentService', () => {
|
||||
expect(twentyConfigService.get).toHaveBeenCalledWith(
|
||||
'IS_ONBOARDING_AI_CHAT_ENABLED',
|
||||
);
|
||||
expect(userWorkspaceRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(userWorkspaceService.isWorkspaceCreator).not.toHaveBeenCalled();
|
||||
expect(throttlerService.tokenBucketThrottleOrThrow).not.toHaveBeenCalled();
|
||||
expect(
|
||||
peopleDataLabsCompanyClientService.enrichCompanyByDomain,
|
||||
|
||||
+8
-24
@@ -1,10 +1,8 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type WorkspaceCompanyEnrichmentResult } from 'twenty-shared/workspace';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { COMPANY_ENRICHMENT_THROTTLE_MAX_REQUESTS } from 'src/engine/core-modules/company-enrichment/constants/company-enrichment-throttle-max-requests.constant';
|
||||
import { COMPANY_ENRICHMENT_THROTTLE_WINDOW_MS } from 'src/engine/core-modules/company-enrichment/constants/company-enrichment-throttle-window-ms.constant';
|
||||
@@ -23,17 +21,17 @@ import {
|
||||
} from 'src/engine/core-modules/throttler/throttler.exception';
|
||||
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
|
||||
import { isWorkDomain } from 'src/utils/is-work-email';
|
||||
|
||||
@Injectable()
|
||||
// oxlint-disable-next-line twenty/inject-workspace-repository
|
||||
export class CompanyEnrichmentService {
|
||||
private readonly logger = new Logger(CompanyEnrichmentService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly peopleDataLabsCompanyClientService: PeopleDataLabsCompanyClientService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly throttlerService: ThrottlerService,
|
||||
@@ -54,10 +52,11 @@ export class CompanyEnrichmentService {
|
||||
return { outcome: 'unavailable', enrichment: null };
|
||||
}
|
||||
|
||||
const isWorkspaceCreator = await this.isWorkspaceCreator({
|
||||
userId,
|
||||
workspaceId,
|
||||
});
|
||||
const isWorkspaceCreator =
|
||||
await this.userWorkspaceService.isWorkspaceCreator({
|
||||
userId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!isWorkspaceCreator) {
|
||||
return { outcome: 'unavailable', enrichment: null };
|
||||
@@ -190,19 +189,4 @@ export class CompanyEnrichmentService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async isWorkspaceCreator({
|
||||
userId,
|
||||
workspaceId,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<boolean> {
|
||||
const earliestUserWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { workspaceId },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
return earliestUserWorkspace?.userId === userId;
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -459,6 +459,40 @@ describe('UserWorkspaceService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWorkspaceCreator', () => {
|
||||
it('should treat the earliest membership as the creator, including soft-deleted ones', async () => {
|
||||
jest.spyOn(userWorkspaceRepository, 'findOne').mockResolvedValue({
|
||||
userId: 'creator-user-id',
|
||||
} as UserWorkspaceEntity);
|
||||
|
||||
await expect(
|
||||
service.isWorkspaceCreator({
|
||||
userId: 'creator-user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(userWorkspaceRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { workspaceId: 'workspace-id' },
|
||||
order: { createdAt: 'ASC' },
|
||||
withDeleted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false for a later member', async () => {
|
||||
jest.spyOn(userWorkspaceRepository, 'findOne').mockResolvedValue({
|
||||
userId: 'creator-user-id',
|
||||
} as UserWorkspaceEntity);
|
||||
|
||||
await expect(
|
||||
service.isWorkspaceCreator({
|
||||
userId: 'second-user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserCount', () => {
|
||||
it('should return the count of users in a workspace', async () => {
|
||||
const workspaceId = 'workspace-id';
|
||||
|
||||
+16
@@ -72,6 +72,22 @@ export class UserWorkspaceService {
|
||||
return this.userWorkspaceRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async isWorkspaceCreator({
|
||||
userId,
|
||||
workspaceId,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<boolean> {
|
||||
const earliestUserWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { workspaceId },
|
||||
order: { createdAt: 'ASC' },
|
||||
withDeleted: true,
|
||||
});
|
||||
|
||||
return earliestUserWorkspace?.userId === userId;
|
||||
}
|
||||
|
||||
async updateUserWorkspaceLocaleForUserWorkspace({
|
||||
locale,
|
||||
userWorkspaceId,
|
||||
|
||||
@@ -31,6 +31,8 @@ import { AgentChatThreadEntity } from './entities/agent-chat-thread.entity';
|
||||
import { StreamAgentChatJob } from './jobs/stream-agent-chat.job';
|
||||
import { AgentChatResolver } from './resolvers/agent-chat.resolver';
|
||||
import { AgentChatSubscriptionResolver } from './resolvers/agent-chat-subscription.resolver';
|
||||
import { WorkspaceSetupChatResolver } from './resolvers/workspace-setup-chat.resolver';
|
||||
import { WorkspaceSetupChatService } from './services/workspace-setup-chat.service';
|
||||
import { AgentChatCancelSubscriberService } from './services/agent-chat-cancel-subscriber.service';
|
||||
import { AgentChatEventPublisherService } from './services/agent-chat-event-publisher.service';
|
||||
import { AgentChatStreamHeartbeatService } from './services/agent-chat-stream-heartbeat.service';
|
||||
@@ -73,8 +75,10 @@ import { SystemPromptBuilderService } from './services/system-prompt-builder.ser
|
||||
AgentChatStreamHeartbeatService,
|
||||
AgentChatResolver,
|
||||
AgentChatSubscriptionResolver,
|
||||
WorkspaceSetupChatResolver,
|
||||
AgentChatService,
|
||||
AgentChatStreamingService,
|
||||
WorkspaceSetupChatService,
|
||||
AgentTitleGenerationService,
|
||||
ChatExecutionService,
|
||||
MessagePruningService,
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { AgentChatThreadDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto';
|
||||
import { WorkspaceSetupChatOutcome } from 'src/engine/metadata-modules/ai/ai-chat/enums/workspace-setup-chat-outcome.enum';
|
||||
|
||||
@ObjectType('StartWorkspaceSetupChatResult')
|
||||
export class StartWorkspaceSetupChatResultDTO {
|
||||
@Field(() => WorkspaceSetupChatOutcome)
|
||||
outcome: WorkspaceSetupChatOutcome;
|
||||
|
||||
@Field(() => AgentChatThreadDTO, { nullable: true })
|
||||
thread: AgentChatThreadDTO | null;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum WorkspaceSetupChatOutcome {
|
||||
STARTED = 'STARTED',
|
||||
ALREADY_STARTED = 'ALREADY_STARTED',
|
||||
UNAVAILABLE = 'UNAVAILABLE',
|
||||
}
|
||||
|
||||
registerEnumType(WorkspaceSetupChatOutcome, {
|
||||
name: 'WorkspaceSetupChatOutcome',
|
||||
});
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { type WorkspaceCompanyEnrichment } from 'twenty-shared/workspace';
|
||||
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WORKSPACE_COMPANY_ENRICHMENT_SUMMARY_MAX_LENGTH } from 'src/engine/core-modules/company-enrichment/constants/workspace-company-enrichment-summary-max-length.constant';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceSetupChatOutcome } from 'src/engine/metadata-modules/ai/ai-chat/enums/workspace-setup-chat-outcome.enum';
|
||||
import { WorkspaceSetupChatResolver } from 'src/engine/metadata-modules/ai/ai-chat/resolvers/workspace-setup-chat.resolver';
|
||||
|
||||
describe('WorkspaceSetupChatResolver startWorkspaceSetupChat', () => {
|
||||
const workspace = { id: 'workspace-id' } as WorkspaceEntity;
|
||||
const user = { id: 'user-id', locale: 'en' } as AuthContextUser;
|
||||
|
||||
const serviceResult = {
|
||||
outcome: WorkspaceSetupChatOutcome.STARTED,
|
||||
thread: { id: 'thread-id' },
|
||||
};
|
||||
|
||||
const buildResolver = () => {
|
||||
const workspaceSetupChatService = {
|
||||
startWorkspaceSetupChat: jest.fn().mockResolvedValue(serviceResult),
|
||||
};
|
||||
|
||||
const resolver = new WorkspaceSetupChatResolver(
|
||||
workspaceSetupChatService as never,
|
||||
);
|
||||
|
||||
return { resolver, workspaceSetupChatService };
|
||||
};
|
||||
|
||||
const start = (
|
||||
resolver: WorkspaceSetupChatResolver,
|
||||
companyContext: WorkspaceCompanyEnrichment | null,
|
||||
) =>
|
||||
resolver.startWorkspaceSetupChat(
|
||||
companyContext,
|
||||
user,
|
||||
'user-workspace-id',
|
||||
workspace,
|
||||
);
|
||||
|
||||
it('should pass a null company context to the service when the client-supplied object is malformed', async () => {
|
||||
const { resolver, workspaceSetupChatService } = buildResolver();
|
||||
|
||||
await start(resolver, {
|
||||
domain: 42,
|
||||
enrichedAt: true,
|
||||
injectedField: 'ignore me',
|
||||
} as unknown as WorkspaceCompanyEnrichment);
|
||||
|
||||
expect(
|
||||
workspaceSetupChatService.startWorkspaceSetupChat,
|
||||
).toHaveBeenCalledWith({
|
||||
userId: 'user-id',
|
||||
userLocale: 'en',
|
||||
userWorkspaceId: 'user-workspace-id',
|
||||
workspace,
|
||||
companyContext: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass the sanitized enrichment to the service when the company context is valid', async () => {
|
||||
const { resolver, workspaceSetupChatService } = buildResolver();
|
||||
|
||||
await start(resolver, {
|
||||
domain: 'acme.com',
|
||||
enrichedAt: '2026-07-21T10:00:00.000Z',
|
||||
summary: 'a'.repeat(
|
||||
WORKSPACE_COMPANY_ENRICHMENT_SUMMARY_MAX_LENGTH + 100,
|
||||
),
|
||||
employeeCount: 'not-a-number',
|
||||
injectedField: 'ignore me',
|
||||
} as unknown as WorkspaceCompanyEnrichment);
|
||||
|
||||
const { companyContext } =
|
||||
workspaceSetupChatService.startWorkspaceSetupChat.mock.calls[0][0];
|
||||
|
||||
expect(companyContext.domain).toBe('acme.com');
|
||||
expect(companyContext.summary).toHaveLength(
|
||||
WORKSPACE_COMPANY_ENRICHMENT_SUMMARY_MAX_LENGTH,
|
||||
);
|
||||
expect(companyContext.employeeCount).toBeNull();
|
||||
expect(companyContext).not.toHaveProperty('injectedField');
|
||||
});
|
||||
|
||||
it('should return the service result untouched', async () => {
|
||||
const { resolver } = buildResolver();
|
||||
|
||||
await expect(start(resolver, null)).resolves.toBe(serviceResult);
|
||||
});
|
||||
});
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Args, Mutation } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { type WorkspaceCompanyEnrichment } from 'twenty-shared/workspace';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { sanitizeWorkspaceCompanyEnrichment } from 'src/engine/core-modules/company-enrichment/utils/sanitize-workspace-company-enrichment.util';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { StartWorkspaceSetupChatResultDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/start-workspace-setup-chat-result.dto';
|
||||
import { WorkspaceSetupChatService } from 'src/engine/metadata-modules/ai/ai-chat/services/workspace-setup-chat.service';
|
||||
import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor';
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
UserAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.AI),
|
||||
)
|
||||
@UseInterceptors(AiGraphqlApiExceptionInterceptor)
|
||||
@MetadataResolver()
|
||||
export class WorkspaceSetupChatResolver {
|
||||
constructor(
|
||||
private readonly workspaceSetupChatService: WorkspaceSetupChatService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => StartWorkspaceSetupChatResultDTO)
|
||||
async startWorkspaceSetupChat(
|
||||
@Args('companyContext', { type: () => GraphQLJSON, nullable: true })
|
||||
companyContext: WorkspaceCompanyEnrichment | null,
|
||||
@AuthUser() user: AuthContextUser,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
) {
|
||||
return this.workspaceSetupChatService.startWorkspaceSetupChat({
|
||||
userId: user.id,
|
||||
userLocale: user.locale,
|
||||
userWorkspaceId,
|
||||
workspace,
|
||||
companyContext: sanitizeWorkspaceCompanyEnrichment(companyContext),
|
||||
});
|
||||
}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import {
|
||||
AgentMessageRole,
|
||||
AgentMessageStatus,
|
||||
type AgentMessageEntity,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
|
||||
import { type AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
import { AgentChatStreamingService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service';
|
||||
import { AiExceptionCode } from 'src/engine/metadata-modules/ai/ai.exception';
|
||||
|
||||
describe('AgentChatStreamingService.startHiddenKickoffStream', () => {
|
||||
const workspace = { id: 'workspace-id' } as WorkspaceEntity;
|
||||
const kickoffText = 'Set up the workspace for Acme Inc';
|
||||
|
||||
const kickoffThread = {
|
||||
id: 'thread-id',
|
||||
title: 'Workspace setup',
|
||||
conversationSize: 0,
|
||||
activeStreamId: null,
|
||||
lastStreamError: null,
|
||||
pendingQuestionMessageId: null,
|
||||
} as unknown as AgentChatThreadEntity;
|
||||
|
||||
const hiddenKickoffMessageEntity = {
|
||||
id: 'kickoff-message-id',
|
||||
role: AgentMessageRole.USER,
|
||||
status: AgentMessageStatus.SENT,
|
||||
isHidden: true,
|
||||
parts: [{ type: 'text', textContent: kickoffText, orderIndex: 0 }],
|
||||
} as unknown as AgentMessageEntity;
|
||||
|
||||
const buildService = ({
|
||||
claimAffected = 1,
|
||||
hasConversationMessages = false,
|
||||
threadMessages = [hiddenKickoffMessageEntity],
|
||||
} = {}) => {
|
||||
const threadRepository = {
|
||||
findOne: jest.fn().mockResolvedValue(kickoffThread),
|
||||
update: jest.fn().mockResolvedValue({ affected: claimAffected }),
|
||||
};
|
||||
const messageQueueService = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const agentChatService = {
|
||||
hasConversationMessages: jest
|
||||
.fn()
|
||||
.mockResolvedValue(hasConversationMessages),
|
||||
ensureHiddenKickoffMessage: jest.fn().mockResolvedValue({
|
||||
id: 'kickoff-message-id',
|
||||
turnId: 'kickoff-turn-id',
|
||||
}),
|
||||
getMessagesForThread: jest.fn().mockResolvedValue(threadMessages),
|
||||
getQueuedMessages: jest.fn().mockResolvedValue([]),
|
||||
queueMessage: jest.fn().mockResolvedValue({ id: 'queued-message-id' }),
|
||||
notifyThreadActivityUpdated: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const streamHeartbeatService = {
|
||||
markClaimed: jest.fn().mockResolvedValue(undefined),
|
||||
isAlive: jest.fn().mockResolvedValue(true),
|
||||
clear: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const metricsService = { incrementCounterBy: jest.fn() };
|
||||
|
||||
const service = new AgentChatStreamingService(
|
||||
threadRepository as never,
|
||||
{ find: jest.fn().mockResolvedValue([]) } as never,
|
||||
messageQueueService as never,
|
||||
agentChatService as never,
|
||||
{ publish: jest.fn().mockResolvedValue(undefined) } as never,
|
||||
{ signFileByIdUrl: jest.fn() } as never,
|
||||
streamHeartbeatService as never,
|
||||
metricsService as never,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
threadRepository,
|
||||
messageQueueService,
|
||||
agentChatService,
|
||||
streamHeartbeatService,
|
||||
metricsService,
|
||||
};
|
||||
};
|
||||
|
||||
const kickoffArguments = {
|
||||
thread: kickoffThread,
|
||||
userWorkspaceId: 'user-workspace-id',
|
||||
workspace,
|
||||
text: kickoffText,
|
||||
};
|
||||
|
||||
it('should return null without queueing a visible copy when the claim is lost', async () => {
|
||||
const {
|
||||
service,
|
||||
agentChatService,
|
||||
messageQueueService,
|
||||
streamHeartbeatService,
|
||||
} = buildService({ claimAffected: 0 });
|
||||
|
||||
const result = await service.startHiddenKickoffStream(kickoffArguments);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(agentChatService.queueMessage).not.toHaveBeenCalled();
|
||||
expect(agentChatService.ensureHiddenKickoffMessage).not.toHaveBeenCalled();
|
||||
expect(messageQueueService.add).not.toHaveBeenCalled();
|
||||
expect(streamHeartbeatService.clear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should release the claim, flush the queue and return null when the thread already has conversation messages', async () => {
|
||||
const { service, threadRepository, agentChatService, messageQueueService } =
|
||||
buildService({ hasConversationMessages: true });
|
||||
|
||||
const result = await service.startHiddenKickoffStream(kickoffArguments);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(agentChatService.ensureHiddenKickoffMessage).not.toHaveBeenCalled();
|
||||
expect(messageQueueService.add).not.toHaveBeenCalled();
|
||||
expect(threadRepository.update).toHaveBeenCalledWith(
|
||||
'workspace-id',
|
||||
{ id: 'thread-id', activeStreamId: expect.any(String) },
|
||||
{ activeStreamId: null },
|
||||
);
|
||||
expect(agentChatService.getQueuedMessages).toHaveBeenCalledWith({
|
||||
threadId: 'thread-id',
|
||||
workspaceId: 'workspace-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should enqueue the hidden kickoff turn without a pinned model and without notifying thread activity', async () => {
|
||||
const { service, threadRepository, agentChatService, messageQueueService } =
|
||||
buildService();
|
||||
|
||||
const result = await service.startHiddenKickoffStream(kickoffArguments);
|
||||
|
||||
expect(agentChatService.ensureHiddenKickoffMessage).toHaveBeenCalledWith({
|
||||
threadId: 'thread-id',
|
||||
workspaceId: 'workspace-id',
|
||||
text: kickoffText,
|
||||
});
|
||||
expect(agentChatService.getMessagesForThread).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ includeHidden: true }),
|
||||
);
|
||||
expect(messageQueueService.add).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
threadId: 'thread-id',
|
||||
browsingContext: null,
|
||||
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),
|
||||
messageId: 'kickoff-message-id',
|
||||
turnId: 'kickoff-turn-id',
|
||||
});
|
||||
expect(threadRepository.update).toHaveBeenCalledWith(
|
||||
'workspace-id',
|
||||
expect.objectContaining({ id: 'thread-id' }),
|
||||
{ activeStreamId: result?.streamId, lastStreamError: null },
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw MESSAGE_NOT_FOUND and release the claim when the loaded messages do not end with the kickoff message', async () => {
|
||||
const staleMessageEntity = {
|
||||
...hiddenKickoffMessageEntity,
|
||||
id: 'other-message-id',
|
||||
} as unknown as AgentMessageEntity;
|
||||
const { service, threadRepository, messageQueueService } = buildService({
|
||||
threadMessages: [staleMessageEntity],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.startHiddenKickoffStream(kickoffArguments),
|
||||
).rejects.toMatchObject({ code: AiExceptionCode.MESSAGE_NOT_FOUND });
|
||||
expect(messageQueueService.add).not.toHaveBeenCalled();
|
||||
expect(threadRepository.update).toHaveBeenLastCalledWith(
|
||||
'workspace-id',
|
||||
{ id: 'thread-id', activeStreamId: expect.any(String) },
|
||||
{ activeStreamId: null },
|
||||
);
|
||||
});
|
||||
|
||||
it('should release the claim and report an enqueue failure when adding the job fails', async () => {
|
||||
const {
|
||||
service,
|
||||
threadRepository,
|
||||
messageQueueService,
|
||||
streamHeartbeatService,
|
||||
metricsService,
|
||||
} = buildService();
|
||||
|
||||
messageQueueService.add.mockRejectedValue(new Error('redis down'));
|
||||
|
||||
await expect(
|
||||
service.startHiddenKickoffStream(kickoffArguments),
|
||||
).rejects.toThrow('redis down');
|
||||
|
||||
expect(threadRepository.update).toHaveBeenLastCalledWith(
|
||||
'workspace-id',
|
||||
{ id: 'thread-id', activeStreamId: expect.any(String) },
|
||||
{ activeStreamId: null },
|
||||
);
|
||||
expect(streamHeartbeatService.clear).toHaveBeenCalled();
|
||||
expect(metricsService.incrementCounterBy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
attributes: expect.objectContaining({ failure_phase: 'enqueue' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+48
-1
@@ -33,7 +33,11 @@ describe('AgentChatStreamingService.retryLastFailedTurn', () => {
|
||||
|
||||
const buildService = ({
|
||||
thread = failedThread,
|
||||
lastUserMessage = { id: 'user-message-id', turnId: 'turn-id' },
|
||||
lastUserMessage = { id: 'user-message-id', turnId: 'turn-id' } as {
|
||||
id: string;
|
||||
turnId: string;
|
||||
processedAt?: Date;
|
||||
},
|
||||
threadMessages = [userMessageEntity],
|
||||
} = {}) => {
|
||||
const threadRepository = {
|
||||
@@ -156,4 +160,47 @@ describe('AgentChatStreamingService.retryLastFailedTurn', () => {
|
||||
expect(result.messageId).toBe('user-message-id');
|
||||
expect(result.turnId).toBe('turn-id');
|
||||
});
|
||||
|
||||
it('should retry the hidden kickoff turn when the thread only contains the kickoff message', async () => {
|
||||
const hiddenKickoffMessageEntity = {
|
||||
id: 'kickoff-message-id',
|
||||
role: AgentMessageRole.USER,
|
||||
status: AgentMessageStatus.SENT,
|
||||
isHidden: true,
|
||||
parts: [{ type: 'text', textContent: 'kickoff prompt', orderIndex: 0 }],
|
||||
} as unknown as AgentMessageEntity;
|
||||
const { service, threadRepository, messageQueueService, agentChatService } =
|
||||
buildService({
|
||||
lastUserMessage: {
|
||||
id: 'kickoff-message-id',
|
||||
turnId: 'kickoff-turn-id',
|
||||
processedAt: new Date('2026-01-01T00:00:01.000Z'),
|
||||
},
|
||||
threadMessages: [hiddenKickoffMessageEntity],
|
||||
});
|
||||
|
||||
const result = await service.retryLastFailedTurn(retryArguments);
|
||||
|
||||
expect(threadRepository.update).toHaveBeenCalledWith(
|
||||
'workspace-id',
|
||||
expect.objectContaining({ id: 'thread-id' }),
|
||||
{ activeStreamId: result.streamId, lastStreamError: null },
|
||||
);
|
||||
expect(
|
||||
agentChatService.deleteAssistantMessagesForTurn,
|
||||
).toHaveBeenCalledWith({
|
||||
turnId: 'kickoff-turn-id',
|
||||
workspaceId: 'workspace-id',
|
||||
});
|
||||
expect(messageQueueService.add).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
existingTurnId: 'kickoff-turn-id',
|
||||
lastUserMessageText: 'kickoff prompt',
|
||||
hasTitle: true,
|
||||
}),
|
||||
);
|
||||
expect(result.messageId).toBe('kickoff-message-id');
|
||||
expect(result.turnId).toBe('kickoff-turn-id');
|
||||
});
|
||||
});
|
||||
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
AgentMessageRole,
|
||||
AgentMessageStatus,
|
||||
} from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
|
||||
import { AgentChatService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service';
|
||||
|
||||
const WORKSPACE_ID = 'workspace-id';
|
||||
const THREAD_ID = 'thread-id';
|
||||
const KICKOFF_TEXT = 'kickoff prompt text';
|
||||
|
||||
const buildService = ({ existingHiddenMessage = null as unknown } = {}) => {
|
||||
const threadRepository = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: THREAD_ID }),
|
||||
};
|
||||
const messageRepository = {
|
||||
findOne: jest.fn().mockResolvedValue(existingHiddenMessage),
|
||||
insert: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ identifiers: [{ id: 'kickoff-message-id' }] }),
|
||||
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
const turnRepository = {
|
||||
insert: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ identifiers: [{ id: 'kickoff-turn-id' }] }),
|
||||
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
const messagePartRepository = { insert: jest.fn().mockResolvedValue({}) };
|
||||
|
||||
const service = new AgentChatService(
|
||||
threadRepository as never,
|
||||
turnRepository as never,
|
||||
messageRepository as never,
|
||||
messagePartRepository as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
return { service, messageRepository, turnRepository, messagePartRepository };
|
||||
};
|
||||
|
||||
const ensureKickoff = (service: AgentChatService) =>
|
||||
service.ensureHiddenKickoffMessage({
|
||||
threadId: THREAD_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
text: KICKOFF_TEXT,
|
||||
});
|
||||
|
||||
describe('AgentChatService ensureHiddenKickoffMessage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should insert a hidden kickoff message with its own turn when none exists', async () => {
|
||||
const {
|
||||
service,
|
||||
messageRepository,
|
||||
turnRepository,
|
||||
messagePartRepository,
|
||||
} = buildService();
|
||||
|
||||
const result = await ensureKickoff(service);
|
||||
|
||||
expect(messageRepository.findOne).toHaveBeenCalledWith(WORKSPACE_ID, {
|
||||
where: { threadId: THREAD_ID, isHidden: true },
|
||||
relations: ['parts'],
|
||||
});
|
||||
expect(turnRepository.insert).toHaveBeenCalledWith(WORKSPACE_ID, {
|
||||
threadId: THREAD_ID,
|
||||
agentId: null,
|
||||
});
|
||||
expect(messageRepository.insert).toHaveBeenCalledWith(
|
||||
WORKSPACE_ID,
|
||||
expect.objectContaining({
|
||||
threadId: THREAD_ID,
|
||||
turnId: 'kickoff-turn-id',
|
||||
role: AgentMessageRole.USER,
|
||||
isHidden: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const [, insertedMessage] = messageRepository.insert.mock.calls[0];
|
||||
|
||||
expect(insertedMessage.processedAt).toBeInstanceOf(Date);
|
||||
expect(insertedMessage.processedAt.getTime()).toBeGreaterThan(0);
|
||||
|
||||
const [, insertedParts] = messagePartRepository.insert.mock.calls[0];
|
||||
|
||||
expect(insertedParts).toEqual([
|
||||
expect.objectContaining({ type: 'text', textContent: KICKOFF_TEXT }),
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'kickoff-message-id',
|
||||
turnId: 'kickoff-turn-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reuse the existing hidden message when one with parts already exists', async () => {
|
||||
const { service, messageRepository, turnRepository } = buildService({
|
||||
existingHiddenMessage: {
|
||||
id: 'existing-id',
|
||||
turnId: 'existing-turn-id',
|
||||
parts: [{ id: 'part-id' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureKickoff(service);
|
||||
|
||||
expect(result).toEqual({ id: 'existing-id', turnId: 'existing-turn-id' });
|
||||
expect(turnRepository.insert).not.toHaveBeenCalled();
|
||||
expect(messageRepository.insert).not.toHaveBeenCalled();
|
||||
expect(messageRepository.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should replace a part-less hidden row and its orphan turn with a fresh kickoff message', async () => {
|
||||
const { service, messageRepository, turnRepository } = buildService({
|
||||
existingHiddenMessage: {
|
||||
id: 'partial-id',
|
||||
turnId: 'partial-turn-id',
|
||||
parts: [],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureKickoff(service);
|
||||
|
||||
expect(messageRepository.delete).toHaveBeenCalledWith(WORKSPACE_ID, {
|
||||
id: 'partial-id',
|
||||
});
|
||||
expect(turnRepository.delete).toHaveBeenCalledWith(WORKSPACE_ID, {
|
||||
id: 'partial-turn-id',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: 'kickoff-message-id',
|
||||
turnId: 'kickoff-turn-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace a turn-less hidden row without touching the turns', async () => {
|
||||
const { service, messageRepository, turnRepository } = buildService({
|
||||
existingHiddenMessage: {
|
||||
id: 'turn-less-id',
|
||||
turnId: null,
|
||||
parts: [{ id: 'part-id' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureKickoff(service);
|
||||
|
||||
expect(messageRepository.delete).toHaveBeenCalledWith(WORKSPACE_ID, {
|
||||
id: 'turn-less-id',
|
||||
});
|
||||
expect(turnRepository.delete).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
id: 'kickoff-message-id',
|
||||
turnId: 'kickoff-turn-id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentChatService findLatestSentUserMessage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should look up sent user messages without an isHidden predicate and order by processedAt then createdAt then id', async () => {
|
||||
const { service, messageRepository } = buildService();
|
||||
|
||||
await service.findLatestSentUserMessage({
|
||||
threadId: THREAD_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
expect(messageRepository.findOne).toHaveBeenCalledWith(WORKSPACE_ID, {
|
||||
where: {
|
||||
threadId: THREAD_ID,
|
||||
role: AgentMessageRole.USER,
|
||||
status: AgentMessageStatus.SENT,
|
||||
},
|
||||
order: {
|
||||
processedAt: { direction: 'DESC', nulls: 'LAST' },
|
||||
createdAt: 'DESC',
|
||||
id: 'DESC',
|
||||
},
|
||||
select: ['id', 'turnId'],
|
||||
});
|
||||
|
||||
const [, findOneOptions] = messageRepository.findOne.mock.calls[0];
|
||||
|
||||
expect(findOneOptions.where).not.toHaveProperty('isHidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentChatService hasConversationMessages', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return true when a visible message exists', async () => {
|
||||
const { service, messageRepository } = buildService();
|
||||
|
||||
messageRepository.findOne.mockResolvedValue({ id: 'visible-message-id' });
|
||||
|
||||
await expect(
|
||||
service.hasConversationMessages({
|
||||
threadId: THREAD_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(messageRepository.findOne).toHaveBeenCalledWith(WORKSPACE_ID, {
|
||||
where: { threadId: THREAD_ID, isHidden: false },
|
||||
select: ['id'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false when no visible message exists', async () => {
|
||||
const { service, messageRepository } = buildService();
|
||||
|
||||
messageRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.hasConversationMessages({
|
||||
threadId: THREAD_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type WorkspaceCompanyEnrichment } from 'twenty-shared/workspace';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import { v5 } from 'uuid';
|
||||
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceSetupChatOutcome } from 'src/engine/metadata-modules/ai/ai-chat/enums/workspace-setup-chat-outcome.enum';
|
||||
import { WorkspaceSetupChatService } from 'src/engine/metadata-modules/ai/ai-chat/services/workspace-setup-chat.service';
|
||||
import { tagAiChatStreamScope } from 'src/engine/metadata-modules/ai/ai-chat/utils/tag-ai-chat-stream-scope.util';
|
||||
|
||||
jest.mock(
|
||||
'src/engine/metadata-modules/ai/ai-chat/utils/tag-ai-chat-stream-scope.util',
|
||||
() => ({
|
||||
tagAiChatStreamScope: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
// Pinned: changing the namespace or the name derivation would orphan the setup
|
||||
// threads already created from it.
|
||||
const WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE =
|
||||
'1e9195f3-c26a-4bfc-961e-dc317b4badbd';
|
||||
|
||||
const EXPECTED_THREAD_ID = v5(
|
||||
'workspace-id:user-workspace-id',
|
||||
WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE,
|
||||
);
|
||||
|
||||
describe('WorkspaceSetupChatService', () => {
|
||||
const workspace = {
|
||||
id: 'workspace-id',
|
||||
} as WorkspaceEntity;
|
||||
|
||||
const startArguments = {
|
||||
userId: 'creator-user-id',
|
||||
userLocale: 'en',
|
||||
userWorkspaceId: 'user-workspace-id',
|
||||
workspace,
|
||||
companyContext: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const buildService = () => {
|
||||
const workspaceMemberState: { locale: string | null } = {
|
||||
locale: 'fr-FR',
|
||||
};
|
||||
|
||||
const twentyConfigService = {
|
||||
get: jest.fn().mockReturnValue(true),
|
||||
};
|
||||
const billingUsageService = {
|
||||
hasAvailableCredits: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const aiModelRegistryService = {
|
||||
getAvailableModels: jest
|
||||
.fn()
|
||||
.mockReturnValue([{ modelId: 'smart-model-id' }]),
|
||||
};
|
||||
const userWorkspaceService = {
|
||||
isWorkspaceCreator: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const translate = jest.fn().mockReturnValue('translated-workspace-setup');
|
||||
const i18nService = {
|
||||
getI18nInstance: jest.fn().mockReturnValue({ _: translate }),
|
||||
};
|
||||
const agentChatService = {
|
||||
findThreadById: jest.fn().mockResolvedValue(null),
|
||||
createThread: jest.fn().mockImplementation(({ id, title }) =>
|
||||
Promise.resolve({
|
||||
id,
|
||||
title,
|
||||
deletedAt: null,
|
||||
activeStreamId: null,
|
||||
}),
|
||||
),
|
||||
unarchiveThread: jest.fn().mockImplementation(({ threadId }) =>
|
||||
Promise.resolve({
|
||||
id: threadId,
|
||||
deletedAt: null,
|
||||
activeStreamId: null,
|
||||
}),
|
||||
),
|
||||
hasConversationMessages: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
const agentChatStreamingService = {
|
||||
reapDeadStream: jest.fn().mockResolvedValue(null),
|
||||
startHiddenKickoffStream: jest.fn().mockResolvedValue({
|
||||
streamId: 'stream-id',
|
||||
messageId: 'message-id',
|
||||
turnId: 'turn-id',
|
||||
}),
|
||||
};
|
||||
const globalWorkspaceOrmManager = {
|
||||
executeInWorkspaceContext: jest
|
||||
.fn()
|
||||
.mockImplementation((callback: () => unknown) => callback()),
|
||||
getRepository: jest.fn().mockResolvedValue({
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
isDefined(workspaceMemberState.locale)
|
||||
? { locale: workspaceMemberState.locale }
|
||||
: null,
|
||||
),
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new WorkspaceSetupChatService(
|
||||
twentyConfigService as never,
|
||||
billingUsageService as never,
|
||||
aiModelRegistryService as never,
|
||||
userWorkspaceService as never,
|
||||
i18nService as never,
|
||||
agentChatService as never,
|
||||
agentChatStreamingService as never,
|
||||
globalWorkspaceOrmManager as never,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
workspaceMemberState,
|
||||
twentyConfigService,
|
||||
billingUsageService,
|
||||
aiModelRegistryService,
|
||||
userWorkspaceService,
|
||||
i18nService,
|
||||
translate,
|
||||
agentChatService,
|
||||
agentChatStreamingService,
|
||||
globalWorkspaceOrmManager,
|
||||
};
|
||||
};
|
||||
|
||||
it('should return unavailable without any thread interaction when the onboarding ai chat is disabled', async () => {
|
||||
const { service, twentyConfigService, agentChatService } = buildService();
|
||||
|
||||
twentyConfigService.get.mockReturnValue(false);
|
||||
|
||||
const result = await service.startWorkspaceSetupChat(startArguments);
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: WorkspaceSetupChatOutcome.UNAVAILABLE,
|
||||
thread: null,
|
||||
});
|
||||
expect(twentyConfigService.get).toHaveBeenCalledWith(
|
||||
'IS_ONBOARDING_AI_CHAT_ENABLED',
|
||||
);
|
||||
expect(agentChatService.findThreadById).not.toHaveBeenCalled();
|
||||
expect(agentChatService.createThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return unavailable when the caller is not the workspace creator', async () => {
|
||||
const { service, userWorkspaceService, agentChatService } = buildService();
|
||||
|
||||
userWorkspaceService.isWorkspaceCreator.mockResolvedValue(false);
|
||||
|
||||
const result = await service.startWorkspaceSetupChat(startArguments);
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: WorkspaceSetupChatOutcome.UNAVAILABLE,
|
||||
thread: null,
|
||||
});
|
||||
expect(userWorkspaceService.isWorkspaceCreator).toHaveBeenCalledWith({
|
||||
userId: 'creator-user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
});
|
||||
expect(agentChatService.createThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return unavailable when no ai models are available', async () => {
|
||||
const { service, aiModelRegistryService, agentChatService } =
|
||||
buildService();
|
||||
|
||||
aiModelRegistryService.getAvailableModels.mockReturnValue([]);
|
||||
|
||||
const result = await service.startWorkspaceSetupChat(startArguments);
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: WorkspaceSetupChatOutcome.UNAVAILABLE,
|
||||
thread: null,
|
||||
});
|
||||
expect(agentChatService.createThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return unavailable without creating a thread when the workspace has no available credits', async () => {
|
||||
const { service, billingUsageService, agentChatService } = buildService();
|
||||
|
||||
billingUsageService.hasAvailableCredits.mockResolvedValue(false);
|
||||
|
||||
const result = await service.startWorkspaceSetupChat(startArguments);
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: WorkspaceSetupChatOutcome.UNAVAILABLE,
|
||||
thread: null,
|
||||
});
|
||||
expect(agentChatService.createThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create the thread under its deterministic id with a translated title and start the hidden kickoff stream', async () => {
|
||||
const {
|
||||
service,
|
||||
i18nService,
|
||||
translate,
|
||||
agentChatService,
|
||||
agentChatStreamingService,
|
||||
} = buildService();
|
||||
|
||||
const result = await service.startWorkspaceSetupChat(startArguments);
|
||||
|
||||
expect(agentChatService.findThreadById).toHaveBeenCalledWith({
|
||||
threadId: EXPECTED_THREAD_ID,
|
||||
userWorkspaceId: 'user-workspace-id',
|
||||
workspaceId: 'workspace-id',
|
||||
});
|
||||
expect(i18nService.getI18nInstance).toHaveBeenCalledWith('fr-FR');
|
||||
expect(translate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Workspace setup' }),
|
||||
);
|
||||
expect(agentChatService.createThread).toHaveBeenCalledWith({
|
||||
userWorkspaceId: 'user-workspace-id',
|
||||
workspaceId: 'workspace-id',
|
||||
id: EXPECTED_THREAD_ID,
|
||||
title: 'translated-workspace-setup',
|
||||
});
|
||||
expect(
|
||||
agentChatStreamingService.startHiddenKickoffStream,
|
||||
).toHaveBeenCalledWith({
|
||||
thread: expect.objectContaining({ id: EXPECTED_THREAD_ID }),
|
||||
userWorkspaceId: 'user-workspace-id',
|
||||
workspace,
|
||||
text: expect.stringContaining(
|
||||
'No information about the company that owns this workspace is available.',
|
||||
),
|
||||
});
|
||||
expect(result).toEqual({
|
||||
outcome: WorkspaceSetupChatOutcome.STARTED,
|
||||
thread: expect.objectContaining({
|
||||
id: EXPECTED_THREAD_ID,
|
||||
title: 'translated-workspace-setup',
|
||||
}),
|
||||
});
|
||||
expect(tagAiChatStreamScope).toHaveBeenCalledWith({
|
||||
streamId: 'stream-id',
|
||||
turnId: 'turn-id',
|
||||
threadId: EXPECTED_THREAD_ID,
|
||||
workspaceId: 'workspace-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reuse the concurrently created thread when the insert hits a unique violation', async () => {
|
||||
const { service, agentChatService, agentChatStreamingService } =
|
||||
buildService();
|
||||
|
||||
const concurrentlyCreatedThread = {
|
||||
id: EXPECTED_THREAD_ID,
|
||||
deletedAt: null,
|
||||
activeStreamId: null,
|
||||
};
|
||||
|
||||
agentChatService.findThreadById
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValue(concurrentlyCreatedThread);
|
||||
agentChatService.createThread.mockRejectedValue(
|
||||
Object.assign(
|
||||
new QueryFailedError('INSERT', [], new Error('duplicate key')),
|
||||
{ code: '23505' },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await service.startWorkspaceSetupChat(startArguments);
|
||||
|
||||
expect(agentChatService.createThread).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
agentChatStreamingService.startHiddenKickoffStream,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ thread: concurrentlyCreatedThread }),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
outcome: WorkspaceSetupChatOutcome.STARTED,
|
||||
thread: concurrentlyCreatedThread,
|
||||
});
|
||||
});
|
||||
|
||||
it('should rethrow a create failure that is not a unique violation', async () => {
|
||||
const { service, agentChatService, agentChatStreamingService } =
|
||||
buildService();
|
||||
|
||||
agentChatService.createThread.mockRejectedValue(
|
||||
new Error('connection lost'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.startWorkspaceSetupChat(startArguments),
|
||||
).rejects.toThrow('connection lost');
|
||||
|
||||
expect(
|
||||
agentChatStreamingService.startHiddenKickoffStream,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should build the prompt with the workspace member locale rather than the user one', async () => {
|
||||
const { service, agentChatStreamingService } = buildService();
|
||||
|
||||
await service.startWorkspaceSetupChat(startArguments);
|
||||
|
||||
const kickoffText =
|
||||
agentChatStreamingService.startHiddenKickoffStream.mock.calls[0][0].text;
|
||||
|
||||
expect(kickoffText).toContain(
|
||||
'The user locale is French, please continue the discussion in that language.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to the calling user locale when the workspace member has none', async () => {
|
||||
const {
|
||||
service,
|
||||
workspaceMemberState,
|
||||
i18nService,
|
||||
agentChatStreamingService,
|
||||
} = buildService();
|
||||
|
||||
workspaceMemberState.locale = null;
|
||||
|
||||
await service.startWorkspaceSetupChat(startArguments);
|
||||
|
||||
const kickoffText =
|
||||
agentChatStreamingService.startHiddenKickoffStream.mock.calls[0][0].text;
|
||||
|
||||
expect(kickoffText).toContain('The user locale is English');
|
||||
expect(i18nService.getI18nInstance).toHaveBeenCalledWith('en');
|
||||
});
|
||||
|
||||
it('should embed the company context and the proposal instructions in the hidden prompt', async () => {
|
||||
const { service, agentChatStreamingService } = buildService();
|
||||
|
||||
const companyContext = {
|
||||
domain: 'acme.com',
|
||||
enrichedAt: '2026-07-21T10:00:00.000Z',
|
||||
name: 'Acme Inc',
|
||||
website: 'https://acme.com',
|
||||
industry: 'computer software',
|
||||
employeeCount: 250,
|
||||
size: '51-200',
|
||||
founded: 2015,
|
||||
headline: 'Anvils as a service',
|
||||
summary: 'Acme sells anvils to coyotes.',
|
||||
tags: ['saas', 'b2b'],
|
||||
locality: 'San Francisco',
|
||||
region: 'California',
|
||||
country: 'United States',
|
||||
} satisfies WorkspaceCompanyEnrichment;
|
||||
|
||||
await service.startWorkspaceSetupChat({
|
||||
...startArguments,
|
||||
companyContext,
|
||||
});
|
||||
|
||||
const kickoffText =
|
||||
agentChatStreamingService.startHiddenKickoffStream.mock.calls[0][0].text;
|
||||
|
||||
expect(kickoffText).toContain('Domain: acme.com');
|
||||
expect(kickoffText).toContain('Name: Acme Inc');
|
||||
expect(kickoffText).toContain('Industry: computer software');
|
||||
expect(kickoffText).toContain('tailored to their business');
|
||||
expect(kickoffText).toContain(
|
||||
'Only propose until the user explicitly approves',
|
||||
);
|
||||
expect(kickoffText).toContain('metadata-building');
|
||||
expect(kickoffText).toContain(
|
||||
'The user locale is French, please continue the discussion in that language.',
|
||||
);
|
||||
expect(kickoffText).not.toContain('No information about the company');
|
||||
});
|
||||
|
||||
it('should return alreadyStarted without a credit check when the thread already has conversation messages', async () => {
|
||||
const {
|
||||
service,
|
||||
billingUsageService,
|
||||
agentChatService,
|
||||
agentChatStreamingService,
|
||||
} = buildService();
|
||||
|
||||
const existingThread = {
|
||||
id: EXPECTED_THREAD_ID,
|
||||
deletedAt: null,
|
||||
activeStreamId: null,
|
||||
};
|
||||
|
||||
agentChatService.findThreadById.mockResolvedValue(existingThread);
|
||||
agentChatService.hasConversationMessages.mockResolvedValue(true);
|
||||
billingUsageService.hasAvailableCredits.mockResolvedValue(false);
|
||||
|
||||
const result = await service.startWorkspaceSetupChat(startArguments);
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: WorkspaceSetupChatOutcome.ALREADY_STARTED,
|
||||
thread: existingThread,
|
||||
});
|
||||
expect(billingUsageService.hasAvailableCredits).not.toHaveBeenCalled();
|
||||
expect(
|
||||
agentChatStreamingService.startHiddenKickoffStream,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not start a stream on an empty existing thread when credits ran out', async () => {
|
||||
const {
|
||||
service,
|
||||
billingUsageService,
|
||||
agentChatService,
|
||||
agentChatStreamingService,
|
||||
} = buildService();
|
||||
|
||||
agentChatService.findThreadById.mockResolvedValue({
|
||||
id: EXPECTED_THREAD_ID,
|
||||
deletedAt: null,
|
||||
activeStreamId: null,
|
||||
});
|
||||
billingUsageService.hasAvailableCredits.mockResolvedValue(false);
|
||||
|
||||
const result = await service.startWorkspaceSetupChat(startArguments);
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: WorkspaceSetupChatOutcome.UNAVAILABLE,
|
||||
thread: null,
|
||||
});
|
||||
expect(agentChatService.createThread).not.toHaveBeenCalled();
|
||||
expect(
|
||||
agentChatStreamingService.startHiddenKickoffStream,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return alreadyStarted without kicking off when the active stream is still alive', async () => {
|
||||
const { service, agentChatService, agentChatStreamingService } =
|
||||
buildService();
|
||||
|
||||
const streamingThread = {
|
||||
id: EXPECTED_THREAD_ID,
|
||||
deletedAt: null,
|
||||
activeStreamId: 'active-stream-id',
|
||||
};
|
||||
|
||||
agentChatService.findThreadById.mockResolvedValue(streamingThread);
|
||||
|
||||
const result = await service.startWorkspaceSetupChat(startArguments);
|
||||
|
||||
expect(agentChatStreamingService.reapDeadStream).toHaveBeenCalledWith({
|
||||
thread: streamingThread,
|
||||
workspaceId: 'workspace-id',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
outcome: WorkspaceSetupChatOutcome.ALREADY_STARTED,
|
||||
thread: streamingThread,
|
||||
});
|
||||
expect(agentChatService.hasConversationMessages).not.toHaveBeenCalled();
|
||||
expect(
|
||||
agentChatStreamingService.startHiddenKickoffStream,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should kick off again when the active stream is dead', async () => {
|
||||
const { service, agentChatService, agentChatStreamingService } =
|
||||
buildService();
|
||||
|
||||
const interruptedThread = {
|
||||
id: EXPECTED_THREAD_ID,
|
||||
deletedAt: null,
|
||||
activeStreamId: 'dead-stream-id',
|
||||
};
|
||||
|
||||
agentChatService.findThreadById.mockResolvedValue(interruptedThread);
|
||||
agentChatStreamingService.reapDeadStream.mockResolvedValue({
|
||||
code: 'CONNECTION_LOST',
|
||||
message: 'interrupted',
|
||||
});
|
||||
|
||||
const result = await service.startWorkspaceSetupChat(startArguments);
|
||||
|
||||
expect(
|
||||
agentChatStreamingService.startHiddenKickoffStream,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ thread: interruptedThread }),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
outcome: WorkspaceSetupChatOutcome.STARTED,
|
||||
thread: interruptedThread,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return alreadyStarted when the hidden kickoff stream claim is lost', async () => {
|
||||
const { service, agentChatStreamingService } = buildService();
|
||||
|
||||
agentChatStreamingService.startHiddenKickoffStream.mockResolvedValue(null);
|
||||
|
||||
const result = await service.startWorkspaceSetupChat(startArguments);
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: WorkspaceSetupChatOutcome.ALREADY_STARTED,
|
||||
thread: expect.objectContaining({ id: EXPECTED_THREAD_ID }),
|
||||
});
|
||||
expect(tagAiChatStreamScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should unarchive an archived thread before kicking off', async () => {
|
||||
const { service, agentChatService, agentChatStreamingService } =
|
||||
buildService();
|
||||
|
||||
agentChatService.findThreadById.mockResolvedValue({
|
||||
id: EXPECTED_THREAD_ID,
|
||||
deletedAt: new Date(),
|
||||
activeStreamId: null,
|
||||
});
|
||||
|
||||
const result = await service.startWorkspaceSetupChat(startArguments);
|
||||
|
||||
expect(agentChatService.unarchiveThread).toHaveBeenCalledWith({
|
||||
threadId: EXPECTED_THREAD_ID,
|
||||
userWorkspaceId: 'user-workspace-id',
|
||||
workspaceId: 'workspace-id',
|
||||
});
|
||||
expect(
|
||||
agentChatService.unarchiveThread.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(
|
||||
agentChatStreamingService.startHiddenKickoffStream.mock
|
||||
.invocationCallOrder[0],
|
||||
);
|
||||
expect(agentChatService.createThread).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
outcome: WorkspaceSetupChatOutcome.STARTED,
|
||||
thread: expect.objectContaining({
|
||||
id: EXPECTED_THREAD_ID,
|
||||
deletedAt: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
+101
@@ -297,6 +297,107 @@ export class AgentChatStreamingService {
|
||||
}
|
||||
}
|
||||
|
||||
async startHiddenKickoffStream({
|
||||
thread,
|
||||
userWorkspaceId,
|
||||
workspace,
|
||||
text,
|
||||
}: {
|
||||
thread: AgentChatThreadEntity;
|
||||
userWorkspaceId: string;
|
||||
workspace: WorkspaceEntity;
|
||||
text: string;
|
||||
}): Promise<{ streamId: string; messageId: string; turnId: string } | null> {
|
||||
const threadId = thread.id;
|
||||
const streamId = generateId();
|
||||
|
||||
const hasClaimedStreamForKickoff = await this.tryClaimStream({
|
||||
threadId,
|
||||
workspaceId: workspace.id,
|
||||
streamId,
|
||||
where: { pendingQuestionMessageId: IsNull() },
|
||||
});
|
||||
|
||||
if (!hasClaimedStreamForKickoff) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasConversationMessages =
|
||||
await this.agentChatService.hasConversationMessages({
|
||||
threadId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (hasConversationMessages) {
|
||||
await this.releaseStreamClaim(threadId, workspace.id, streamId);
|
||||
await this.flushNextQueuedMessage(
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
workspace.id,
|
||||
!!thread.title,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const { id: messageId, turnId } =
|
||||
await this.agentChatService.ensureHiddenKickoffMessage({
|
||||
threadId,
|
||||
workspaceId: workspace.id,
|
||||
text,
|
||||
});
|
||||
|
||||
const messages = await this.loadMessagesFromDB(
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
const kickoffMessage = messages[messages.length - 1];
|
||||
|
||||
if (!kickoffMessage || kickoffMessage.id !== messageId) {
|
||||
throw new AiException(
|
||||
'Workspace setup kickoff message could not be loaded',
|
||||
AiExceptionCode.MESSAGE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
await this.messageQueueService.add<StreamAgentChatJobData>(
|
||||
STREAM_AGENT_CHAT_JOB_NAME,
|
||||
{
|
||||
threadId,
|
||||
streamId,
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
messages,
|
||||
browsingContext: null,
|
||||
lastUserMessageText: text,
|
||||
lastUserMessageParts: [{ type: 'text' as const, text }],
|
||||
hasTitle: !!thread.title,
|
||||
conversationSizeTokens: thread.conversationSize,
|
||||
existingTurnId: turnId,
|
||||
},
|
||||
);
|
||||
|
||||
return { streamId, messageId, turnId };
|
||||
} catch (error) {
|
||||
await this.releaseStreamClaim(threadId, workspace.id, streamId);
|
||||
const streamError = mapErrorToStreamError(error);
|
||||
|
||||
this.metricsService.incrementCounterBy({
|
||||
key: MetricsKeys.AiChatTurnFailed,
|
||||
amount: 1,
|
||||
attributes: {
|
||||
model: 'unknown',
|
||||
failure_phase: 'enqueue',
|
||||
error_code: streamError.code,
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async retryLastFailedTurn({
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
|
||||
+108
-9
@@ -7,7 +7,7 @@ import {
|
||||
type AskQuestionsToolResult,
|
||||
ExtendedUIMessage,
|
||||
} from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
import { In, IsNull, Not } from 'typeorm';
|
||||
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
@@ -80,11 +80,17 @@ export class AgentChatService {
|
||||
async createThread({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
id,
|
||||
title,
|
||||
}: {
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
id?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const savedThread = await this.threadRepository.save(workspaceId, {
|
||||
...(isDefined(id) ? { id } : {}),
|
||||
...(isDefined(title) ? { title } : {}),
|
||||
userWorkspaceId,
|
||||
});
|
||||
|
||||
@@ -106,6 +112,23 @@ export class AgentChatService {
|
||||
return savedThread;
|
||||
}
|
||||
|
||||
async findThreadById({
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
threadId: string;
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
return this.threadRepository.findOne(workspaceId, {
|
||||
where: {
|
||||
id: threadId,
|
||||
userWorkspaceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getThreadById({
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
@@ -115,11 +138,10 @@ export class AgentChatService {
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const thread = await this.threadRepository.findOne(workspaceId, {
|
||||
where: {
|
||||
id: threadId,
|
||||
userWorkspaceId,
|
||||
},
|
||||
const thread = await this.findThreadById({
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!thread) {
|
||||
@@ -266,7 +288,7 @@ export class AgentChatService {
|
||||
turnId: actualTurnId,
|
||||
role: uiMessage.role as AgentMessageRole,
|
||||
agentId: agentId ?? null,
|
||||
processedAt: new Date(),
|
||||
processedAt: messageValues.processedAt,
|
||||
workspaceId,
|
||||
} as AgentMessageEntity;
|
||||
}
|
||||
@@ -324,13 +346,31 @@ export class AgentChatService {
|
||||
threadId,
|
||||
role: AgentMessageRole.USER,
|
||||
status: AgentMessageStatus.SENT,
|
||||
isHidden: false,
|
||||
},
|
||||
order: { createdAt: 'DESC', id: 'DESC' },
|
||||
order: {
|
||||
processedAt: { direction: 'DESC', nulls: 'LAST' },
|
||||
createdAt: 'DESC',
|
||||
id: 'DESC',
|
||||
},
|
||||
select: ['id', 'turnId'],
|
||||
});
|
||||
}
|
||||
|
||||
async hasConversationMessages({
|
||||
threadId,
|
||||
workspaceId,
|
||||
}: {
|
||||
threadId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<boolean> {
|
||||
const visibleMessage = await this.messageRepository.findOne(workspaceId, {
|
||||
where: { threadId, isHidden: false },
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
return isDefined(visibleMessage);
|
||||
}
|
||||
|
||||
async deleteAssistantMessagesForTurn({
|
||||
turnId,
|
||||
workspaceId,
|
||||
@@ -381,6 +421,65 @@ export class AgentChatService {
|
||||
});
|
||||
}
|
||||
|
||||
async ensureHiddenKickoffMessage({
|
||||
threadId,
|
||||
workspaceId,
|
||||
text,
|
||||
}: {
|
||||
threadId: string;
|
||||
workspaceId: string;
|
||||
text: string;
|
||||
}): Promise<{ id: string; turnId: string }> {
|
||||
const existingKickoffMessage = await this.messageRepository.findOne(
|
||||
workspaceId,
|
||||
{
|
||||
where: { threadId, isHidden: true },
|
||||
relations: ['parts'],
|
||||
},
|
||||
);
|
||||
|
||||
if (isDefined(existingKickoffMessage)) {
|
||||
if (
|
||||
isDefined(existingKickoffMessage.turnId) &&
|
||||
isNonEmptyArray(existingKickoffMessage.parts)
|
||||
) {
|
||||
return {
|
||||
id: existingKickoffMessage.id,
|
||||
turnId: existingKickoffMessage.turnId,
|
||||
};
|
||||
}
|
||||
|
||||
await this.messageRepository.delete(workspaceId, {
|
||||
id: existingKickoffMessage.id,
|
||||
});
|
||||
|
||||
if (isDefined(existingKickoffMessage.turnId)) {
|
||||
await this.turnRepository.delete(workspaceId, {
|
||||
id: existingKickoffMessage.turnId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const savedMessage = await this.addMessage({
|
||||
threadId,
|
||||
workspaceId,
|
||||
uiMessage: {
|
||||
role: AgentMessageRole.USER,
|
||||
parts: [{ type: 'text' as const, text }],
|
||||
},
|
||||
isHidden: true,
|
||||
});
|
||||
|
||||
if (!isDefined(savedMessage.turnId)) {
|
||||
throw new AiException(
|
||||
'Workspace setup kickoff message was persisted without a turn',
|
||||
AiExceptionCode.MESSAGE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return { id: savedMessage.id, turnId: savedMessage.turnId };
|
||||
}
|
||||
|
||||
async queueMessage({
|
||||
threadId,
|
||||
text,
|
||||
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type WorkspaceCompanyEnrichment } from 'twenty-shared/workspace';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import { v5 } from 'uuid';
|
||||
|
||||
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
import { WorkspaceSetupChatOutcome } from 'src/engine/metadata-modules/ai/ai-chat/enums/workspace-setup-chat-outcome.enum';
|
||||
import { AgentChatStreamingService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service';
|
||||
import { AgentChatService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service';
|
||||
import { buildWorkspaceSetupPromptText } from 'src/engine/metadata-modules/ai/ai-chat/utils/build-workspace-setup-prompt-text.util';
|
||||
import { tagAiChatStreamScope } from 'src/engine/metadata-modules/ai/ai-chat/utils/tag-ai-chat-stream-scope.util';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
|
||||
const WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE =
|
||||
'1e9195f3-c26a-4bfc-961e-dc317b4badbd';
|
||||
|
||||
const WORKSPACE_SETUP_CHAT_THREAD_TITLE = msg`Workspace setup`;
|
||||
|
||||
type StartWorkspaceSetupChatServiceResult =
|
||||
| {
|
||||
outcome:
|
||||
| WorkspaceSetupChatOutcome.STARTED
|
||||
| WorkspaceSetupChatOutcome.ALREADY_STARTED;
|
||||
thread: AgentChatThreadEntity;
|
||||
}
|
||||
| {
|
||||
outcome: WorkspaceSetupChatOutcome.UNAVAILABLE;
|
||||
thread: null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
// oxlint-disable-next-line twenty/inject-workspace-repository
|
||||
export class WorkspaceSetupChatService {
|
||||
private readonly logger = new Logger(WorkspaceSetupChatService.name);
|
||||
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly agentChatService: AgentChatService,
|
||||
private readonly agentChatStreamingService: AgentChatStreamingService,
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
) {}
|
||||
|
||||
async startWorkspaceSetupChat({
|
||||
userId,
|
||||
userLocale,
|
||||
userWorkspaceId,
|
||||
workspace,
|
||||
companyContext,
|
||||
}: {
|
||||
userId: string;
|
||||
userLocale: string | null;
|
||||
userWorkspaceId: string;
|
||||
workspace: WorkspaceEntity;
|
||||
companyContext: WorkspaceCompanyEnrichment | null;
|
||||
}): Promise<StartWorkspaceSetupChatServiceResult> {
|
||||
if (!this.twentyConfigService.get('IS_ONBOARDING_AI_CHAT_ENABLED')) {
|
||||
return { outcome: WorkspaceSetupChatOutcome.UNAVAILABLE, thread: null };
|
||||
}
|
||||
|
||||
const isWorkspaceCreator =
|
||||
await this.userWorkspaceService.isWorkspaceCreator({
|
||||
userId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (!isWorkspaceCreator) {
|
||||
return { outcome: WorkspaceSetupChatOutcome.UNAVAILABLE, thread: null };
|
||||
}
|
||||
|
||||
if (this.aiModelRegistryService.getAvailableModels().length === 0) {
|
||||
return { outcome: WorkspaceSetupChatOutcome.UNAVAILABLE, thread: null };
|
||||
}
|
||||
|
||||
const localePromise = this.resolveUserLocale({
|
||||
userId,
|
||||
userLocale,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
const threadId = v5(
|
||||
`${workspace.id}:${userWorkspaceId}`,
|
||||
WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE,
|
||||
);
|
||||
|
||||
let thread = await this.agentChatService.findThreadById({
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (isDefined(thread)) {
|
||||
if (isDefined(thread.deletedAt)) {
|
||||
thread = await this.agentChatService.unarchiveThread({
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDefined(thread.activeStreamId)) {
|
||||
const interruptedError =
|
||||
await this.agentChatStreamingService.reapDeadStream({
|
||||
thread,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (!isDefined(interruptedError)) {
|
||||
return {
|
||||
outcome: WorkspaceSetupChatOutcome.ALREADY_STARTED,
|
||||
thread,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const hasConversationMessages =
|
||||
await this.agentChatService.hasConversationMessages({
|
||||
threadId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (hasConversationMessages) {
|
||||
return { outcome: WorkspaceSetupChatOutcome.ALREADY_STARTED, thread };
|
||||
}
|
||||
}
|
||||
|
||||
const hasAvailableCredits =
|
||||
await this.billingUsageService.hasAvailableCredits(workspace.id);
|
||||
|
||||
if (!hasAvailableCredits) {
|
||||
return { outcome: WorkspaceSetupChatOutcome.UNAVAILABLE, thread: null };
|
||||
}
|
||||
|
||||
const locale = await localePromise;
|
||||
|
||||
thread ??= await this.createThreadWithDeterministicId({
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
locale,
|
||||
});
|
||||
|
||||
const kickoffResult =
|
||||
await this.agentChatStreamingService.startHiddenKickoffStream({
|
||||
thread,
|
||||
userWorkspaceId,
|
||||
workspace,
|
||||
text: buildWorkspaceSetupPromptText({
|
||||
companyEnrichment: companyContext,
|
||||
locale,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!isDefined(kickoffResult)) {
|
||||
return { outcome: WorkspaceSetupChatOutcome.ALREADY_STARTED, thread };
|
||||
}
|
||||
|
||||
tagAiChatStreamScope({
|
||||
streamId: kickoffResult.streamId,
|
||||
turnId: kickoffResult.turnId,
|
||||
threadId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
return { outcome: WorkspaceSetupChatOutcome.STARTED, thread };
|
||||
}
|
||||
|
||||
private async createThreadWithDeterministicId({
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
locale,
|
||||
}: {
|
||||
threadId: string;
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
locale: string;
|
||||
}): Promise<AgentChatThreadEntity> {
|
||||
const safeLocale = (locale as keyof typeof APP_LOCALES) ?? SOURCE_LOCALE;
|
||||
const title = this.i18nService
|
||||
.getI18nInstance(safeLocale)
|
||||
._(WORKSPACE_SETUP_CHAT_THREAD_TITLE);
|
||||
|
||||
try {
|
||||
return await this.agentChatService.createThread({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
id: threadId,
|
||||
title,
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.isUniqueViolation(error)) {
|
||||
const concurrentlyCreatedThread =
|
||||
await this.agentChatService.findThreadById({
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (isDefined(concurrentlyCreatedThread)) {
|
||||
return concurrentlyCreatedThread;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof QueryFailedError &&
|
||||
(error as QueryFailedError & { code?: string }).code ===
|
||||
POSTGRESQL_ERROR_CODES.UNIQUE_VIOLATION
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveUserLocale({
|
||||
userId,
|
||||
userLocale,
|
||||
workspaceId,
|
||||
}: {
|
||||
userId: string;
|
||||
userLocale: string | null;
|
||||
workspaceId: string;
|
||||
}): Promise<string> {
|
||||
// The workspace member locale is what the UI is translated with, while the user
|
||||
// one stays at its signup default, so the assistant must follow the member locale.
|
||||
const workspaceMemberLocale = await this.findWorkspaceMemberLocale({
|
||||
userId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return workspaceMemberLocale ?? userLocale ?? SOURCE_LOCALE;
|
||||
}
|
||||
|
||||
private async findWorkspaceMemberLocale({
|
||||
userId,
|
||||
workspaceId,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const workspaceMember =
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
async () => {
|
||||
const workspaceMemberRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository(
|
||||
workspaceId,
|
||||
'workspaceMember',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
return workspaceMemberRepository.findOne({ where: { userId } });
|
||||
},
|
||||
buildSystemAuthContext(workspaceId),
|
||||
);
|
||||
|
||||
return workspaceMember?.locale ?? null;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to read the workspace member locale for workspace ${workspaceId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
import { type WorkspaceCompanyEnrichment } from 'twenty-shared/workspace';
|
||||
|
||||
import { buildCompanyContextMessageText } from 'src/engine/metadata-modules/ai/ai-chat/utils/build-company-context-message-text.util';
|
||||
import { buildWorkspaceSetupPromptText } from 'src/engine/metadata-modules/ai/ai-chat/utils/build-workspace-setup-prompt-text.util';
|
||||
|
||||
const companyEnrichment: WorkspaceCompanyEnrichment = {
|
||||
domain: 'acme.com',
|
||||
enrichedAt: '2026-07-21T10:00:00.000Z',
|
||||
name: 'Acme Inc',
|
||||
website: 'https://acme.com',
|
||||
industry: 'computer software',
|
||||
employeeCount: 250,
|
||||
size: '51-200',
|
||||
founded: 2015,
|
||||
headline: 'Anvils as a service',
|
||||
summary: 'Acme sells anvils to coyotes.',
|
||||
tags: ['saas', 'b2b'],
|
||||
locality: 'San Francisco',
|
||||
region: 'California',
|
||||
country: 'United States',
|
||||
};
|
||||
|
||||
describe('buildWorkspaceSetupPromptText', () => {
|
||||
it('should embed the company context message text when a full enrichment is provided', () => {
|
||||
const result = buildWorkspaceSetupPromptText({
|
||||
companyEnrichment,
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
expect(result).toContain(buildCompanyContextMessageText(companyEnrichment));
|
||||
expect(result).toContain('Domain: acme.com');
|
||||
expect(result).not.toContain('No information about the company');
|
||||
});
|
||||
|
||||
it('should instruct a tailored greeting without a discovery question when a full enrichment is provided', () => {
|
||||
const result = buildWorkspaceSetupPromptText({
|
||||
companyEnrichment,
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
expect(result).toContain('tailored to their business');
|
||||
expect(result).not.toContain('You do not know what this company does yet');
|
||||
});
|
||||
|
||||
it('should forbid every first-reply tool except ask_questions when a full enrichment is provided', () => {
|
||||
const result = buildWorkspaceSetupPromptText({
|
||||
companyEnrichment,
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
expect(result).toContain('required ask_questions call');
|
||||
expect(result).toContain('needs no skill and no learn_tools step');
|
||||
expect(result).toContain(
|
||||
'do not call load_skills, learn_tools, execute_tool, or web search',
|
||||
);
|
||||
});
|
||||
|
||||
it('should require explicit approval before building and name the metadata tools when a full enrichment is provided', () => {
|
||||
const result = buildWorkspaceSetupPromptText({
|
||||
companyEnrichment,
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
expect(result).toContain('Only propose until the user explicitly approves');
|
||||
expect(result).toContain(
|
||||
'never create, update, or delete anything before approval',
|
||||
);
|
||||
expect(result).toContain('metadata-building');
|
||||
expect(result).toContain('create_many_object_metadata');
|
||||
expect(result).toContain('create_many_field_metadata');
|
||||
expect(result).toContain('create_many_relation_fields');
|
||||
});
|
||||
|
||||
it('should state that no company information is available when the enrichment is null', () => {
|
||||
const result = buildWorkspaceSetupPromptText({
|
||||
companyEnrichment: null,
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
expect(result).toContain('No information about the company');
|
||||
expect(result).not.toContain('Domain:');
|
||||
});
|
||||
|
||||
it('should instruct an ask_questions discovery when the enrichment is null', () => {
|
||||
const result = buildWorkspaceSetupPromptText({
|
||||
companyEnrichment: null,
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
expect(result).toContain('You do not know what this company does yet');
|
||||
expect(result).toContain(
|
||||
'call ask_questions to learn what the business does',
|
||||
);
|
||||
expect(result).not.toContain('tailored to their business');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a full enrichment', companyEnrichment],
|
||||
['a null enrichment', null],
|
||||
])(
|
||||
'should stay invisible and never claim tools are already loaded when %s is provided',
|
||||
(_label, enrichment) => {
|
||||
const result = buildWorkspaceSetupPromptText({
|
||||
companyEnrichment: enrichment,
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
expect(result).toContain('invisible');
|
||||
expect(result).not.toContain('already loaded');
|
||||
},
|
||||
);
|
||||
|
||||
it('should ask about the data model with the ask_questions tool', () => {
|
||||
const result = buildWorkspaceSetupPromptText({
|
||||
companyEnrichment,
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
expect(result).toContain('Never stop after presenting the proposal');
|
||||
expect(result).toContain(
|
||||
'The turn is unfinished until you call ask_questions asking whether to go ahead and build it',
|
||||
);
|
||||
expect(result).toContain(
|
||||
'End this reply with the ask_questions call asking whether to build the proposed data model.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should require making the created fields visible in the views', () => {
|
||||
const result = buildWorkspaceSetupPromptText({
|
||||
companyEnrichment,
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
expect(result).toContain('view-building');
|
||||
expect(result).toContain('get_view_fields');
|
||||
expect(result).toContain('update_many_view_fields with isVisible true');
|
||||
expect(result).toContain('create_many_view_fields');
|
||||
});
|
||||
|
||||
it('should require English names with labels in the user language', () => {
|
||||
const result = buildWorkspaceSetupPromptText({
|
||||
companyEnrichment,
|
||||
locale: 'fr-FR',
|
||||
});
|
||||
|
||||
expect(result).toContain('names must be in English');
|
||||
expect(result).toContain("must be in the user's language");
|
||||
});
|
||||
|
||||
it.each([
|
||||
['fr-FR', 'French'],
|
||||
['de-DE', 'German'],
|
||||
['pt-BR', 'Portuguese'],
|
||||
['en', 'English'],
|
||||
])(
|
||||
'should end with the locale instruction when the locale is %s',
|
||||
(locale, languageName) => {
|
||||
const result = buildWorkspaceSetupPromptText({
|
||||
companyEnrichment,
|
||||
locale,
|
||||
});
|
||||
|
||||
expect(
|
||||
result.endsWith(
|
||||
`The user locale is ${languageName}, please continue the discussion in that language.`,
|
||||
),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('should fall back to the raw locale when it is not a structurally valid language tag', () => {
|
||||
const result = buildWorkspaceSetupPromptText({
|
||||
companyEnrichment,
|
||||
locale: '!',
|
||||
});
|
||||
|
||||
expect(result).toContain(
|
||||
'The user locale is !, please continue the discussion in that language.',
|
||||
);
|
||||
});
|
||||
});
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { getEnglishLanguageNameFromLocale } from 'src/engine/metadata-modules/ai/ai-chat/utils/get-english-language-name-from-locale.util';
|
||||
|
||||
describe('getEnglishLanguageNameFromLocale', () => {
|
||||
it.each([
|
||||
['fr-FR', 'French'],
|
||||
['de-DE', 'German'],
|
||||
['ja-JP', 'Japanese'],
|
||||
['zh-CN', 'Chinese'],
|
||||
['en', 'English'],
|
||||
])('should return %s as %s', (locale, expectedLanguageName) => {
|
||||
expect(getEnglishLanguageNameFromLocale(locale)).toBe(expectedLanguageName);
|
||||
});
|
||||
|
||||
it('should return the locale itself when it is not a structurally valid language tag', () => {
|
||||
expect(getEnglishLanguageNameFromLocale('!')).toBe('!');
|
||||
});
|
||||
|
||||
it('should return the language subtag when it has no display name', () => {
|
||||
expect(getEnglishLanguageNameFromLocale('xx-XX')).toBe('xx');
|
||||
});
|
||||
});
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type WorkspaceCompanyEnrichment } from 'twenty-shared/workspace';
|
||||
|
||||
import { buildCompanyContextMessageText } from 'src/engine/metadata-modules/ai/ai-chat/utils/build-company-context-message-text.util';
|
||||
import { getEnglishLanguageNameFromLocale } from 'src/engine/metadata-modules/ai/ai-chat/utils/get-english-language-name-from-locale.util';
|
||||
|
||||
const NO_COMPANY_CONTEXT_LINE =
|
||||
'No information about the company that owns this workspace is available.';
|
||||
|
||||
const FIRST_REPLY_INSTRUCTION_WITH_COMPANY_CONTEXT =
|
||||
'Greet the user with one short sentence tailored to their business, then immediately present the data model proposal described below.';
|
||||
|
||||
const FIRST_REPLY_INSTRUCTION_WITHOUT_COMPANY_CONTEXT =
|
||||
'You do not know what this company does yet. Greet the user briefly, then call ask_questions to learn what the business does, who its customers are, and how it sells, offering the most likely answers as options. Once the user answers, present the data model proposal described below before doing anything else.';
|
||||
|
||||
export const buildWorkspaceSetupPromptText = ({
|
||||
companyEnrichment,
|
||||
locale,
|
||||
}: {
|
||||
companyEnrichment: WorkspaceCompanyEnrichment | null;
|
||||
locale: string;
|
||||
}): string => {
|
||||
const companyContextSection = isDefined(companyEnrichment)
|
||||
? buildCompanyContextMessageText(companyEnrichment)
|
||||
: NO_COMPANY_CONTEXT_LINE;
|
||||
|
||||
const firstReplyInstruction = isDefined(companyEnrichment)
|
||||
? FIRST_REPLY_INSTRUCTION_WITH_COMPANY_CONTEXT
|
||||
: FIRST_REPLY_INSTRUCTION_WITHOUT_COMPANY_CONTEXT;
|
||||
|
||||
const userLanguageName = getEnglishLanguageNameFromLocale(locale);
|
||||
|
||||
return `${companyContextSection}
|
||||
|
||||
You are kicking off the setup of this brand-new Twenty workspace for its admin. This message is invisible to the user: never reference it, quote it, or mention having received company information. Write as if you naturally know it.
|
||||
|
||||
This first reply ends with a required ask_questions call. It needs no skill and no learn_tools step, so call it directly. Before it, do not call load_skills, learn_tools, execute_tool, or web search: write your text first so the answer starts streaming immediately.
|
||||
|
||||
${firstReplyInstruction}
|
||||
|
||||
The proposal is a concise markdown data model proposal for this workspace, under 250 words:
|
||||
- One line for each standard object (People, Companies, Opportunities) mapping it onto their domain.
|
||||
- 2 to 4 custom objects. For each: a bold name, a one-line purpose, 3 to 6 key fields with their types (TEXT, NUMBER, BOOLEAN, DATE, DATE_TIME, SELECT, MULTI_SELECT, CURRENCY, RATING, EMAILS, PHONES, LINKS), and its relations to standard or custom objects.
|
||||
|
||||
Never stop after presenting the proposal. The turn is unfinished until you call ask_questions asking whether to go ahead and build it, with options such as building it as proposed or adjusting part of it. Ask it even though it has an obvious recommended answer: this approval question is required here, and the general guidance about skipping questions with obvious defaults does not apply to it. Ask the user about the data model with ask_questions rather than with a plain-text question, here and whenever a later data model choice needs their input. Each question takes 2 to 4 short options and the user can always answer in free text, so never spell the options out in your text.
|
||||
|
||||
Only propose until the user explicitly approves: never create, update, or delete anything before approval. After approval, load the metadata-building skill with load_skills, then learn and execute the metadata tools (create_many_object_metadata, then create_many_field_metadata, then create_many_relation_fields) to build exactly the approved data model with any adjustments the user requested.
|
||||
|
||||
Fields you create are not shown in the objects' views by default. Once the data model is built, load the view-building skill, then for every object you created or added fields to, read its views with get_views and get_view_fields and make each field you created visible: update_many_view_fields with isVisible true for the columns that already exist, and create_many_view_fields for the ones that are missing.
|
||||
|
||||
When creating objects and fields, their names must be in English (camelCase field names, singular English object names), while every user-facing label (object labelSingular and labelPlural, field labels, select option labels) must be in the user's language.
|
||||
|
||||
End this reply with the ask_questions call asking whether to build the proposed data model.
|
||||
|
||||
The user locale is ${userLanguageName}, please continue the discussion in that language.`;
|
||||
};
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export const getEnglishLanguageNameFromLocale = (locale: string): string => {
|
||||
const languageTag = locale.split('-')[0];
|
||||
|
||||
try {
|
||||
const languageName = new Intl.DisplayNames(['en'], {
|
||||
type: 'language',
|
||||
}).of(languageTag);
|
||||
|
||||
return isNonEmptyString(languageName) ? languageName : locale;
|
||||
} catch {
|
||||
return locale;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user