feat: queue messages + replace AI SDK with GraphQL SSE subscription (#19203)

## Summary

- **Queue messages while streaming**: Messages sent during active AI
streaming are queued server-side and auto-flushed when the current
stream completes. Frontend renders queued messages optimistically in a
dedicated queue UI.
- **Drop `@ai-sdk/react` + `resumable-stream`**: Replace the dual HTTP
SSE + AI SDK client architecture with a single GraphQL SSE subscription
per thread. All events (token chunks, message persistence, queue
updates, errors) flow through Redis PubSub → GraphQL subscription.
- **Server-driven architecture**: The server decides whether to queue or
stream (via `POST /:threadId/message`). The frontend mirrors this
decision for optimistic rendering but defers to the server response.
- **Reuse AI SDK accumulation logic**: `readUIMessageStream` from the
`ai` package handles chunk-to-message accumulation on the frontend,
avoiding a custom 780-line accumulator.

## Key files

**Backend:**
- `agent-chat-event-publisher.service.ts` — publishes events to Redis
PubSub
- `agent-chat-subscription.resolver.ts` — GraphQL subscription resolver
- `stream-agent-chat.job.ts` — publishes chunks via PubSub instead of
resumable-stream
- `agent-chat.controller.ts` — unified `POST /:threadId/message`
endpoint

**Frontend:**
- `useAgentChatSubscription.ts` — subscribes to `onAgentChatEvent`,
bridges to `readUIMessageStream`
- `useAgentChat.ts` — send/stop/optimistic rendering (no more AI SDK)
- `AgentChatStreamSubscriptionEffect.tsx` — replaces
`AgentChatAiSdkStreamEffect.tsx`

## Test plan

- [ ] Send message on new thread → optimistic render, streaming response
appears
- [ ] Send message while streaming → queued instantly (no flash in main
thread)
- [ ] Queued message auto-flushes after current stream completes
- [ ] Remove queued message via queue UI
- [ ] Stop streaming mid-response
- [ ] Leave chat idle for several minutes → streaming still works after
(SSE client recycling)
- [ ] Token refresh during session → requests succeed (authenticated
fetch)
- [ ] Switch threads while streaming → clean subscription handoff


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-02 10:10:13 +02:00
committed by GitHub
parent 3733a5a763
commit fd7387928c
79 changed files with 2617 additions and 1428 deletions
@@ -13,8 +13,8 @@ export class AgentMessageDTO {
@Field(() => UUIDScalarType)
threadId: string;
@Field(() => UUIDScalarType)
turnId: string;
@Field(() => UUIDScalarType, { nullable: true })
turnId: string | null;
@Field(() => UUIDScalarType, { nullable: true })
agentId: string | null;
@@ -22,9 +22,16 @@ export class AgentMessageDTO {
@Field()
role: 'system' | 'user' | 'assistant';
@Field()
status: 'queued' | 'sent';
@Field(() => [AgentMessagePartDTO])
parts: AgentMessagePartDTO[];
@IsDateString()
@Field(() => Date, { nullable: true })
processedAt: Date | null;
@IsDateString()
@Field()
createdAt: Date;
@@ -20,6 +20,11 @@ export enum AgentMessageRole {
ASSISTANT = 'assistant',
}
export enum AgentMessageStatus {
QUEUED = 'queued',
SENT = 'sent',
}
@Entity('agentMessage')
export class AgentMessageEntity {
@PrimaryGeneratedColumn('uuid')
@@ -35,15 +40,16 @@ export class AgentMessageEntity {
@JoinColumn({ name: 'threadId' })
thread: Relation<AgentChatThreadEntity>;
@Column('uuid')
@Column({ type: 'uuid', nullable: true })
@Index()
turnId: string;
turnId: string | null;
@ManyToOne(() => AgentTurnEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'turnId' })
turn: Relation<AgentTurnEntity>;
turn: Relation<AgentTurnEntity> | null;
@Column({ type: 'uuid', nullable: true })
@Index()
@@ -52,9 +58,19 @@ export class AgentMessageEntity {
@Column({ type: 'enum', enum: AgentMessageRole })
role: AgentMessageRole;
@Column({
type: 'enum',
enum: AgentMessageStatus,
default: AgentMessageStatus.SENT,
})
status: AgentMessageStatus;
@OneToMany(() => AgentMessagePartEntity, (part) => part.message)
parts: Relation<AgentMessagePartEntity[]>;
@Column({ type: 'timestamptz', nullable: true })
processedAt: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
}
@@ -0,0 +1,71 @@
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
import { type AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity';
// Maps TypeORM entity fields to UI message parts.
// A parallel mapping for GraphQL DTOs exists in the frontend at:
// packages/twenty-front/src/modules/ai/utils/mapDBPartToUIMessagePart.ts
export const mapDBPartToUIMessagePart = (
part: AgentMessagePartEntity,
): ExtendedUIMessagePart | null => {
switch (part.type) {
case 'text':
return {
type: 'text',
text: part.textContent ?? '',
};
case 'reasoning':
return {
type: 'reasoning',
text: part.reasoningContent ?? '',
state: (part.state as 'streaming' | 'done') ?? 'done',
};
case 'file':
return {
type: 'file',
mediaType: part.fileFilename?.endsWith('.png')
? 'image/png'
: 'application/octet-stream',
filename: part.fileFilename ?? '',
url: '',
};
case 'source-url':
return {
type: 'source-url',
sourceId: part.sourceUrlSourceId ?? '',
url: part.sourceUrlUrl ?? '',
title: part.sourceUrlTitle ?? '',
providerMetadata: part.providerMetadata ?? undefined,
};
case 'source-document':
return {
type: 'source-document',
sourceId: part.sourceDocumentSourceId ?? '',
mediaType: part.sourceDocumentMediaType ?? '',
title: part.sourceDocumentTitle ?? '',
filename: part.sourceDocumentFilename ?? '',
providerMetadata: part.providerMetadata ?? undefined,
};
case 'step-start':
return {
type: 'step-start',
};
case 'data-routing-status':
return null;
default: {
if (part.type.includes('tool-') && part.toolCallId) {
return {
type: part.type,
toolCallId: part.toolCallId,
input: part.toolInput ?? {},
output: part.toolOutput,
errorText: part.errorMessage ?? '',
state: part.state,
} as ExtendedUIMessagePart;
}
return null;
}
}
};
@@ -0,0 +1,13 @@
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
import { type AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity';
import { mapDBPartToUIMessagePart } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapDBPartToUIMessagePart';
export const mapDBPartsToUIMessageParts = (
parts: AgentMessagePartEntity[],
): ExtendedUIMessagePart[] => {
return parts
.sort((a, b) => a.orderIndex - b.orderIndex)
.map(mapDBPartToUIMessagePart)
.filter((part): part is ExtendedUIMessagePart => part !== null);
};
@@ -33,13 +33,13 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
import { DashboardToolsModule } from 'src/modules/dashboard/tools/dashboard-tools.module';
import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module';
import { AgentChatController } from './controllers/agent-chat.controller';
import { AgentChatThreadDTO } from './dtos/agent-chat-thread.dto';
import { AgentChatThreadEntity } from './entities/agent-chat-thread.entity';
import { StreamAgentChatJob } from './jobs/stream-agent-chat.job';
import { AgentChatResolver } from './resolvers/agent-chat.resolver';
import { AgentChatSubscriptionResolver } from './resolvers/agent-chat-subscription.resolver';
import { AgentChatCancelSubscriberService } from './services/agent-chat-cancel-subscriber.service';
import { AgentChatResumableStreamService } from './services/agent-chat-resumable-stream.service';
import { AgentChatEventPublisherService } from './services/agent-chat-event-publisher.service';
import { AgentChatStreamingService } from './services/agent-chat-streaming.service';
import { AgentChatService } from './services/agent-chat.service';
import { AgentTitleGenerationService } from './services/agent-title-generation.service';
@@ -101,11 +101,11 @@ import { SystemPromptBuilderService } from './services/system-prompt-builder.ser
DashboardToolsModule,
WorkflowToolsModule,
],
controllers: [AgentChatController],
providers: [
AgentChatCancelSubscriberService,
AgentChatEventPublisherService,
AgentChatResolver,
AgentChatResumableStreamService,
AgentChatSubscriptionResolver,
AgentChatService,
AgentChatStreamingService,
AgentTitleGenerationService,
@@ -1,183 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Res,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import { InjectRepository } from '@nestjs/typeorm';
import { UI_MESSAGE_STREAM_HEADERS } from 'ai';
import type { Response } from 'express';
import type { ExtendedUIMessage } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import type { Repository } from 'typeorm';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import { AgentRestApiExceptionFilter } from 'src/engine/metadata-modules/ai/ai-agent/filters/agent-api-exception.filter';
import type { BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { getCancelChannel } from 'src/engine/metadata-modules/ai/ai-chat/utils/get-cancel-channel.util';
import { AgentChatResumableStreamService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-resumable-stream.service';
import { AgentChatStreamingService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
@Controller('rest/agent-chat')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
@UseFilters(
RestApiExceptionFilter,
AgentRestApiExceptionFilter,
BillingRestApiExceptionFilter,
)
export class AgentChatController {
constructor(
private readonly agentStreamingService: AgentChatStreamingService,
private readonly resumableStreamService: AgentChatResumableStreamService,
private readonly billingService: BillingService,
private readonly twentyConfigService: TwentyConfigService,
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly redisClientService: RedisClientService,
@InjectRepository(AgentChatThreadEntity)
private readonly threadRepository: Repository<AgentChatThreadEntity>,
) {}
@Post('stream')
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI))
async streamAgentChat(
@Body()
body: {
threadId: string;
messages: ExtendedUIMessage[];
browsingContext?: BrowsingContextType | null;
modelId?: string;
},
@AuthUserWorkspaceId() userWorkspaceId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@Res() response: Response,
) {
if (this.aiModelRegistryService.getAvailableModels().length === 0) {
throw new AgentException(
'No AI models are available. Configure at least one AI provider.',
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
);
}
const resolvedModelId = body.modelId ?? workspace.smartModel;
this.aiModelRegistryService.validateModelAvailability(
resolvedModelId,
workspace,
);
if (this.twentyConfigService.get('IS_BILLING_ENABLED')) {
const canBill = await this.billingService.canBillMeteredProduct(
workspace.id,
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!canBill) {
throw new BillingException(
'Credits exhausted',
BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
);
}
}
return this.agentStreamingService.streamAgentChat({
threadId: body.threadId,
messages: body.messages,
browsingContext: body.browsingContext ?? null,
modelId: body.modelId,
userWorkspaceId,
workspace,
response,
});
}
@Get(':threadId/stream')
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI))
async resumeAgentChatStream(
@Param('threadId') threadId: string,
@AuthUserWorkspaceId() userWorkspaceId: string,
@Res() response: Response,
) {
const thread = await this.threadRepository.findOne({
where: { id: threadId, userWorkspaceId },
});
if (!isDefined(thread) || !isDefined(thread.activeStreamId)) {
response.status(204).end();
return;
}
const resumedNodeReadable =
await this.resumableStreamService.resumeExistingStreamAsNodeReadable(
thread.activeStreamId,
);
if (!isDefined(resumedNodeReadable)) {
response.status(204).end();
return;
}
response.writeHead(200, UI_MESSAGE_STREAM_HEADERS);
resumedNodeReadable.pipe(response);
}
@Delete(':threadId/stream')
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI))
async stopAgentChatStream(
@Param('threadId') threadId: string,
@AuthUserWorkspaceId() userWorkspaceId: string,
) {
const thread = await this.threadRepository.findOne({
where: { id: threadId, userWorkspaceId },
});
if (!isDefined(thread) || !isDefined(thread.activeStreamId)) {
return { success: true };
}
// Publish a cancel signal via Redis pub/sub. The BullMQ worker
// processing this thread's stream subscribes to this channel and
// will abort the LLM connection when the message arrives — stopping
// token generation and billing immediately.
const redis = this.redisClientService.getClient();
await redis.publish(getCancelChannel(threadId), 'cancel');
await this.threadRepository.update(
{ id: threadId, userWorkspaceId },
{ activeStreamId: null },
);
return { success: true };
}
}
@@ -0,0 +1,14 @@
import { Field, ObjectType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
// Typed as JSON because the payload is AgentChatSubscriptionEvent
// (a discriminated union defined in twenty-shared).
@ObjectType('AgentChatEvent')
export class AgentChatEventDTO {
@Field(() => String)
threadId: string;
@Field(() => GraphQLJSON)
event: Record<string, unknown>;
}
@@ -0,0 +1,12 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
@ObjectType('ChatStreamCatchupChunks')
export class ChatStreamCatchupChunksDTO {
@Field(() => [GraphQLJSON])
chunks: Record<string, unknown>[];
@Field(() => Int)
maxSeq: number;
}
@@ -0,0 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('SendChatMessageResult')
export class SendChatMessageResultDTO {
@Field(() => String)
messageId: string;
@Field(() => Boolean)
queued: boolean;
@Field(() => String, { nullable: true })
streamId?: string;
}
@@ -0,0 +1 @@
export const STREAM_AGENT_CHAT_JOB_NAME = 'StreamAgentChatJob';
@@ -0,0 +1,20 @@
import type {
ExtendedUIMessage,
ExtendedUIMessagePart,
} from 'twenty-shared/ai';
import type { BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
export type StreamAgentChatJobData = {
threadId: string;
streamId: string;
userWorkspaceId: string;
workspaceId: string;
messages: ExtendedUIMessage[];
browsingContext: BrowsingContextType | null;
modelId?: string;
lastUserMessageText: string;
lastUserMessageParts: ExtendedUIMessagePart[];
hasTitle: boolean;
existingTurnId?: string;
};
@@ -1,7 +1,7 @@
import { Logger, Scope } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { createUIMessageStream, JsonToSseTransformStream } from 'ai';
import { createUIMessageStream } from 'ai';
import type {
CodeExecutionData,
ExtendedUIMessage,
@@ -10,37 +10,27 @@ import type {
import { Repository } from 'typeorm';
import { AgentChatCancelSubscriberService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-cancel-subscriber.service';
import { AgentChatEventPublisherService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-event-publisher.service';
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { AgentMessageRole } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
import type { BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
import { AgentChatStreamingService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service';
import { computeCostBreakdown } from 'src/engine/metadata-modules/ai/ai-billing/utils/compute-cost-breakdown.util';
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
import { extractCacheCreationTokens } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
import type { AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { AgentChatResumableStreamService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-resumable-stream.service';
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';
export const STREAM_AGENT_CHAT_JOB_NAME = 'StreamAgentChatJob';
import { STREAM_AGENT_CHAT_JOB_NAME } from './stream-agent-chat-job-name.constant';
import { type StreamAgentChatJobData } from './stream-agent-chat-job.types';
export type StreamAgentChatJobData = {
threadId: string;
streamId: string;
userWorkspaceId: string;
workspaceId: string;
messages: ExtendedUIMessage[];
browsingContext: BrowsingContextType | null;
modelId?: string;
lastUserMessageText: string;
lastUserMessageParts: ExtendedUIMessagePart[];
hasTitle: boolean;
};
export { STREAM_AGENT_CHAT_JOB_NAME, type StreamAgentChatJobData };
@Processor({ queueName: MessageQueue.aiStreamQueue, scope: Scope.REQUEST })
export class StreamAgentChatJob {
@@ -53,8 +43,9 @@ export class StreamAgentChatJob {
private readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly agentChatService: AgentChatService,
private readonly chatExecutionService: ChatExecutionService,
private readonly resumableStreamService: AgentChatResumableStreamService,
private readonly eventPublisherService: AgentChatEventPublisherService,
private readonly cancelSubscriberService: AgentChatCancelSubscriberService,
private readonly agentChatStreamingService: AgentChatStreamingService,
) {}
@Process(STREAM_AGENT_CHAT_JOB_NAME)
@@ -65,9 +56,14 @@ export class StreamAgentChatJob {
if (!workspace) {
this.logger.error(`Workspace ${data.workspaceId} not found`);
await this.resumableStreamService.writeStreamError(data.streamId, {
code: 'WORKSPACE_NOT_FOUND',
message: `Workspace ${data.workspaceId} not found`,
await this.eventPublisherService.publish({
threadId: data.threadId,
workspaceId: data.workspaceId,
event: {
type: 'stream-error',
code: 'WORKSPACE_NOT_FOUND',
message: `Workspace ${data.workspaceId} not found`,
},
});
return;
@@ -86,11 +82,18 @@ export class StreamAgentChatJob {
this.logger.error(
`Stream ${data.streamId} failed: ${error instanceof Error ? error.message : String(error)}`,
);
await this.resumableStreamService
.writeStreamError(data.streamId, {
code: 'STREAM_EXECUTION_FAILED',
message:
error instanceof Error ? error.message : 'Stream execution failed',
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',
},
})
.catch(() => {});
} finally {
@@ -105,6 +108,21 @@ export class StreamAgentChatJob {
})
.execute()
.catch(() => {});
if (!abortController.signal.aborted) {
await this.agentChatStreamingService
.flushNextQueuedMessage(
data.threadId,
data.userWorkspaceId,
data.workspaceId,
data.hasTitle,
)
.catch((error) => {
this.logger.error(
`Failed to flush queued message for thread ${data.threadId}: ${error instanceof Error ? error.message : String(error)}`,
);
});
}
}
}
@@ -113,26 +131,34 @@ export class StreamAgentChatJob {
workspace: WorkspaceEntity,
abortSignal: AbortSignal,
): Promise<void> {
const userMessagePromise = this.agentChatService.addMessage({
threadId: data.threadId,
uiMessage: {
role: AgentMessageRole.USER,
parts: data.lastUserMessageParts.filter(
(part): part is ExtendedUIMessagePart =>
part.type === 'text' || part.type === 'file',
),
},
});
// When processing a promoted queued message, the user message already
// exists in the DB with a turn — skip persisting it again.
const userMessagePromise = data.existingTurnId
? Promise.resolve({ turnId: data.existingTurnId })
: this.agentChatService.addMessage({
threadId: data.threadId,
uiMessage: {
role: AgentMessageRole.USER,
parts: data.lastUserMessageParts.filter(
(part): part is ExtendedUIMessagePart =>
part.type === 'text' || part.type === 'file',
),
},
});
userMessagePromise.catch(() => {});
const titlePromise = data.hasTitle
? Promise.resolve(null)
: this.agentChatService
.generateTitleIfNeeded(data.threadId, data.lastUserMessageText)
.generateTitleIfNeeded({
threadId: data.threadId,
messageContent: data.lastUserMessageText,
workspaceId: data.workspaceId,
})
.catch(() => null);
await this.buildAndPipeStream({
await this.buildAndPublishStream({
workspace,
data,
userMessagePromise,
@@ -141,7 +167,7 @@ export class StreamAgentChatJob {
});
}
private async buildAndPipeStream({
private async buildAndPublishStream({
workspace,
data,
userMessagePromise,
@@ -150,7 +176,7 @@ export class StreamAgentChatJob {
}: {
workspace: WorkspaceEntity;
data: StreamAgentChatJobData;
userMessagePromise: Promise<{ turnId: string }>;
userMessagePromise: Promise<{ turnId: string | null }>;
titlePromise: Promise<string | null>;
abortSignal: AbortSignal;
}): Promise<void> {
@@ -164,6 +190,14 @@ export class StreamAgentChatJob {
let lastStepConversationSize = 0;
let totalCacheCreationTokens = 0;
// onFinish fires before the uiStream is fully drained. We use this
// promise to coordinate: the IIFE waits for DB persist to complete
// before publishing message-persisted (after all chunks).
let resolveStreamFinished: () => void;
const streamFinishedPromise = new Promise<void>((res) => {
resolveStreamFinished = res;
});
abortSignal.addEventListener('abort', () => resolve(), { once: true });
const uiStream = createUIMessageStream<ExtendedUIMessage>({
@@ -233,7 +267,7 @@ export class StreamAgentChatJob {
userMessagePromise,
});
await titleWritePromise;
resolve();
resolveStreamFinished();
} catch (error) {
reject(error);
}
@@ -244,11 +278,34 @@ export class StreamAgentChatJob {
},
});
const sseStream = uiStream.pipeThrough(new JsonToSseTransformStream());
// Publish all chunks first, then signal completion. This guarantees
// message-persisted arrives after every stream-chunk on the client.
(async () => {
try {
for await (const chunk of uiStream) {
await this.eventPublisherService.publish({
threadId: data.threadId,
workspaceId: data.workspaceId,
event: {
type: 'stream-chunk',
chunk: chunk as Record<string, unknown>,
},
});
}
this.resumableStreamService
.createResumableStream(data.streamId, () => sseStream)
.catch(reject);
await streamFinishedPromise;
await this.eventPublisherService.publish({
threadId: data.threadId,
workspaceId: data.workspaceId,
event: { type: 'message-persisted', messageId: data.threadId },
});
resolve();
} catch (error) {
reject(error);
}
})();
});
}
@@ -358,7 +415,7 @@ export class StreamAgentChatJob {
};
lastStepConversationSize: number;
modelConfig: AIModelConfig;
userMessagePromise: Promise<{ turnId: string }>;
userMessagePromise: Promise<{ turnId: string | null }>;
}): Promise<void> {
if (responseMessage.parts.length === 0) {
return;
@@ -369,7 +426,7 @@ export class StreamAgentChatJob {
await this.agentChatService.addMessage({
threadId,
uiMessage: responseMessage,
turnId: userMessage.turnId,
turnId: userMessage.turnId ?? undefined,
});
await this.threadRepository.update(threadId, {
@@ -0,0 +1,68 @@
import { UseGuards } from '@nestjs/common';
import { Args, Subscription } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import { AgentChatEventDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-event.dto';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { FeatureFlagKey } from 'twenty-shared/types';
@MetadataResolver()
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
export class AgentChatSubscriptionResolver {
constructor(
private readonly subscriptionService: SubscriptionService,
@InjectRepository(AgentChatThreadEntity)
private readonly threadRepository: Repository<AgentChatThreadEntity>,
) {}
@Subscription(() => AgentChatEventDTO, {
filter: (
payload: { onAgentChatEvent: AgentChatEventDTO },
variables: { threadId: string },
) => {
return payload.onAgentChatEvent.threadId === variables.threadId;
},
})
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI))
async onAgentChatEvent(
@Args('threadId', { type: () => UUIDScalarType }) threadId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
) {
const thread = await this.threadRepository.findOne({
where: { id: threadId, userWorkspaceId },
select: ['id'],
});
if (!isDefined(thread)) {
throw new AgentException(
'Thread not found',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
return this.subscriptionService.subscribeToAgentChat({
workspaceId: workspace.id,
threadId,
});
}
}
@@ -8,11 +8,23 @@ import {
ResolveField,
} from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import GraphQLJSON from 'graphql-type-json';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
@@ -23,12 +35,23 @@ import {
} from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
import { AgentMessageDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/agent-message.dto';
import { AgentChatThreadDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto';
import { AISystemPromptPreviewDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/ai-system-prompt-preview.dto';
import { type AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { ChatStreamCatchupChunksDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/chat-stream-catchup-chunks.dto';
import { SendChatMessageResultDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/send-chat-message-result.dto';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { AgentChatEventPublisherService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-event-publisher.service';
import { AgentChatStreamingService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service';
import { AgentChatService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service';
import { getCancelChannel } from 'src/engine/metadata-modules/ai/ai-chat/utils/get-cancel-channel.util';
import { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
@UseGuards(
WorkspaceAuthGuard,
@@ -39,7 +62,15 @@ import { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-ch
export class AgentChatResolver {
constructor(
private readonly agentChatService: AgentChatService,
private readonly agentChatStreamingService: AgentChatStreamingService,
private readonly eventPublisherService: AgentChatEventPublisherService,
private readonly systemPromptBuilderService: SystemPromptBuilderService,
private readonly billingService: BillingService,
private readonly twentyConfigService: TwentyConfigService,
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly redisClientService: RedisClientService,
@InjectRepository(AgentChatThreadEntity)
private readonly threadRepository: Repository<AgentChatThreadEntity>,
) {}
@Query(() => AgentChatThreadDTO)
@@ -63,10 +94,178 @@ export class AgentChatResolver {
);
}
@Query(() => ChatStreamCatchupChunksDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async chatStreamCatchupChunks(
@Args('threadId', { type: () => UUIDScalarType }) threadId: string,
@AuthUserWorkspaceId() userWorkspaceId: string,
) {
await this.agentChatService.getThreadById(threadId, userWorkspaceId);
return this.eventPublisherService.getAccumulatedChunks(threadId);
}
@Mutation(() => AgentChatThreadDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async createChatThread(@AuthUserWorkspaceId() userWorkspaceId: string) {
return this.agentChatService.createThread(userWorkspaceId);
async createChatThread(
@AuthUserWorkspaceId() userWorkspaceId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
) {
return this.agentChatService.createThread({
userWorkspaceId,
workspaceId: workspace.id,
});
}
@Mutation(() => SendChatMessageResultDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async sendChatMessage(
@Args('threadId', { type: () => UUIDScalarType }) threadId: string,
@Args('text') text: string,
@Args('messageId', { type: () => UUIDScalarType }) messageId: string,
@Args('browsingContext', { type: () => GraphQLJSON, nullable: true })
browsingContext: BrowsingContextType | null,
@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 AgentException(
'No AI models are available. Configure at least one AI provider.',
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
);
}
const resolvedModelId = modelId ?? workspace.smartModel;
this.aiModelRegistryService.validateModelAvailability(
resolvedModelId,
workspace,
);
if (this.twentyConfigService.get('IS_BILLING_ENABLED')) {
const canBill = await this.billingService.canBillMeteredProduct(
workspace.id,
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!canBill) {
throw new BillingException(
'Credits exhausted',
BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
);
}
}
const thread = await this.threadRepository.findOne({
where: { id: threadId, userWorkspaceId },
});
if (!isDefined(thread)) {
throw new AgentException(
'Thread not found',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
if (isDefined(thread.activeStreamId)) {
const queuedMessage = await this.agentChatService.queueMessage({
threadId,
text,
id: messageId,
});
await this.eventPublisherService.publish({
threadId,
workspaceId: workspace.id,
event: { type: 'queue-updated' },
});
return { messageId: queuedMessage.id, queued: true };
}
const result = await this.agentChatStreamingService.streamAgentChat({
threadId,
browsingContext: browsingContext ?? null,
modelId,
userWorkspaceId,
workspace,
text,
messageId,
});
return {
messageId: result.messageId,
queued: false,
streamId: result.streamId,
};
}
@Mutation(() => Boolean)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async stopAgentChatStream(
@Args('threadId', { type: () => UUIDScalarType }) threadId: string,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<boolean> {
const thread = await this.threadRepository.findOne({
where: { id: threadId, userWorkspaceId },
});
if (!isDefined(thread) || !isDefined(thread.activeStreamId)) {
return true;
}
const redis = this.redisClientService.getClient();
await redis.publish(getCancelChannel(threadId), 'cancel');
await this.threadRepository.update(
{ id: threadId, userWorkspaceId },
{ activeStreamId: null },
);
return true;
}
@Mutation(() => Boolean)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async deleteQueuedChatMessage(
@Args('messageId', { type: () => UUIDScalarType }) messageId: string,
@AuthUserWorkspaceId() userWorkspaceId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
const message = await this.agentChatService.findQueuedMessage(messageId);
if (!isDefined(message)) {
throw new AgentException(
'Queued message not found',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
const thread = await this.threadRepository.findOne({
where: { id: message.threadId, userWorkspaceId },
});
if (!isDefined(thread)) {
throw new AgentException(
'Thread not found',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
const deleted = await this.agentChatService.deleteQueuedMessage(messageId);
if (deleted) {
await this.eventPublisherService.publish({
threadId: message.threadId,
workspaceId: workspace.id,
event: { type: 'queue-updated' },
});
}
return deleted;
}
@Query(() => AISystemPromptPreviewDTO)
@@ -0,0 +1,74 @@
import { Injectable } from '@nestjs/common';
import { type AgentChatSubscriptionEvent } from 'twenty-shared/ai';
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
const STREAM_CHUNKS_TTL_SECONDS = 3600;
@Injectable()
export class AgentChatEventPublisherService {
constructor(
private readonly subscriptionService: SubscriptionService,
private readonly redisClientService: RedisClientService,
) {}
private getStreamChunksKey(threadId: string): string {
return `agent-chat-stream-chunks:${threadId}`;
}
async publish({
threadId,
workspaceId,
event,
}: {
threadId: string;
workspaceId: string;
event: AgentChatSubscriptionEvent;
}): Promise<void> {
let publishedEvent = event;
if (event.type === 'stream-chunk') {
const redis = this.redisClientService.getClient();
const key = this.getStreamChunksKey(threadId);
// RPUSH returns the new list length — use it as a 1-based sequence number
const seq = await redis.rpush(key, JSON.stringify(event.chunk));
await redis.expire(key, STREAM_CHUNKS_TTL_SECONDS);
publishedEvent = { ...event, seq };
} else if (event.type === 'message-persisted') {
const redis = this.redisClientService.getClient();
await redis.del(this.getStreamChunksKey(threadId));
}
await this.subscriptionService.publishToAgentChat({
workspaceId,
threadId,
payload: {
onAgentChatEvent: {
threadId,
event: publishedEvent,
},
},
});
}
async getAccumulatedChunks(threadId: string): Promise<{
chunks: Record<string, unknown>[];
maxSeq: number;
}> {
const redis = this.redisClientService.getClient();
const rawChunks = await redis.lrange(
this.getStreamChunksKey(threadId),
0,
-1,
);
return {
chunks: rawChunks.map((raw) => JSON.parse(raw)),
maxSeq: rawChunks.length,
};
}
}
@@ -1,104 +0,0 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { Readable } from 'stream';
import { type ReadableStream as NodeWebReadableStream } from 'stream/web';
import type { Redis } from 'ioredis';
import { createResumableStreamContext } from 'resumable-stream/ioredis';
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
@Injectable()
export class AgentChatResumableStreamService implements OnModuleDestroy {
private streamContext: ReturnType<typeof createResumableStreamContext>;
private streamPublisher: Redis;
private streamSubscriber: Redis;
private redisClient: Redis;
constructor(private readonly redisClientService: RedisClientService) {
const baseClient = this.redisClientService.getClient();
this.streamPublisher = baseClient.duplicate();
this.streamSubscriber = baseClient.duplicate();
this.redisClient = baseClient.duplicate();
this.streamContext = createResumableStreamContext({
waitUntil: () => {},
publisher: this.streamPublisher,
subscriber: this.streamSubscriber,
});
}
async onModuleDestroy() {
await this.streamPublisher.quit();
await this.streamSubscriber.quit();
await this.redisClient.quit();
}
async createResumableStream(
streamId: string,
streamFactory: () => ReadableStream<string>,
) {
const resumableStream = await this.streamContext.createNewResumableStream(
streamId,
streamFactory,
);
if (!resumableStream) {
return;
}
// Read the stream to completion in the background so chunks are
// published to Redis and available for later resume consumers.
const reader = resumableStream.getReader();
void (async () => {
try {
while (true) {
const { done } = await reader.read();
if (done) {
break;
}
}
} catch {
// Stream interrupted — chunks already published are still resumable.
}
})();
}
async resumeExistingStreamAsNodeReadable(
streamId: string,
): Promise<Readable | null> {
const webStream = await this.streamContext.resumeExistingStream(streamId);
if (!webStream) {
return null;
}
return Readable.fromWeb(webStream as NodeWebReadableStream);
}
async writeStreamError(
streamId: string,
error: { code: string; message: string },
): Promise<void> {
await this.redisClient.set(
`ai-stream:error:${streamId}`,
JSON.stringify(error),
'EX',
60,
);
}
async readStreamError(
streamId: string,
): Promise<{ code: string; message: string } | null> {
const raw = await this.redisClient.get(`ai-stream:error:${streamId}`);
if (!raw) {
return null;
}
return JSON.parse(raw);
}
}
@@ -1,10 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type Readable } from 'stream';
import { generateId, UI_MESSAGE_STREAM_HEADERS } from 'ai';
import { type Response } from 'express';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { generateId } from 'ai';
import { type Repository } from 'typeorm';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
@@ -16,27 +13,27 @@ import {
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import {
STREAM_AGENT_CHAT_JOB_NAME,
type StreamAgentChatJobData,
} from 'src/engine/metadata-modules/ai/ai-chat/jobs/stream-agent-chat.job';
import { AgentChatResumableStreamService } from './agent-chat-resumable-stream.service';
AgentMessageRole,
AgentMessageStatus,
} from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
import { mapDBPartsToUIMessageParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapDBPartsToUIMessageParts';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { STREAM_AGENT_CHAT_JOB_NAME } from 'src/engine/metadata-modules/ai/ai-chat/jobs/stream-agent-chat-job-name.constant';
import { type StreamAgentChatJobData } from 'src/engine/metadata-modules/ai/ai-chat/jobs/stream-agent-chat-job.types';
import { AgentChatEventPublisherService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-event-publisher.service';
import { AgentChatService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service';
export type StreamAgentChatOptions = {
threadId: string;
userWorkspaceId: string;
workspace: WorkspaceEntity;
response: Response;
messages: ExtendedUIMessage[];
text: string;
browsingContext: BrowsingContextType | null;
modelId?: string;
messageId?: string;
};
const STREAM_READY_TIMEOUT_MS = 5_000;
const STREAM_READY_POLL_INTERVAL_MS = 50;
@Injectable()
export class AgentChatStreamingService {
private readonly logger = new Logger(AgentChatStreamingService.name);
@@ -46,18 +43,19 @@ export class AgentChatStreamingService {
private readonly threadRepository: Repository<AgentChatThreadEntity>,
@InjectMessageQueue(MessageQueue.aiStreamQueue)
private readonly messageQueueService: MessageQueueService,
private readonly resumableStreamService: AgentChatResumableStreamService,
private readonly agentChatService: AgentChatService,
private readonly eventPublisherService: AgentChatEventPublisherService,
) {}
async streamAgentChat({
threadId,
userWorkspaceId,
workspace,
messages,
text,
browsingContext,
response,
modelId,
}: StreamAgentChatOptions) {
messageId,
}: StreamAgentChatOptions): Promise<{ streamId: string; messageId: string }> {
const thread = await this.threadRepository.findOne({
where: {
id: threadId,
@@ -72,10 +70,21 @@ export class AgentChatStreamingService {
);
}
const savedUserMessage = await this.agentChatService.addMessage({
threadId,
id: messageId,
uiMessage: {
role: AgentMessageRole.USER,
parts: [{ type: 'text' as const, text }],
},
});
const previousMessages = await this.loadMessagesFromDB(
threadId,
userWorkspaceId,
);
const streamId = generateId();
const lastUserMessage = messages[messages.length - 1];
const lastUserText =
lastUserMessage?.parts.find((part) => part.type === 'text')?.text ?? '';
await this.messageQueueService.add<StreamAgentChatJobData>(
STREAM_AGENT_CHAT_JOB_NAME,
@@ -84,12 +93,13 @@ export class AgentChatStreamingService {
streamId,
userWorkspaceId,
workspaceId: workspace.id,
messages,
messages: previousMessages,
browsingContext,
modelId,
lastUserMessageText: lastUserText,
lastUserMessageParts: lastUserMessage?.parts ?? [],
lastUserMessageText: text,
lastUserMessageParts: [{ type: 'text', text }],
hasTitle: !!thread.title,
existingTurnId: savedUserMessage.turnId ?? undefined,
},
);
@@ -97,67 +107,92 @@ export class AgentChatStreamingService {
activeStreamId: streamId,
});
try {
const result = await this.waitForResumableStream(streamId);
if ('error' in result) {
response.status(500).json(result.error);
return;
}
if (!result.readable) {
this.logger.error(
`Stream ${streamId} did not become available within timeout`,
);
response
.status(500)
.json({ code: 'WORKER_UNREACHABLE', message: 'Stream timed out' });
return;
}
response.writeHead(200, UI_MESSAGE_STREAM_HEADERS);
result.readable.pipe(response);
} catch (error) {
response.end();
throw error;
}
return { streamId, messageId: savedUserMessage.id };
}
private async waitForResumableStream(
streamId: string,
): Promise<
| { readable: Readable }
| { error: { code: string; message: string } }
| { readable: null }
> {
const maxAttempts = Math.ceil(
STREAM_READY_TIMEOUT_MS / STREAM_READY_POLL_INTERVAL_MS,
async flushNextQueuedMessage(
threadId: string,
userWorkspaceId: string,
workspaceId: string,
hasTitle: boolean,
): Promise<void> {
const queuedMessages =
await this.agentChatService.getQueuedMessages(threadId);
const nextQueued = queuedMessages[0];
if (!nextQueued) {
return;
}
const textPart = nextQueued.parts?.find((part) => part.type === 'text');
const messageText = textPart?.textContent ?? '';
if (messageText === '') {
await this.agentChatService.deleteQueuedMessage(nextQueued.id);
return;
}
const turnId = await this.agentChatService.promoteQueuedMessage(
nextQueued.id,
threadId,
);
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const streamError =
await this.resumableStreamService.readStreamError(streamId);
if (streamError) {
return { error: streamError };
}
const readable =
await this.resumableStreamService.resumeExistingStreamAsNodeReadable(
streamId,
);
if (readable) {
return { readable };
}
await new Promise((resolve) =>
setTimeout(resolve, STREAM_READY_POLL_INTERVAL_MS),
);
if (turnId === null) {
return;
}
return { readable: null };
await this.eventPublisherService.publish({
threadId,
workspaceId,
event: { type: 'queue-updated' },
});
await this.eventPublisherService.publish({
threadId,
workspaceId,
event: { type: 'message-persisted', messageId: nextQueued.id },
});
const uiMessages = await this.loadMessagesFromDB(threadId, userWorkspaceId);
const streamId = generateId();
await this.messageQueueService.add<StreamAgentChatJobData>(
STREAM_AGENT_CHAT_JOB_NAME,
{
threadId,
streamId,
userWorkspaceId,
workspaceId,
messages: uiMessages,
browsingContext: null,
lastUserMessageText: messageText,
lastUserMessageParts: [{ type: 'text', text: messageText }],
hasTitle,
existingTurnId: turnId,
},
);
await this.threadRepository.update(threadId, {
activeStreamId: streamId,
});
}
private async loadMessagesFromDB(threadId: string, userWorkspaceId: string) {
const allMessages = await this.agentChatService.getMessagesForThread(
threadId,
userWorkspaceId,
);
return allMessages
.filter((message) => message.status !== AgentMessageStatus.QUEUED)
.map((message) => ({
id: message.id,
role: message.role as 'user' | 'assistant' | 'system',
parts: mapDBPartsToUIMessageParts(message.parts ?? []),
createdAt: message.createdAt,
}));
}
}
@@ -10,6 +10,7 @@ import { AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-
import {
AgentMessageEntity,
AgentMessageRole,
AgentMessageStatus,
} 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 { mapUIMessagePartsToDBParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapUIMessagePartsToDBParts';
@@ -18,9 +19,23 @@ import {
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { WorkspaceEventBroadcaster } from 'src/engine/subscriptions/workspace-event-broadcaster/workspace-event-broadcaster.service';
import { AgentTitleGenerationService } from './agent-title-generation.service';
const serializeThreadForBroadcast = (thread: AgentChatThreadEntity) => ({
id: thread.id,
title: thread.title,
totalInputTokens: thread.totalInputTokens,
totalOutputTokens: thread.totalOutputTokens,
contextWindowTokens: thread.contextWindowTokens,
conversationSize: thread.conversationSize,
totalInputCredits: thread.totalInputCredits,
totalOutputCredits: thread.totalOutputCredits,
createdAt: thread.createdAt.toISOString(),
updatedAt: thread.updatedAt.toISOString(),
});
@Injectable()
export class AgentChatService {
constructor(
@@ -33,14 +48,37 @@ export class AgentChatService {
@InjectRepository(AgentMessagePartEntity)
private readonly messagePartRepository: Repository<AgentMessagePartEntity>,
private readonly titleGenerationService: AgentTitleGenerationService,
private readonly workspaceEventBroadcaster: WorkspaceEventBroadcaster,
) {}
async createThread(userWorkspaceId: string) {
async createThread({
userWorkspaceId,
workspaceId,
}: {
userWorkspaceId: string;
workspaceId: string;
}) {
const thread = this.threadRepository.create({
userWorkspaceId,
});
return this.threadRepository.save(thread);
const savedThread = await this.threadRepository.save(thread);
await this.workspaceEventBroadcaster.broadcast({
workspaceId,
events: [
{
type: 'created',
entityName: 'agentChatThread',
recordId: savedThread.id,
properties: {
after: serializeThreadForBroadcast(savedThread),
},
},
],
});
return savedThread;
}
async getThreadById(threadId: string, userWorkspaceId: string) {
@@ -66,12 +104,14 @@ export class AgentChatService {
uiMessage,
agentId,
turnId,
id,
}: {
threadId: string;
uiMessage: Omit<ExtendedUIMessage, 'id'>;
uiMessageParts?: UIMessagePart<UIDataTypes, UITools>[];
agentId?: string;
turnId?: string;
id?: string;
}) {
let actualTurnId = turnId;
@@ -87,10 +127,12 @@ export class AgentChatService {
}
const message = this.messageRepository.create({
...(id ? { id } : {}),
threadId,
turnId: actualTurnId,
role: uiMessage.role as AgentMessageRole,
agentId: agentId ?? null,
processedAt: new Date(),
});
const savedMessage = await this.messageRepository.save(message);
@@ -124,18 +166,111 @@ export class AgentChatService {
return this.messageRepository.find({
where: { threadId },
order: { createdAt: 'ASC' },
order: { processedAt: { direction: 'ASC', nulls: 'LAST' } },
relations: ['parts', 'parts.file'],
});
}
async generateTitleIfNeeded(
async queueMessage({
threadId,
text,
id,
}: {
threadId: string;
text: string;
id?: string;
}): Promise<AgentMessageEntity> {
const message = this.messageRepository.create({
...(id ? { id } : {}),
threadId,
turnId: null,
role: AgentMessageRole.USER,
agentId: null,
status: AgentMessageStatus.QUEUED,
});
const savedMessage = await this.messageRepository.save(message);
const part = this.messagePartRepository.create({
messageId: savedMessage.id,
orderIndex: 0,
type: 'text',
textContent: text,
});
await this.messagePartRepository.save(part);
return savedMessage;
}
async getQueuedMessages(threadId: string): Promise<AgentMessageEntity[]> {
return this.messageRepository.find({
where: {
threadId,
status: AgentMessageStatus.QUEUED,
},
order: { createdAt: 'ASC' },
relations: ['parts'],
});
}
async findQueuedMessage(
messageId: string,
): Promise<AgentMessageEntity | null> {
return this.messageRepository.findOne({
where: { id: messageId, status: AgentMessageStatus.QUEUED },
});
}
async deleteQueuedMessage(messageId: string): Promise<boolean> {
const result = await this.messageRepository.delete({
id: messageId,
status: AgentMessageStatus.QUEUED,
});
return (result.affected ?? 0) > 0;
}
async promoteQueuedMessage(
messageId: string,
threadId: string,
messageContent: string,
): Promise<string | null> {
const turn = this.turnRepository.create({
threadId,
agentId: null,
});
const savedTurn = await this.turnRepository.save(turn);
const result = await this.messageRepository.update(
{ id: messageId, threadId, status: AgentMessageStatus.QUEUED },
{
status: AgentMessageStatus.SENT,
processedAt: new Date(),
turnId: savedTurn.id,
},
);
if ((result.affected ?? 0) === 0) {
await this.turnRepository.delete(savedTurn.id);
return null;
}
return savedTurn.id;
}
async generateTitleIfNeeded({
threadId,
messageContent,
workspaceId,
}: {
threadId: string;
messageContent: string;
workspaceId: string;
}): Promise<string | null> {
const thread = await this.threadRepository.findOne({
where: { id: threadId },
select: ['id', 'title'],
});
if (!thread || thread.title || !messageContent) {
@@ -147,6 +282,21 @@ export class AgentChatService {
await this.threadRepository.update(threadId, { title });
await this.workspaceEventBroadcaster.broadcast({
workspaceId,
events: [
{
type: 'updated',
entityName: 'agentChatThread',
recordId: threadId,
properties: {
updatedFields: ['title'],
after: serializeThreadForBroadcast({ ...thread, title }),
},
},
],
});
return title;
}
}