From ac75ca7f722166e485fef9e02ceb57da592fb60b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Fri, 22 May 2026 15:44:08 +0200 Subject: [PATCH] fix(ai-chat): clean up orphan thread when first send fails When sending the first message of a new chat fails (e.g. no AI provider configured), the freshly created thread used to be committed into `currentAiChatThreadState`, leaving an orphan thread in the database and making subsequent sends fail with "Chat thread not found" if the thread later became unreachable. The catch block in `useAgentChat` now: - Best-effort deletes the just-created backend thread on first-send failure. - Removes the thread from the metadata store so it disappears from the list. - Resets `currentAiChatThreadState` to the new-thread placeholder and shows the error against that placeholder so the next send creates a fresh thread. - Also runs the same reset when any send fails with the THREAD_NOT_FOUND subCode, so a stale threadId doesn't strand the chat. --- .../src/modules/ai/hooks/useAgentChat.ts | 54 +++++++++++++++---- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts index e1cbed6694f..ff3bbc5b5ee 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts @@ -11,6 +11,7 @@ import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChat import { AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME } from '@/ai/constants/AgentChatRestoreEditorContentEventName'; import { AGENT_CHAT_SEND_MESSAGE_EVENT_NAME } from '@/ai/constants/AgentChatSendMessageEventName'; import { AGENT_CHAT_STOP_EVENT_NAME } from '@/ai/constants/AgentChatStopEventName'; +import { DELETE_CHAT_THREAD } from '@/ai/graphql/mutations/deleteChatThread'; import { SEND_CHAT_MESSAGE } from '@/ai/graphql/mutations/sendChatMessage'; import { STOP_AGENT_CHAT_STREAM } from '@/ai/graphql/mutations/stopAgentChatStream'; import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId'; @@ -29,9 +30,13 @@ import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesS import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState'; import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent'; import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent'; +import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; +import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util'; + +const AI_THREAD_NOT_FOUND_ERROR_CODE = 'THREAD_NOT_FOUND'; export const useAgentChat = ( ensureThreadIdForSend: () => Promise, @@ -42,6 +47,7 @@ export const useAgentChat = ( const apolloClient = useApolloClient(); const { enqueueErrorSnackBar } = useSnackBar(); const setCurrentAiChatThread = useSetAtomState(currentAiChatThreadState); + const { removeFromDraft, applyChanges } = useUpdateMetadataStoreDraft(); const store = useStore(); const [, setPendingThreadIdAfterFirstSend] = useState(null); @@ -193,8 +199,20 @@ export const useAgentChat = ( return null; }); } catch (error) { - const restoredDraftKey = - draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY ? threadId : draftKey; + const isFirstSendForNewThread = + draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY; + const isThreadMissing = isGraphqlErrorOfType( + error, + AI_THREAD_NOT_FOUND_ERROR_CODE, + ); + const shouldResetToNewThread = isFirstSendForNewThread || isThreadMissing; + + const restoredDraftKey = shouldResetToNewThread + ? AGENT_CHAT_NEW_THREAD_DRAFT_KEY + : draftKey; + const errorTargetThreadId = shouldResetToNewThread + ? AGENT_CHAT_NEW_THREAD_DRAFT_KEY + : threadId; rollbackOptimisticUnarchive?.(); @@ -202,9 +220,6 @@ export const useAgentChat = ( setAgentChatDraftsByThreadId((prev) => ({ ...prev, [restoredDraftKey]: contentToSend, - ...(draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY - ? { [AGENT_CHAT_NEW_THREAD_DRAFT_KEY]: '' } - : {}), })); setAgentChatUploadedFiles(uploadedFilesSnapshot); @@ -215,19 +230,38 @@ export const useAgentChat = ( latestMessages.filter((message) => message.id !== messageId), ); - store.set( - errorAtom, + const normalizedError = CombinedGraphQLErrors.is(error) || error instanceof Error ? error - : new Error('An unexpected error occurred'), + : new Error('An unexpected error occurred'); + + store.set( + agentChatErrorComponentFamilyState.atomFamily({ + instanceId: AGENT_CHAT_INSTANCE_ID, + familyKey: { threadId: errorTargetThreadId }, + }), + normalizedError, ); dispatchBrowserEvent(AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME, { content: contentToSend, }); - if (draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY) { - setCurrentAiChatThread(threadId); + if (shouldResetToNewThread) { + removeFromDraft({ key: 'agentChatThreads', itemIds: [threadId] }); + applyChanges(); + setCurrentAiChatThread(AGENT_CHAT_NEW_THREAD_DRAFT_KEY); + + if (isFirstSendForNewThread) { + // Best-effort orphan cleanup; safe to ignore failure because + // currentAiChatThreadState is already reset. + apolloClient + .mutate({ + mutation: DELETE_CHAT_THREAD, + variables: { id: threadId }, + }) + .catch(() => undefined); + } } setPendingThreadIdAfterFirstSend(null);