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:
@@ -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 };
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
+21
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+37
@@ -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"',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+4
@@ -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';
|
||||
+2
@@ -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,
|
||||
];
|
||||
|
||||
+5
@@ -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;
|
||||
}
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('ChatStreamError')
|
||||
export class ChatStreamErrorDTO {
|
||||
@Field(() => String)
|
||||
code: string;
|
||||
|
||||
@Field(() => String)
|
||||
message: string;
|
||||
}
|
||||
+10
@@ -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[]>;
|
||||
|
||||
|
||||
+43
-7
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+50
-2
@@ -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,
|
||||
|
||||
+142
@@ -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');
|
||||
});
|
||||
});
|
||||
+6
@@ -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;
|
||||
|
||||
+99
-2
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+31
@@ -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,
|
||||
|
||||
+5
@@ -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;
|
||||
};
|
||||
+49
@@ -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');
|
||||
});
|
||||
});
|
||||
+28
@@ -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);
|
||||
}
|
||||
|
||||
+1
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user