Align GraphQL error handling for billing and AI chat (#19690)
## What changed This refactor fixes AI chat error surfacing by aligning both the backend and frontend with the existing GraphQL error architecture instead of adding AI-local error translation. On the backend: - add a dedicated GraphQL billing exception path - register billing GraphQL handling globally for GraphQL requests - reuse the existing AI GraphQL interceptor path for agent/chat exceptions - keep billing status classification shared between REST and GraphQL - remove the earlier attempt to preserve `CustomException` metadata in the global GraphQL fallback On the frontend: - keep the original Apollo GraphQL error object in AI chat state - reuse shared Apollo/GraphQL helpers for user-facing messages and error-type checks - delete AI-specific error extraction helpers that duplicated generic GraphQL parsing - replace a few direct `extensions.subCode` call sites with a shared predicate ## Why it changed The original bug was that `BillingException` and AI exceptions thrown from chat were not being translated into GraphQL errors with the expected `extensions.subCode` and `extensions.userFriendlyMessage`, so the AI chat UI had nothing structured to inspect. An intermediate fix worked mechanically but pushed `CustomException` handling into the global GraphQL fallback, which blurred the intended layering. This PR moves the behavior back to explicit GraphQL edges. ## Root cause `AgentChatResolver` could throw `BillingException` and `AgentException`, but: - billing had a REST exception filter and no shared GraphQL equivalent - AI chat was not consistently using the same GraphQL exception translation path as the sibling AI resolver - the frontend chat UI had drifted into AI-specific error parsing instead of consuming the same structured Apollo errors as the rest of the app ## Impact - `BILLING_CREDITS_EXHAUSTED` is now preserved through GraphQL and can render the existing credits-exhausted UI in chat - `API_KEY_NOT_CONFIGURED` is preserved through the AI GraphQL path - AI chat now follows the same general GraphQL error consumption pattern as the rest of the frontend - billing GraphQL handling is less dependent on individual resolver authors remembering to add a filter ## Validation - `yarn jest --config packages/twenty-server/jest.config.mjs packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/billing-graphql-api-exception-handler.util.spec.ts packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/__tests__/agent-graphql-api-exception-handler.util.spec.ts` - `yarn jest --config packages/twenty-front/jest.config.mjs packages/twenty-front/src/utils/__tests__/is-graphql-error-of-type.util.test.ts` - `npx oxlint --type-aware ...` on touched backend/frontend files - `npx prettier --check ...` on touched backend/frontend files ## Follow-up ideas - consolidate frontend GraphQL error helpers further so more existing direct `extensions.subCode` checks move to shared utilities - consider whether common GraphQL exception filter registration should live in a more explicit GraphQL-specific module instead of `CoreEngineModule` - add an end-to-end test for a real `sendChatMessage` GraphQL failure path in AI chat --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,10 @@ import { Paragraph } from '@tiptap/extension-paragraph';
|
||||
import { Text } from '@tiptap/extension-text';
|
||||
import { Placeholder } from '@tiptap/extensions/placeholder';
|
||||
import { useEditor } from '@tiptap/react';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME } from '@/ai/constants/AgentChatRestoreEditorContentEventName';
|
||||
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
|
||||
import {
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
|
||||
@@ -21,6 +22,7 @@ import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSugges
|
||||
import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion';
|
||||
import { MentionTag } from '@/mention/extensions/MentionTag';
|
||||
import { useMentionSearch } from '@/mention/hooks/useMentionSearch';
|
||||
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
|
||||
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
|
||||
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
|
||||
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||
@@ -134,6 +136,20 @@ export const useAIChatEditor = () => {
|
||||
mentionStorage.searchMentionRecords = searchMentionRecords;
|
||||
}
|
||||
|
||||
const handleRestoreEditorContent = useCallback(
|
||||
(detail?: { content: string }) => {
|
||||
if (isDefined(detail?.content)) {
|
||||
editor?.commands.setContent(textToTiptapContent(detail.content));
|
||||
}
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
useListenToBrowserEvent<{ content: string }>({
|
||||
eventName: AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME,
|
||||
onBrowserEvent: handleRestoreEditorContent,
|
||||
});
|
||||
|
||||
const handleSendAndClear = () => {
|
||||
dispatchAgentChatSendMessageEvent();
|
||||
editor?.commands.clearContent();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CombinedGraphQLErrors } from '@apollo/client/errors';
|
||||
import { useApolloClient } from '@apollo/client/react';
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback, useState } from 'react';
|
||||
@@ -7,6 +8,7 @@ import { v4 } from 'uuid';
|
||||
|
||||
import { AGENT_CHAT_INSTANCE_ID } from '@/ai/constants/AgentChatInstanceId';
|
||||
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
|
||||
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 { SEND_CHAT_MESSAGE } from '@/ai/graphql/mutations/sendChatMessage';
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
|
||||
agentChatDraftsByThreadIdState,
|
||||
} from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
|
||||
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
|
||||
@@ -109,10 +112,15 @@ export const useAgentChat = (
|
||||
instanceId: AGENT_CHAT_INSTANCE_ID,
|
||||
familyKey: { threadId },
|
||||
});
|
||||
const errorAtom = agentChatErrorComponentFamilyState.atomFamily({
|
||||
instanceId: AGENT_CHAT_INSTANCE_ID,
|
||||
familyKey: { threadId },
|
||||
});
|
||||
|
||||
const currentMessages = store.get(messagesAtom);
|
||||
|
||||
store.set(messagesAtom, [...currentMessages, optimisticUserMessage]);
|
||||
store.set(errorAtom, null);
|
||||
|
||||
const fileIds = agentChatUploadedFiles.map((file) => file.fileId);
|
||||
|
||||
@@ -155,11 +163,17 @@ export const useAgentChat = (
|
||||
|
||||
return null;
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const restoredDraftKey =
|
||||
draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY ? threadId : draftKey;
|
||||
|
||||
setAgentChatInput(contentToSend);
|
||||
setAgentChatDraftsByThreadId((prev) => ({
|
||||
...prev,
|
||||
[draftKey]: contentToSend,
|
||||
[restoredDraftKey]: contentToSend,
|
||||
...(draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY
|
||||
? { [AGENT_CHAT_NEW_THREAD_DRAFT_KEY]: '' }
|
||||
: {}),
|
||||
}));
|
||||
|
||||
const latestMessages = store.get(messagesAtom);
|
||||
@@ -168,6 +182,23 @@ export const useAgentChat = (
|
||||
messagesAtom,
|
||||
latestMessages.filter((message) => message.id !== messageId),
|
||||
);
|
||||
|
||||
store.set(
|
||||
errorAtom,
|
||||
CombinedGraphQLErrors.is(error) || error instanceof Error
|
||||
? error
|
||||
: new Error('An unexpected error occurred'),
|
||||
);
|
||||
|
||||
dispatchBrowserEvent(AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME, {
|
||||
content: contentToSend,
|
||||
});
|
||||
|
||||
if (draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
|
||||
setCurrentAIChatThread(threadId);
|
||||
}
|
||||
|
||||
setPendingThreadIdAfterFirstSend(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
|
||||
Reference in New Issue
Block a user