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:
+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