Compare commits

..
Author SHA1 Message Date
Félix Malfait ac75ca7f72 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.
2026-05-22 15:44:08 +02:00
WeikoandGitHub 8f24cda586 Fix permission flag removed flag property from diffing (#20845)
## Why it fixes the bug

RolePermissionFlagEntity.flag (role-permission-flag.entity.ts:54) is
marked @WasRemovedInUpgrade for
2.7.0_FinalizeRolePermissionFlagCutoverFastInstanceCommand.
After 2.7.0, the column is gone from real DBs and the metadata layer no
longer accepts writes to it — but the diffing config still listed flag
with toCompare: true. So when an SDK-generated manifest carried a flag
value, computeUniversalFlatEntityPropertiesToCompareAndStringify
(all-universal-flat-entity-properties-to-compare-and-stringify.constant.ts:55-69)
included it in the comparison, the diff emitted { update: { flag:
"UPLOAD_FILE" } }, and the metadata update failed with Property "flag"
was not found in "RolePermissionFlagEntity".

Switching toCompare: false makes the diff skip flag; the only properties
compared are now permissionFlagUniversalIdentifier and
roleUniversalIdentifier, which is what the post-cutover
entity actually supports.

Fixes https://github.com/twentyhq/twenty/issues/20843
2026-05-22 13:20:27 +00:00
8 changed files with 60 additions and 88 deletions
@@ -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<string | null>,
@@ -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<string | null>(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);
@@ -10,7 +10,7 @@ import {
ServerParseError,
UnconventionalError,
} from '@apollo/client/errors';
import { isDefined, isNonEmptyString, type CustomError } from 'twenty-shared/utils';
import { isDefined, type CustomError } from 'twenty-shared/utils';
const isApolloError = (error: unknown): boolean =>
CombinedGraphQLErrors.is(error) ||
@@ -44,45 +44,25 @@ export const PromiseRejectionEffect = () => {
error?.networkError?.name === 'AbortError' ||
error?.name === 'AbortError';
if (isAbortError) {
return;
if (!isAbortError) {
enqueueErrorSnackBar(
error instanceof Error ? { message: error.message } : {},
);
}
enqueueErrorSnackBar(
error instanceof Error ? { message: error.message } : {},
);
try {
const { captureException, captureMessage } = await import('@sentry/react');
const { captureException } = await import('@sentry/react');
captureException(error, (scope) => {
scope.setExtras({ mechanism: 'onUnhandle' });
if (error instanceof Error) {
captureException(error, (scope) => {
scope.setExtras({ mechanism: 'onUnhandledRejection' });
const fingerprint = hasErrorCode(error)
? error.code
: isNonEmptyString(error.message)
? error.message
: 'unknown-unhandled-rejection';
scope.setFingerprint([fingerprint]);
return scope;
});
return;
}
captureMessage('Unhandled promise rejection with non-error reason', {
level: 'warning',
extra: {
mechanism: 'onUnhandledRejection',
reasonType: typeof error,
},
const fingerprint = hasErrorCode(error) ? error.code : error.message;
scope.setFingerprint([fingerprint]);
error.name = error.message;
return scope;
});
} catch (sentryError) {
// oxlint-disable-next-line no-console
console.warn('Failed to capture exception with Sentry:', sentryError);
console.error('Failed to capture exception with Sentry:', sentryError);
}
},
[enqueueErrorSnackBar],
@@ -1,19 +0,0 @@
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldDateMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { computeDraftValueFromString } from '@/object-record/record-field/ui/utils/computeDraftValueFromString';
import { FieldMetadataType } from '~/generated-metadata/graphql';
describe('computeDraftValueFromString', () => {
it('should return typed value for DATE field', () => {
const fieldDefinition: Pick<FieldDefinition<FieldDateMetadata>, 'type'> = {
type: FieldMetadataType.DATE,
};
expect(
computeDraftValueFromString<string>({
fieldDefinition,
value: '2026-05-22',
}),
).toBe('2026-05-22');
});
});
@@ -1,18 +0,0 @@
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldDateMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { computeEmptyDraftValue } from '@/object-record/record-field/ui/utils/computeEmptyDraftValue';
import { FieldMetadataType } from '~/generated-metadata/graphql';
describe('computeEmptyDraftValue', () => {
it('should return empty draft value for DATE field', () => {
const fieldDefinition: Pick<FieldDefinition<FieldDateMetadata>, 'type'> = {
type: FieldMetadataType.DATE,
};
expect(
computeEmptyDraftValue<string>({
fieldDefinition,
}),
).toBe('');
});
});
@@ -3,7 +3,6 @@ import { type FieldInputDraftValue } from '@/object-record/record-field/ui/types
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { isFieldAddress } from '@/object-record/record-field/ui/types/guards/isFieldAddress';
import { isFieldCurrency } from '@/object-record/record-field/ui/types/guards/isFieldCurrency';
import { isFieldDate } from '@/object-record/record-field/ui/types/guards/isFieldDate';
import { isFieldDateTime } from '@/object-record/record-field/ui/types/guards/isFieldDateTime';
import { isFieldEmails } from '@/object-record/record-field/ui/types/guards/isFieldEmails';
import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName';
@@ -31,7 +30,6 @@ export const computeDraftValueFromString = <FieldValue>({
if (
isFieldUuid(fieldDefinition) ||
isFieldText(fieldDefinition) ||
isFieldDate(fieldDefinition) ||
isFieldDateTime(fieldDefinition) ||
isFieldNumber(fieldDefinition) ||
isFieldRelation(fieldDefinition)
@@ -85,7 +83,7 @@ export const computeDraftValueFromString = <FieldValue>({
}
throw new CustomError(
`Record field type not supported : ${fieldDefinition.type}`,
`Record field type not supported : ${fieldDefinition.type}}`,
'RECORD_FIELD_TYPE_NOT_SUPPORTED',
);
};
@@ -3,7 +3,6 @@ import { type FieldInputDraftValue } from '@/object-record/record-field/ui/types
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { isFieldAddress } from '@/object-record/record-field/ui/types/guards/isFieldAddress';
import { isFieldCurrency } from '@/object-record/record-field/ui/types/guards/isFieldCurrency';
import { isFieldDate } from '@/object-record/record-field/ui/types/guards/isFieldDate';
import { isFieldDateTime } from '@/object-record/record-field/ui/types/guards/isFieldDateTime';
import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName';
import { isFieldLinks } from '@/object-record/record-field/ui/types/guards/isFieldLinks';
@@ -26,7 +25,6 @@ export const computeEmptyDraftValue = <FieldValue>({
if (
isFieldUuid(fieldDefinition) ||
isFieldText(fieldDefinition) ||
isFieldDate(fieldDefinition) ||
isFieldDateTime(fieldDefinition) ||
isFieldNumber(fieldDefinition) ||
isFieldRelation(fieldDefinition) ||
@@ -70,7 +68,7 @@ export const computeEmptyDraftValue = <FieldValue>({
}
throw new CustomError(
`Record field type not supported : ${fieldDefinition.type}`,
`Record field type not supported : ${fieldDefinition.type}}`,
'RECORD_FIELD_TYPE_NOT_SUPPORTED',
);
};
@@ -258,7 +258,6 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"propertiesToCompare": [
"permissionFlagUniversalIdentifier",
"roleUniversalIdentifier",
"flag",
],
"propertiesToStringify": [],
},
@@ -1198,7 +1198,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
universalProperty: 'roleUniversalIdentifier',
},
flag: {
toCompare: true,
toCompare: false,
toStringify: false,
universalProperty: undefined,
},