feat(ai): surface AI chat stream failures through one typed error channel (#22434)

## Context

Investigating a report where the AI chat showed only a `...` spinner
while the network response clearly contained `No AI models are
available`. Root cause: terminal stream failures reach the client on
**two mismatched channels**.

| Representation | Persisted (survives reload) | Rendered by client |
|---|---|---|
| AI-SDK `error` chunk (inside `stream-chunk`) |  RPUSH'd to Redis | 
dropped by `readUIMessageStream` (no message part, no error state) |
| typed `stream-error` event |  never persisted |  sets the error atom
|

Live, the `stream-error` event renders. But on reload,
`chatStreamCatchupChunks` replays only the persisted **error chunk** —
which the reducer discards — and the streaming indicator never clears.

## Change

Collapse to a single typed error contract:

- **Suppress the opaque `error` chunk** in the stream job; every failure
is surfaced through the typed `stream-error` event. Errors are mapped
via `mapErrorToStreamError` so an `AiException` keeps its
`AiExceptionCode` (e.g. `API_KEY_NOT_CONFIGURED` → the existing "AI not
configured" banner) instead of leaking a raw string.
- **Persist the terminal error** next to the accumulated chunks and
expose it as an explicit `error { code message }` field on
`ChatStreamCatchupChunks`, so a client catching up after a reload
recovers it — no dependency on the AI SDK's internal chunk shape.
- **Reset per-thread stream state at job start**, so a failed turn's
leftover chunks/error never replay on the next stream.
- **Client replays the catchup error** as a terminal `stream-error`
event, which clears the streaming indicator and renders the error (fixes
the infinite spinner on a stream that ended in error).

## Notes

- `ChatStreamError` is a new metadata GraphQL type; generated types
(twenty-front metadata + client-sdk) were hand-updated to keep the tree
consistent and will be reconciled by CI's `graphql:generate` check if
anything differs.
- Server unit test added for the error mapping. No schema/DB migration.

## Test plan

- [ ] With no AI provider configured, send a chat message → error
renders immediately (not a spinner).
- [ ] Reload the thread → the error still renders (recovered from
catchup), indicator not spinning.
- [ ] Configure a provider and send again → normal streaming; no stale
error from the previous failed turn.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22434?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:
Félix Malfait
2026-07-02 14:50:57 +02:00
committed by GitHub
parent 47689e676b
commit d709467902
30 changed files with 965 additions and 243 deletions
@@ -2824,9 +2824,15 @@ type AiSystemPromptPreview {
estimatedTokenCount: Int!
}
type ChatStreamError {
code: String!
message: String!
}
type ChatStreamCatchupChunks {
chunks: [JSON!]!
maxSeq: Int!
error: ChatStreamError
}
type SendChatMessageResult {
@@ -3373,6 +3379,7 @@ type Mutation {
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
createChatThread: AgentChatThread!
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
retryChatMessage(threadId: UUID!, modelId: String): SendChatMessageResult!
stopAgentChatStream(threadId: UUID!): Boolean!
renameChatThread(id: UUID!, title: String!): AgentChatThread!
archiveChatThread(id: UUID!): AgentChatThread!
@@ -2508,9 +2508,16 @@ export interface AiSystemPromptPreview {
__typename: 'AiSystemPromptPreview'
}
export interface ChatStreamError {
code: Scalars['String']
message: Scalars['String']
__typename: 'ChatStreamError'
}
export interface ChatStreamCatchupChunks {
chunks: Scalars['JSON'][]
maxSeq: Scalars['Int']
error?: ChatStreamError
__typename: 'ChatStreamCatchupChunks'
}
@@ -2904,6 +2911,7 @@ export interface Mutation {
updateCalendarChannel: CalendarChannel
createChatThread: AgentChatThread
sendChatMessage: SendChatMessageResult
retryChatMessage: SendChatMessageResult
stopAgentChatStream: Scalars['Boolean']
renameChatThread: AgentChatThread
archiveChatThread: AgentChatThread
@@ -5632,9 +5640,17 @@ export interface AiSystemPromptPreviewGenqlSelection{
__scalar?: boolean | number
}
export interface ChatStreamErrorGenqlSelection{
code?: boolean | number
message?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ChatStreamCatchupChunksGenqlSelection{
chunks?: boolean | number
maxSeq?: boolean | number
error?: ChatStreamErrorGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -6071,6 +6087,7 @@ export interface MutationGenqlSelection{
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
createChatThread?: AgentChatThreadGenqlSelection
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} })
retryChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], modelId?: (Scalars['String'] | null)} })
stopAgentChatStream?: { __args: {threadId: Scalars['UUID']} }
renameChatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID'], title: Scalars['String']} })
archiveChatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID']} })
@@ -8452,6 +8469,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const ChatStreamError_possibleTypes: string[] = ['ChatStreamError']
export const isChatStreamError = (obj?: { __typename?: any } | null): obj is ChatStreamError => {
if (!obj?.__typename) throw new Error('__typename is missing in "isChatStreamError"')
return ChatStreamError_possibleTypes.includes(obj.__typename)
}
const ChatStreamCatchupChunks_possibleTypes: string[] = ['ChatStreamCatchupChunks']
export const isChatStreamCatchupChunks = (obj?: { __typename?: any } | null): obj is ChatStreamCatchupChunks => {
if (!obj?.__typename) throw new Error('__typename is missing in "isChatStreamCatchupChunks"')
File diff suppressed because it is too large Load Diff
@@ -862,9 +862,16 @@ export enum ChartNumberFormat {
export type ChatStreamCatchupChunks = {
__typename?: 'ChatStreamCatchupChunks';
chunks: Array<Scalars['JSON']['output']>;
error?: Maybe<ChatStreamError>;
maxSeq: Scalars['Int']['output'];
};
export type ChatStreamError = {
__typename?: 'ChatStreamError';
code: Scalars['String']['output'];
message: Scalars['String']['output'];
};
export type CheckUserExist = {
__typename?: 'CheckUserExist';
availableWorkspacesCount: Scalars['Float']['output'];
@@ -2577,6 +2584,7 @@ export type Mutation = {
resetPageLayoutTabToDefault: PageLayoutTab;
resetPageLayoutToDefault: PageLayout;
resetPageLayoutWidgetToDefault: PageLayoutWidget;
retryChatMessage: SendChatMessageResult;
revokeApiKey?: Maybe<ApiKey>;
rotateApplicationRegistrationClientSecret: RotateClientSecret;
runAgent: RunAgentResult;
@@ -3300,6 +3308,12 @@ export type MutationResetPageLayoutWidgetToDefaultArgs = {
};
export type MutationRetryChatMessageArgs = {
modelId?: InputMaybe<Scalars['String']['input']>;
threadId: Scalars['UUID']['input'];
};
export type MutationRevokeApiKeyArgs = {
input: RevokeApiKeyInput;
};
@@ -6450,6 +6464,14 @@ export type RenameChatThreadMutationVariables = Exact<{
export type RenameChatThreadMutation = { __typename?: 'Mutation', renameChatThread: { __typename?: 'AgentChatThread', id: string, title?: string | null, updatedAt: string } };
export type RetryChatMessageMutationVariables = Exact<{
threadId: Scalars['UUID']['input'];
modelId?: InputMaybe<Scalars['String']['input']>;
}>;
export type RetryChatMessageMutation = { __typename?: 'Mutation', retryChatMessage: { __typename?: 'SendChatMessageResult', messageId: string, queued: boolean, streamId?: string | null } };
export type RunEvaluationInputMutationVariables = Exact<{
agentId: Scalars['UUID']['input'];
input: Scalars['String']['input'];
@@ -6546,7 +6568,7 @@ export type GetChatMessagesQueryVariables = Exact<{
}>;
export type GetChatMessagesQuery = { __typename?: 'Query', chatMessages: Array<{ __typename?: 'AgentMessage', id: string, threadId: string, turnId?: string | null, role: string, status: string, createdAt: string, parts: Array<{ __typename?: 'AgentMessagePart', id: string, messageId: string, orderIndex: number, type: string, textContent?: string | null, reasoningContent?: string | null, toolName?: string | null, toolCallId?: string | null, toolInput?: any | null, toolOutput?: any | null, state?: string | null, providerExecuted?: boolean | null, errorMessage?: string | null, errorDetails?: any | null, sourceUrlSourceId?: string | null, sourceUrlUrl?: string | null, sourceUrlTitle?: string | null, sourceDocumentSourceId?: string | null, sourceDocumentMediaType?: string | null, sourceDocumentTitle?: string | null, sourceDocumentFilename?: string | null, fileMediaType?: string | null, fileFilename?: string | null, fileUrl?: string | null, fileId?: string | null, providerMetadata?: any | null, createdAt: string }> }>, chatStreamCatchupChunks: { __typename?: 'ChatStreamCatchupChunks', chunks: Array<any>, maxSeq: number } };
export type GetChatMessagesQuery = { __typename?: 'Query', chatMessages: Array<{ __typename?: 'AgentMessage', id: string, threadId: string, turnId?: string | null, role: string, status: string, createdAt: string, parts: Array<{ __typename?: 'AgentMessagePart', id: string, messageId: string, orderIndex: number, type: string, textContent?: string | null, reasoningContent?: string | null, toolName?: string | null, toolCallId?: string | null, toolInput?: any | null, toolOutput?: any | null, state?: string | null, providerExecuted?: boolean | null, errorMessage?: string | null, errorDetails?: any | null, sourceUrlSourceId?: string | null, sourceUrlUrl?: string | null, sourceUrlTitle?: string | null, sourceDocumentSourceId?: string | null, sourceDocumentMediaType?: string | null, sourceDocumentTitle?: string | null, sourceDocumentFilename?: string | null, fileMediaType?: string | null, fileFilename?: string | null, fileUrl?: string | null, fileId?: string | null, providerMetadata?: any | null, createdAt: string }> }>, chatStreamCatchupChunks: { __typename?: 'ChatStreamCatchupChunks', chunks: Array<any>, maxSeq: number, error?: { __typename?: 'ChatStreamError', code: string, message: string } | null } };
export type GetChatThreadsQueryVariables = Exact<{ [key: string]: never; }>;
@@ -8702,6 +8724,7 @@ export const DeleteSkillDocument = {"kind":"Document","definitions":[{"kind":"Op
export const EvaluateAgentTurnDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EvaluateAgentTurn"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"turnId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"evaluateAgentTurn"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"turnId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"turnId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"turnId"}},{"kind":"Field","name":{"kind":"Name","value":"score"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode<EvaluateAgentTurnMutation, EvaluateAgentTurnMutationVariables>;
export const RemoveRoleFromAgentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveRoleFromAgent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeRoleFromAgent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"agentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}}}]}]}}]} as unknown as DocumentNode<RemoveRoleFromAgentMutation, RemoveRoleFromAgentMutationVariables>;
export const RenameChatThreadDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RenameChatThread"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"title"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"renameChatThread"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"title"},"value":{"kind":"Variable","name":{"kind":"Name","value":"title"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<RenameChatThreadMutation, RenameChatThreadMutationVariables>;
export const RetryChatMessageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RetryChatMessage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"retryChatMessage"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}},{"kind":"Argument","name":{"kind":"Name","value":"modelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"messageId"}},{"kind":"Field","name":{"kind":"Name","value":"queued"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}}]}}]}}]} as unknown as DocumentNode<RetryChatMessageMutation, RetryChatMessageMutationVariables>;
export const RunEvaluationInputDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RunEvaluationInput"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"runEvaluationInput"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"agentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"agentId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"evaluations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"score"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode<RunEvaluationInputMutation, RunEvaluationInputMutationVariables>;
export const SendChatMessageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SendChatMessage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"text"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"messageId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"browsingContext"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fileAttachments"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"FileAttachmentInput"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sendChatMessage"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}},{"kind":"Argument","name":{"kind":"Name","value":"text"},"value":{"kind":"Variable","name":{"kind":"Name","value":"text"}}},{"kind":"Argument","name":{"kind":"Name","value":"messageId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"messageId"}}},{"kind":"Argument","name":{"kind":"Name","value":"browsingContext"},"value":{"kind":"Variable","name":{"kind":"Name","value":"browsingContext"}}},{"kind":"Argument","name":{"kind":"Name","value":"modelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}}},{"kind":"Argument","name":{"kind":"Name","value":"fileAttachments"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fileAttachments"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"messageId"}},{"kind":"Field","name":{"kind":"Name","value":"queued"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}}]}}]}}]} as unknown as DocumentNode<SendChatMessageMutation, SendChatMessageMutationVariables>;
export const StopAgentChatStreamDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StopAgentChatStream"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stopAgentChatStream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}]}]}}]} as unknown as DocumentNode<StopAgentChatStreamMutation, StopAgentChatStreamMutationVariables>;
@@ -8715,7 +8738,7 @@ export const FindOneAgentDocument = {"kind":"Document","definitions":[{"kind":"O
export const FindOneSkillDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneSkill"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"skill"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SkillFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SkillFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Skill"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindOneSkillQuery, FindOneSkillQueryVariables>;
export const FindWorkspaceAiStatsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindWorkspaceAiStats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findWorkspaceAiStats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"conversationsCount"}},{"kind":"Field","name":{"kind":"Name","value":"skillsCount"}},{"kind":"Field","name":{"kind":"Name","value":"toolsCount"}}]}}]}}]} as unknown as DocumentNode<FindWorkspaceAiStatsQuery, FindWorkspaceAiStatsQueryVariables>;
export const GetAgentTurnsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAgentTurns"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"agentTurns"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"agentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"agentId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"evaluations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"score"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"reasoningContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}},{"kind":"Field","name":{"kind":"Name","value":"toolCallId"}},{"kind":"Field","name":{"kind":"Name","value":"toolInput"}},{"kind":"Field","name":{"kind":"Name","value":"toolOutput"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"providerExecuted"}},{"kind":"Field","name":{"kind":"Name","value":"errorDetails"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlUrl"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"fileFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileUrl"}},{"kind":"Field","name":{"kind":"Name","value":"providerMetadata"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetAgentTurnsQuery, GetAgentTurnsQueryVariables>;
export const GetChatMessagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChatMessages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chatMessages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"turnId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"messageId"}},{"kind":"Field","name":{"kind":"Name","value":"orderIndex"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"reasoningContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}},{"kind":"Field","name":{"kind":"Name","value":"toolCallId"}},{"kind":"Field","name":{"kind":"Name","value":"toolInput"}},{"kind":"Field","name":{"kind":"Name","value":"toolOutput"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"providerExecuted"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"errorDetails"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlUrl"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"fileFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileUrl"}},{"kind":"Field","name":{"kind":"Name","value":"fileId"}},{"kind":"Field","name":{"kind":"Name","value":"providerMetadata"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"chatStreamCatchupChunks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chunks"}},{"kind":"Field","name":{"kind":"Name","value":"maxSeq"}}]}}]}}]} as unknown as DocumentNode<GetChatMessagesQuery, GetChatMessagesQueryVariables>;
export const GetChatMessagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChatMessages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chatMessages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"turnId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"messageId"}},{"kind":"Field","name":{"kind":"Name","value":"orderIndex"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"reasoningContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}},{"kind":"Field","name":{"kind":"Name","value":"toolCallId"}},{"kind":"Field","name":{"kind":"Name","value":"toolInput"}},{"kind":"Field","name":{"kind":"Name","value":"toolOutput"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"providerExecuted"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"errorDetails"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlUrl"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"fileFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileUrl"}},{"kind":"Field","name":{"kind":"Name","value":"fileId"}},{"kind":"Field","name":{"kind":"Name","value":"providerMetadata"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"chatStreamCatchupChunks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chunks"}},{"kind":"Field","name":{"kind":"Name","value":"maxSeq"}},{"kind":"Field","name":{"kind":"Name","value":"error"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<GetChatMessagesQuery, GetChatMessagesQueryVariables>;
export const GetChatThreadsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChatThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chatThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"contextWindowTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputCredits"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputCredits"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<GetChatThreadsQuery, GetChatThreadsQueryVariables>;
export const GetToolIndexDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetToolIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getToolIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"objectName"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}}]}}]}}]} as unknown as DocumentNode<GetToolIndexQuery, GetToolIndexQueryVariables>;
export const GetToolInputSchemaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetToolInputSchema"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"toolName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getToolInputSchema"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"toolName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"toolName"}}}]}]}}]} as unknown as DocumentNode<GetToolInputSchemaQuery, GetToolInputSchemaQueryVariables>;
@@ -14,6 +14,7 @@ import { agentChatQueuedMessagesComponentFamilyState } from '@/ai/states/agentCh
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages';
import { SSE_CLIENT_RECONNECTED_EVENT_NAME } from '@/sse-db-event/constants/SseClientReconnectedEventName';
import { useQueryWithCallbacks } from '@/apollo/hooks/useQueryWithCallbacks';
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
@@ -87,7 +88,7 @@ export const AgentChatMessagesFetchEffect = () => {
const catchup = data.chatStreamCatchupChunks;
if (!isDefined(catchup) || catchup.chunks.length === 0) {
if (!isDefined(catchup)) {
return;
}
@@ -122,6 +123,15 @@ export const AgentChatMessagesFetchEffect = () => {
seq: chunkSeq,
} as AgentChatSubscriptionEvent);
}
// Never replay a persisted error into an active live stream.
if (isDefined(catchup.error) && firstLiveSeq === null) {
handleEvent({
type: 'stream-error',
code: catchup.error.code,
message: catchup.error.message,
} as AgentChatSubscriptionEvent);
}
},
[
setAgentChatFetchedMessages,
@@ -168,5 +178,11 @@ export const AgentChatMessagesFetchEffect = () => {
onBrowserEvent: handleRefetchMessages,
});
// Replay events missed while the SSE connection was down.
useListenToBrowserEvent({
eventName: SSE_CLIENT_RECONNECTED_EVENT_NAME,
onBrowserEvent: handleRefetchMessages,
});
return null;
};
@@ -1,8 +1,10 @@
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { IconAlertCircle } from 'twenty-ui/icon';
import { IconAlertCircle, IconRefresh } from 'twenty-ui/icon';
import { Button } from 'twenty-ui/input';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type AiChatError } from '@/ai/types/AiChatError';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
@@ -45,9 +47,13 @@ const StyledErrorMessage = styled.div`
type AiChatErrorMessageProps = {
error: AiChatError;
onRetry?: () => void;
};
export const AiChatErrorMessage = ({ error }: AiChatErrorMessageProps) => {
export const AiChatErrorMessage = ({
error,
onRetry,
}: AiChatErrorMessageProps) => {
const { theme } = useContext(ThemeContext);
const errorMessage = CombinedGraphQLErrors.is(error)
? getErrorMessageFromApolloError(error)
@@ -64,6 +70,15 @@ export const AiChatErrorMessage = ({ error }: AiChatErrorMessageProps) => {
{errorMessage || t`An error occurred while processing your message`}
</StyledErrorMessage>
</StyledErrorContent>
{isDefined(onRetry) && (
<Button
variant="secondary"
size="small"
Icon={IconRefresh}
onClick={onRetry}
title={t`Retry`}
/>
)}
</StyledErrorContainer>
);
};
@@ -6,9 +6,13 @@ import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
type AiChatErrorRendererProps = {
error: AiChatError;
onRetry?: () => void;
};
export const AiChatErrorRenderer = ({ error }: AiChatErrorRendererProps) => {
export const AiChatErrorRenderer = ({
error,
onRetry,
}: AiChatErrorRendererProps) => {
if (isGraphqlErrorOfType(error, AiChatErrorCode.BILLING_CREDITS_EXHAUSTED)) {
//Handle by AIChatNoMoreBillingCreditsBanner
return null;
@@ -18,5 +22,5 @@ export const AiChatErrorRenderer = ({ error }: AiChatErrorRendererProps) => {
return <AiChatApiKeyNotConfiguredMessage />;
}
return <AiChatErrorMessage error={error} />;
return <AiChatErrorMessage error={error} onRetry={onRetry} />;
};
@@ -1,5 +1,6 @@
import { AiChatErrorRenderer } from '@/ai/components/AiChatErrorRenderer';
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { useRetryChatMessage } from '@/ai/hooks/useRetryChatMessage';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState';
@@ -17,6 +18,7 @@ const StyledErrorWrapper = styled.div`
`;
export const AiChatErrorUnderMessageList = () => {
const { retryChatMessage } = useRetryChatMessage();
const agentChatDisplayedThread = useAtomStateValue(
agentChatDisplayedThreadState,
);
@@ -50,7 +52,7 @@ export const AiChatErrorUnderMessageList = () => {
return (
<StyledErrorWrapper>
<AiChatErrorRenderer error={agentChatError} />
<AiChatErrorRenderer error={agentChatError} onRetry={retryChatMessage} />
</StyledErrorWrapper>
);
};
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const RETRY_CHAT_MESSAGE = gql`
mutation RetryChatMessage($threadId: UUID!, $modelId: String) {
retryChatMessage(threadId: $threadId, modelId: $modelId) {
messageId
queued
streamId
}
}
`;
@@ -42,6 +42,10 @@ export const GET_CHAT_MESSAGES = gql`
chatStreamCatchupChunks(threadId: $threadId) {
chunks
maxSeq
error {
code
message
}
}
}
`;
@@ -0,0 +1,53 @@
import { useApolloClient } from '@apollo/client/react';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { AGENT_CHAT_INSTANCE_ID } from '@/ai/constants/AgentChatInstanceId';
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
import { RETRY_CHAT_MESSAGE } from '@/ai/graphql/mutations/retryChatMessage';
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
export const useRetryChatMessage = () => {
const apolloClient = useApolloClient();
const store = useStore();
const { modelIdForRequest } = useAgentChatModelId();
const retryChatMessage = useCallback(async () => {
const threadId = store.get(agentChatDisplayedThreadState.atom);
if (!isDefined(threadId)) {
return;
}
const errorAtom = agentChatErrorComponentFamilyState.atomFamily({
instanceId: AGENT_CHAT_INSTANCE_ID,
familyKey: { threadId },
});
const previousError = store.get(errorAtom);
store.set(errorAtom, null);
try {
await apolloClient.mutate({
mutation: RETRY_CHAT_MESSAGE,
variables: {
threadId,
modelId: modelIdForRequest ?? undefined,
},
});
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
} catch (retryError) {
store.set(
errorAtom,
retryError instanceof Error ? retryError : previousError,
);
}
}, [apolloClient, store, modelIdForRequest]);
return { retryChatMessage };
};
+3 -2
View File
@@ -18,9 +18,10 @@ const jestConfig = {
setupFilesAfterEnv: ['./setupTests.ts'],
transformIgnorePatterns: [
// jsdom 29 pulls ESM-only transitive deps (parse5, entities, tough-cookie,
// @exodus/bytes via html-encoding-sniffer, @csstools/@asamuzakjp css engine).
// @exodus/bytes via html-encoding-sniffer, @csstools/@asamuzakjp css engine),
// and e2b/@e2b pull ESM-only chalk.
// jest's CJS runtime can't load their `export` syntax, so let swc transform them.
'/node_modules/(?!(file-type|@file-type|strtok3|token-types|@borewit|@tokenizer|uint8array-extras|read-next-line|digest-fetch|md5|js-sha256|js-sha512|base-64|charenc|crypt|email-reply-parser|jsdom|html-encoding-sniffer|whatwg-encoding|@exodus|parse5|entities|tough-cookie|@csstools|@asamuzakjp|graphql-upload|fs-capacitor)/)',
'/node_modules/(?!(file-type|@file-type|strtok3|token-types|@borewit|@tokenizer|uint8array-extras|read-next-line|digest-fetch|md5|js-sha256|js-sha512|base-64|charenc|crypt|email-reply-parser|jsdom|html-encoding-sniffer|whatwg-encoding|@exodus|parse5|entities|tough-cookie|@csstools|@asamuzakjp|graphql-upload|fs-capacitor|e2b|@e2b|chalk)/)',
],
testRegex: '.*\\.spec\\.ts$',
transform: {
@@ -0,0 +1,21 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.19.0', 1821000000000)
export class AddLastStreamErrorToAgentChatThreadFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" ADD COLUMN IF NOT EXISTS "lastStreamError" jsonb`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" DROP COLUMN IF EXISTS "lastStreamError"`,
);
}
}
@@ -0,0 +1,37 @@
import { type QueryRunner } from 'typeorm';
import { AddLastStreamErrorToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1821000000000-add-last-stream-error-to-agent-chat-thread';
describe('AddLastStreamErrorToAgentChatThreadFastInstanceCommand', () => {
let command: AddLastStreamErrorToAgentChatThreadFastInstanceCommand;
beforeEach(() => {
command = new AddLastStreamErrorToAgentChatThreadFastInstanceCommand();
});
describe('up', () => {
it('adds the lastStreamError column without mutating data', async () => {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
await command.up(queryRunner);
expect(query.mock.calls.map((call) => call[0] as string)).toEqual([
'ALTER TABLE "core"."agentChatThread" ADD COLUMN IF NOT EXISTS "lastStreamError" jsonb',
]);
});
});
describe('down', () => {
it('drops the lastStreamError column', async () => {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
await command.down(queryRunner);
expect(query.mock.calls.map((call) => call[0] as string)).toEqual([
'ALTER TABLE "core"."agentChatThread" DROP COLUMN IF EXISTS "lastStreamError"',
]);
});
});
});
@@ -0,0 +1,4 @@
// Referenced by @WasIntroducedInUpgrade on the "lastStreamError" column so
// pre-2.19 upgrade steps don't SELECT it before this command adds it.
export const ADD_LAST_STREAM_ERROR_TO_AGENT_CHAT_THREAD_UPGRADE_COMMAND_NAME =
'2.19.0_AddLastStreamErrorToAgentChatThreadFastInstanceCommand_1821000000000';
@@ -42,6 +42,7 @@ import { CreateApplicationTranslationCoreTableFastInstanceCommand } from 'src/da
import { AddTsVectorFieldMetadataIdToSearchFieldMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-18/2-18-instance-command-fast-1810000001000-add-ts-vector-field-metadata-id-to-search-field-metadata';
import { BackfillTsVectorFieldMetadataIdOnSearchFieldMetadataSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-18/2-18-instance-command-slow-1810000003000-backfill-ts-vector-field-metadata-id-on-search-field-metadata';
import { AddMetadataOverridesColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1820000100000-add-metadata-overrides-column';
import { AddLastStreamErrorToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1821000000000-add-last-stream-error-to-agent-chat-thread';
import { BackfillMetadataOverridesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-instance-command-slow-1820000110000-backfill-metadata-overrides';
import { AddCacheTokensToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777455269302-add-cache-tokens-to-agent-chat-thread';
import { AddLogoToApplicationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777539664664-add-logo-to-application';
@@ -175,5 +176,6 @@ export const INSTANCE_COMMANDS = [
BackfillTsVectorFieldMetadataIdOnSearchFieldMetadataSlowInstanceCommand,
AddMetadataOverridesColumnFastInstanceCommand,
BackfillMetadataOverridesSlowInstanceCommand,
AddLastStreamErrorToAgentChatThreadFastInstanceCommand,
DropMetadataStandardOverridesColumnFastInstanceCommand,
];
@@ -2,6 +2,8 @@ import { Field, Int, ObjectType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { ChatStreamErrorDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/chat-stream-error.dto';
@ObjectType('ChatStreamCatchupChunks')
export class ChatStreamCatchupChunksDTO {
@Field(() => [GraphQLJSON])
@@ -9,4 +11,7 @@ export class ChatStreamCatchupChunksDTO {
@Field(() => Int)
maxSeq: number;
@Field(() => ChatStreamErrorDTO, { nullable: true })
error: ChatStreamErrorDTO | null;
}
@@ -0,0 +1,10 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('ChatStreamError')
export class ChatStreamErrorDTO {
@Field(() => String)
code: string;
@Field(() => String)
message: string;
}
@@ -10,9 +10,12 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { ADD_LAST_STREAM_ERROR_TO_AGENT_CHAT_THREAD_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-19/add-last-stream-error-to-agent-chat-thread-upgrade-command-name.constant';
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { AgentMessageEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
import { AgentTurnEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-turn.entity';
import { type AgentChatThreadLastStreamError } from 'src/engine/metadata-modules/ai/ai-chat/types/agent-chat-thread-last-stream-error.type';
import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
@@ -70,6 +73,13 @@ export class AgentChatThreadEntity {
@Column({ type: 'varchar', nullable: true })
activeStreamId: string | null;
@WasIntroducedInUpgrade({
upgradeCommandName:
ADD_LAST_STREAM_ERROR_TO_AGENT_CHAT_THREAD_UPGRADE_COMMAND_NAME,
})
@Column({ type: 'jsonb', nullable: true })
lastStreamError: AgentChatThreadLastStreamError | null;
@OneToMany(() => AgentTurnEntity, (turn) => turn.thread)
turns: EntityRelation<AgentTurnEntity[]>;
@@ -28,6 +28,7 @@ import { AgentChatStreamingService } from 'src/engine/metadata-modules/ai/ai-cha
import { AgentChatService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service';
import { ChatExecutionService } from 'src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service';
import { getCancelChannel } from 'src/engine/metadata-modules/ai/ai-chat/utils/get-cancel-channel.util';
import { mapErrorToStreamError } from 'src/engine/metadata-modules/ai/ai-chat/utils/map-error-to-stream-error.util';
import type { AiModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
@@ -55,6 +56,8 @@ export class StreamAgentChatJob {
@Process(STREAM_AGENT_CHAT_JOB_NAME)
async handle(data: StreamAgentChatJobData): Promise<void> {
await this.eventPublisherService.resetStreamState(data.threadId);
const workspace = await this.workspaceRepository.findOne({
where: { id: data.workspaceId },
});
@@ -87,17 +90,33 @@ export class StreamAgentChatJob {
this.logger.error(
`Stream ${data.streamId} failed: ${error instanceof Error ? error.message : String(error)}`,
);
const streamError = mapErrorToStreamError(error);
await this.threadRepository
.update(
data.workspaceId,
{ id: data.threadId },
{
lastStreamError: {
...streamError,
failedAt: new Date().toISOString(),
},
},
)
.catch((persistError) => {
this.logger.error(
`Failed to persist stream error for thread ${data.threadId}: ${persistError instanceof Error ? persistError.message : String(persistError)}`,
);
});
await this.eventPublisherService
.publish({
threadId: data.threadId,
workspaceId: data.workspaceId,
event: {
type: 'stream-error',
code: 'STREAM_EXECUTION_FAILED',
message:
error instanceof Error
? error.message
: 'Stream execution failed',
code: streamError.code,
message: streamError.message,
},
})
.catch(() => {});
@@ -197,6 +216,7 @@ export class StreamAgentChatJob {
let lastStepConversationSize = 0;
let totalCacheCreationTokens = 0;
let streamError: unknown;
let streamFinishError: unknown;
let checkHasNoMoreAvailableCredits: () => boolean = () => false;
// onFinish fires before the uiStream is fully drained. We use this
@@ -282,6 +302,7 @@ export class StreamAgentChatJob {
});
},
onFinish: async ({ responseMessage, isAborted }) => {
// Rejecting here would race chunks still draining.
try {
await this.handleStreamFinish({
assistantMessageId,
@@ -299,15 +320,23 @@ export class StreamAgentChatJob {
userMessagePromise,
});
await titleWritePromise;
resolveStreamFinished();
} catch (error) {
reject(error);
streamFinishError = error;
} finally {
resolveStreamFinished();
}
},
sendReasoning: true,
}),
);
},
// Errors thrown before the model stream merges never reach onFinish.
onError: (error) => {
streamError = error;
resolveStreamFinished();
return error instanceof Error ? error.message : String(error);
},
});
// Publish all chunks first, then signal completion. This guarantees
@@ -315,6 +344,10 @@ export class StreamAgentChatJob {
void (async () => {
try {
for await (const chunk of uiStream) {
if ((chunk as { type?: string }).type === 'error') {
continue;
}
await this.eventPublisherService.publish({
threadId: data.threadId,
workspaceId: data.workspaceId,
@@ -329,6 +362,8 @@ export class StreamAgentChatJob {
if (streamError) {
reject(streamError);
} else if (streamFinishError) {
reject(streamFinishError);
} else if (checkHasNoMoreAvailableCredits()) {
await this.eventPublisherService.publish({
threadId: data.threadId,
@@ -545,6 +580,7 @@ export class StreamAgentChatJob {
`"totalCacheCreationTokens" + ${totalCacheCreationTokens}`,
contextWindowTokens: modelConfig.contextWindowTokens,
conversationSize: lastStepConversationSize,
lastStreamError: null,
},
);
@@ -102,13 +102,25 @@ export class AgentChatResolver {
@AuthUserWorkspaceId() userWorkspaceId: string,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
await this.agentChatService.getThreadById({
const thread = await this.agentChatService.getThreadById({
threadId,
userWorkspaceId,
workspaceId,
});
return this.eventPublisherService.getAccumulatedChunks(threadId);
const { chunks, maxSeq } =
await this.eventPublisherService.getAccumulatedChunks(threadId);
return {
chunks,
maxSeq,
error: thread.lastStreamError
? {
code: thread.lastStreamError.code,
message: thread.lastStreamError.message,
}
: null,
};
}
@Mutation(() => AgentChatThreadDTO)
@@ -211,6 +223,42 @@ export class AgentChatResolver {
};
}
@Mutation(() => SendChatMessageResultDTO)
async retryChatMessage(
@Args('threadId', { type: () => UUIDScalarType }) threadId: string,
@Args('modelId', { type: () => String, nullable: true })
modelId: string | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SendChatMessageResultDTO> {
if (this.aiModelRegistryService.getAvailableModels().length === 0) {
throw new AiException(
'No AI models are available. Configure at least one AI provider.',
AiExceptionCode.API_KEY_NOT_CONFIGURED,
);
}
this.aiModelRegistryService.validateModelAvailability(
modelId ?? workspace.smartModel,
workspace,
);
await this.billingUsageService.hasAvailableCreditsOrThrow(workspace.id);
const result = await this.agentChatStreamingService.retryLastFailedTurn({
threadId,
userWorkspaceId,
workspace,
modelId,
});
return {
messageId: result.messageId,
queued: false,
streamId: result.streamId,
};
}
@Mutation(() => Boolean)
async stopAgentChatStream(
@Args('threadId', { type: () => UUIDScalarType }) threadId: string,
@@ -0,0 +1,142 @@
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.retryLastFailedTurn', () => {
const workspace = { id: 'workspace-id' } as WorkspaceEntity;
const failedThread = {
id: 'thread-id',
title: 'Thread title',
conversationSize: 42,
activeStreamId: null,
lastStreamError: {
code: 'STREAM_EXECUTION_FAILED',
message: 'Provider timed out',
failedAt: '2026-01-01T00:00:00.000Z',
},
} as unknown as AgentChatThreadEntity;
const userMessageEntity = {
id: 'user-message-id',
role: AgentMessageRole.USER,
status: AgentMessageStatus.SENT,
parts: [{ type: 'text', textContent: 'hello', orderIndex: 0 }],
createdAt: new Date('2026-01-01T00:00:00.000Z'),
} as unknown as AgentMessageEntity;
const buildService = ({
thread = failedThread,
lastUserMessage = { id: 'user-message-id', turnId: 'turn-id' },
threadMessages = [userMessageEntity],
} = {}) => {
const threadRepository = {
findOne: jest.fn().mockResolvedValue(thread),
update: jest.fn().mockResolvedValue(undefined),
};
const messageQueueService = { add: jest.fn().mockResolvedValue(undefined) };
const agentChatService = {
findLatestSentUserMessage: jest.fn().mockResolvedValue(lastUserMessage),
deleteAssistantMessagesForTurn: jest.fn().mockResolvedValue(undefined),
getMessagesForThread: jest.fn().mockResolvedValue(threadMessages),
};
const service = new AgentChatStreamingService(
threadRepository as never,
{ find: jest.fn() } as never,
messageQueueService as never,
agentChatService as never,
{ publish: jest.fn() } as never,
{ signFileByIdUrl: jest.fn() } as never,
);
return { service, threadRepository, messageQueueService, agentChatService };
};
const retryArguments = {
threadId: 'thread-id',
userWorkspaceId: 'user-workspace-id',
workspace,
};
it('rejects when the thread has no persisted stream error', async () => {
const { service, messageQueueService } = buildService({
thread: { ...failedThread, lastStreamError: null },
});
await expect(
service.retryLastFailedTurn(retryArguments),
).rejects.toMatchObject({
code: AiExceptionCode.NO_FAILED_TURN_TO_RETRY,
});
expect(messageQueueService.add).not.toHaveBeenCalled();
});
it('rejects when a stream is already active', async () => {
const { service, messageQueueService } = buildService({
thread: { ...failedThread, activeStreamId: 'stream-id' },
});
await expect(
service.retryLastFailedTurn(retryArguments),
).rejects.toMatchObject({
code: AiExceptionCode.NO_FAILED_TURN_TO_RETRY,
});
expect(messageQueueService.add).not.toHaveBeenCalled();
});
it('rejects without clearing state when a newer message exists', async () => {
const newerAssistantMessage = {
...userMessageEntity,
id: 'newer-message-id',
role: AgentMessageRole.ASSISTANT,
} as unknown as AgentMessageEntity;
const { service, threadRepository } = buildService({
threadMessages: [userMessageEntity, newerAssistantMessage],
});
await expect(
service.retryLastFailedTurn(retryArguments),
).rejects.toMatchObject({
code: AiExceptionCode.NO_FAILED_TURN_TO_RETRY,
});
expect(threadRepository.update).not.toHaveBeenCalled();
});
it('drops the failed output, re-enqueues the turn, and clears the error', async () => {
const { service, threadRepository, messageQueueService, agentChatService } =
buildService();
const result = await service.retryLastFailedTurn({
...retryArguments,
modelId: 'model-id',
});
expect(
agentChatService.deleteAssistantMessagesForTurn,
).toHaveBeenCalledWith({ turnId: 'turn-id', workspaceId: 'workspace-id' });
expect(messageQueueService.add).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
threadId: 'thread-id',
existingTurnId: 'turn-id',
lastUserMessageText: 'hello',
modelId: 'model-id',
hasTitle: true,
conversationSizeTokens: 42,
}),
);
expect(threadRepository.update).toHaveBeenCalledWith(
'workspace-id',
{ id: 'thread-id' },
{ activeStreamId: result.streamId, lastStreamError: null },
);
expect(result.messageId).toBe('user-message-id');
});
});
@@ -55,6 +55,12 @@ export class AgentChatEventPublisherService {
});
}
async resetStreamState(threadId: string): Promise<void> {
const redis = this.redisClientService.getClient();
await redis.del(this.getStreamChunksKey(threadId));
}
async getAccumulatedChunks(threadId: string): Promise<{
chunks: Record<string, unknown>[];
maxSeq: number;
@@ -7,6 +7,7 @@ import {
isExtendedFileUIPart,
} from 'twenty-shared/ai';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In, Like } from 'typeorm';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@@ -139,12 +140,108 @@ export class AgentChatStreamingService {
await this.threadRepository.update(
workspace.id,
{ id: thread.id },
{ activeStreamId: streamId },
{ activeStreamId: streamId, lastStreamError: null },
);
return { streamId, messageId: savedUserMessage.id };
}
async retryLastFailedTurn({
threadId,
userWorkspaceId,
workspace,
modelId,
}: {
threadId: string;
userWorkspaceId: string;
workspace: WorkspaceEntity;
modelId?: string;
}): Promise<{ streamId: string; messageId: string }> {
const thread = await this.threadRepository.findOne(workspace.id, {
where: { id: threadId, userWorkspaceId },
});
if (!thread) {
throw new AiException(
'Thread not found',
AiExceptionCode.THREAD_NOT_FOUND,
);
}
if (
!isDefined(thread.lastStreamError) ||
isDefined(thread.activeStreamId)
) {
throw new AiException(
'There is no failed turn to retry on this thread',
AiExceptionCode.NO_FAILED_TURN_TO_RETRY,
);
}
const lastUserMessage =
await this.agentChatService.findLatestSentUserMessage({
threadId,
workspaceId: workspace.id,
});
if (!isDefined(lastUserMessage) || !isDefined(lastUserMessage.turnId)) {
throw new AiException(
'There is no failed turn to retry on this thread',
AiExceptionCode.NO_FAILED_TURN_TO_RETRY,
);
}
await this.agentChatService.deleteAssistantMessagesForTurn({
turnId: lastUserMessage.turnId,
workspaceId: workspace.id,
});
const messages = await this.loadMessagesFromDB(
threadId,
userWorkspaceId,
workspace.id,
);
const retriedMessage = messages[messages.length - 1];
if (!retriedMessage || retriedMessage.id !== lastUserMessage.id) {
throw new AiException(
'There is no failed turn to retry on this thread',
AiExceptionCode.NO_FAILED_TURN_TO_RETRY,
);
}
const textPart = retriedMessage.parts.find((part) => part.type === 'text');
const streamId = generateId();
await this.messageQueueService.add<StreamAgentChatJobData>(
STREAM_AGENT_CHAT_JOB_NAME,
{
threadId,
streamId,
userWorkspaceId,
workspaceId: workspace.id,
messages,
browsingContext: null,
modelId,
lastUserMessageText: textPart?.text ?? '',
lastUserMessageParts: retriedMessage.parts,
hasTitle: !!thread.title,
conversationSizeTokens: thread.conversationSize,
existingTurnId: lastUserMessage.turnId,
},
);
await this.threadRepository.update(
workspace.id,
{ id: threadId },
{ activeStreamId: streamId, lastStreamError: null },
);
return { streamId, messageId: lastUserMessage.id };
}
async flushNextQueuedMessage(
threadId: string,
userWorkspaceId: string,
@@ -252,7 +349,7 @@ export class AgentChatStreamingService {
await this.threadRepository.update(
workspaceId,
{ id: threadId },
{ activeStreamId: streamId },
{ activeStreamId: streamId, lastStreamError: null },
);
}
@@ -260,6 +260,37 @@ export class AgentChatService {
} as AgentMessageEntity;
}
async findLatestSentUserMessage({
threadId,
workspaceId,
}: {
threadId: string;
workspaceId: string;
}): Promise<Pick<AgentMessageEntity, 'id' | 'turnId'> | null> {
return this.messageRepository.findOne(workspaceId, {
where: {
threadId,
role: AgentMessageRole.USER,
status: AgentMessageStatus.SENT,
},
order: { createdAt: 'DESC', id: 'DESC' },
select: ['id', 'turnId'],
});
}
async deleteAssistantMessagesForTurn({
turnId,
workspaceId,
}: {
turnId: string;
workspaceId: string;
}): Promise<void> {
await this.messageRepository.delete(workspaceId, {
turnId,
role: AgentMessageRole.ASSISTANT,
});
}
async hasAssistantMessageForTurn({
turnId,
workspaceId,
@@ -0,0 +1,5 @@
import { type StreamErrorPayload } from 'src/engine/metadata-modules/ai/ai-chat/utils/map-error-to-stream-error.util';
export type AgentChatThreadLastStreamError = StreamErrorPayload & {
failedAt: string;
};
@@ -0,0 +1,49 @@
import {
AiException,
AiExceptionCode,
} from 'src/engine/metadata-modules/ai/ai.exception';
import {
STREAM_EXECUTION_FAILED_CODE,
mapErrorToStreamError,
} from 'src/engine/metadata-modules/ai/ai-chat/utils/map-error-to-stream-error.util';
describe('mapErrorToStreamError', () => {
it('maps an AiException to its typed code and message', () => {
const error = new AiException(
'No AI models are available. Configure at least one AI provider.',
AiExceptionCode.API_KEY_NOT_CONFIGURED,
);
expect(mapErrorToStreamError(error)).toEqual({
code: AiExceptionCode.API_KEY_NOT_CONFIGURED,
message:
'No AI models are available. Configure at least one AI provider.',
});
});
it('collapses a generic Error to the fallback code but keeps its message', () => {
expect(mapErrorToStreamError(new Error('Provider timed out'))).toEqual({
code: STREAM_EXECUTION_FAILED_CODE,
message: 'Provider timed out',
});
});
it('handles non-Error values with a stable fallback', () => {
expect(mapErrorToStreamError('boom')).toEqual({
code: STREAM_EXECUTION_FAILED_CODE,
message: 'Stream execution failed',
});
});
it('truncates oversized provider messages before they are persisted', () => {
const result = mapErrorToStreamError(new Error('x'.repeat(10_000)));
expect(result.code).toBe(STREAM_EXECUTION_FAILED_CODE);
expect(result.message.length).toBe(2001);
expect(result.message.endsWith('…')).toBe(true);
});
it('leaves short messages untouched', () => {
expect(mapErrorToStreamError(new Error('short')).message).toBe('short');
});
});
@@ -0,0 +1,28 @@
import { AiException } from 'src/engine/metadata-modules/ai/ai.exception';
export const STREAM_EXECUTION_FAILED_CODE = 'STREAM_EXECUTION_FAILED';
const STREAM_ERROR_MESSAGE_MAX_LENGTH = 2000;
export type StreamErrorPayload = {
code: string;
message: string;
};
const truncateMessage = (message: string): string =>
message.length > STREAM_ERROR_MESSAGE_MAX_LENGTH
? `${message.slice(0, STREAM_ERROR_MESSAGE_MAX_LENGTH)}`
: message;
export const mapErrorToStreamError = (error: unknown): StreamErrorPayload => {
if (error instanceof AiException) {
return { code: error.code, message: truncateMessage(error.message) };
}
return {
code: STREAM_EXECUTION_FAILED_CODE,
message: truncateMessage(
error instanceof Error ? error.message : 'Stream execution failed',
),
};
};
@@ -17,6 +17,7 @@ export enum AiExceptionCode {
USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND',
ROLE_NOT_FOUND = 'ROLE_NOT_FOUND',
ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS = 'ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS',
NO_FAILED_TURN_TO_RETRY = 'NO_FAILED_TURN_TO_RETRY',
}
const getAiExceptionUserFriendlyMessage = (code: AiExceptionCode) => {
@@ -45,6 +46,8 @@ const getAiExceptionUserFriendlyMessage = (code: AiExceptionCode) => {
return msg`Role not found.`;
case AiExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS:
return msg`This role cannot be assigned to agents.`;
case AiExceptionCode.NO_FAILED_TURN_TO_RETRY:
return msg`There is no failed message to retry.`;
default:
assertUnreachable(code);
}
@@ -30,6 +30,7 @@ export const aiGraphqlApiExceptionHandler = (error: Error) => {
case AiExceptionCode.INVALID_CHAT_THREAD_TITLE:
throw new UserInputError(error);
case AiExceptionCode.AGENT_ALREADY_EXISTS:
case AiExceptionCode.NO_FAILED_TURN_TO_RETRY:
throw new ConflictError(error);
case AiExceptionCode.AGENT_IS_STANDARD:
case AiExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS: