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:
@@ -1,4 +1,5 @@
|
||||
export enum SubscriptionChannel {
|
||||
LOGIC_FUNCTION_LOGS_CHANNEL = 'LOGIC_FUNCTION_LOGS_CHANNEL',
|
||||
EVENT_STREAM_CHANNEL = 'EVENT_STREAM_CHANNEL',
|
||||
AGENT_CHAT_CHANNEL = 'AGENT_CHAT_CHANNEL',
|
||||
}
|
||||
|
||||
+13
-62
@@ -6,56 +6,35 @@ import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metada
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
|
||||
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
|
||||
import { type EventStreamPayload } from 'src/engine/subscriptions/types/event-stream-payload.type';
|
||||
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
|
||||
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
|
||||
import { WorkspaceEventBroadcaster } from 'src/engine/subscriptions/workspace-event-broadcaster/workspace-event-broadcaster.service';
|
||||
import { enrichFieldMetadataEventWithRelations } from 'src/engine/subscriptions/metadata-event/utils/enrich-field-metadata-event-with-relations.util';
|
||||
|
||||
@Injectable()
|
||||
export class MetadataEventPublisher {
|
||||
constructor(
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly eventStreamService: EventStreamService,
|
||||
private readonly workspaceEventBroadcaster: WorkspaceEventBroadcaster,
|
||||
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly navigationMenuItemRecordIdentifierService: NavigationMenuItemRecordIdentifierService,
|
||||
) {}
|
||||
|
||||
async publish(metadataEventBatch: MetadataEventBatch): Promise<void> {
|
||||
const workspaceId = metadataEventBatch.workspaceId;
|
||||
|
||||
const activeStreamIds =
|
||||
await this.eventStreamService.getActiveStreamIds(workspaceId);
|
||||
|
||||
if (activeStreamIds.length === 0) {
|
||||
if (!isNonEmptyArray(metadataEventBatch.events)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const streamsData = await this.eventStreamService.getStreamsData(
|
||||
workspaceId,
|
||||
activeStreamIds,
|
||||
);
|
||||
|
||||
const enrichedBatch =
|
||||
await this.enrichMetadataEventBatch(metadataEventBatch);
|
||||
|
||||
const streamIdsToRemove: string[] = [];
|
||||
|
||||
for (const [streamChannelId, streamData] of streamsData) {
|
||||
if (!isDefined(streamData)) {
|
||||
streamIdsToRemove.push(streamChannelId);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.publishToStream({
|
||||
streamChannelId,
|
||||
metadataEventBatch: enrichedBatch,
|
||||
});
|
||||
}
|
||||
|
||||
await this.eventStreamService.removeFromActiveStreams(
|
||||
workspaceId,
|
||||
streamIdsToRemove,
|
||||
);
|
||||
await this.workspaceEventBroadcaster.broadcast({
|
||||
workspaceId: enrichedBatch.workspaceId,
|
||||
updatedCollectionHash: enrichedBatch.updatedCollectionHash,
|
||||
events: enrichedBatch.events.map((event) => ({
|
||||
type: event.type,
|
||||
entityName: event.metadataName,
|
||||
recordId: event.recordId,
|
||||
properties: event.properties as Record<string, unknown>,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
private async enrichMetadataEventBatch(
|
||||
@@ -79,34 +58,6 @@ export class MetadataEventPublisher {
|
||||
}
|
||||
}
|
||||
|
||||
private async publishToStream({
|
||||
streamChannelId,
|
||||
metadataEventBatch,
|
||||
}: {
|
||||
streamChannelId: string;
|
||||
metadataEventBatch: MetadataEventBatch;
|
||||
}): Promise<void> {
|
||||
if (!isNonEmptyArray(metadataEventBatch.events)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadataEvents = metadataEventBatch.events.map((metadataEvent) => ({
|
||||
...metadataEvent,
|
||||
updatedCollectionHash: metadataEventBatch.updatedCollectionHash,
|
||||
}));
|
||||
|
||||
const payload: EventStreamPayload = {
|
||||
objectRecordEventsWithQueryIds: [],
|
||||
metadataEvents,
|
||||
};
|
||||
|
||||
await this.subscriptionService.publishToEventStream({
|
||||
workspaceId: metadataEventBatch.workspaceId,
|
||||
eventStreamChannelId: streamChannelId,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
private async enrichFieldMetadataEventsWithRelations(
|
||||
metadataEventBatch: MetadataEventBatch<'fieldMetadata'>,
|
||||
): Promise<MetadataEventBatch<'fieldMetadata'>> {
|
||||
|
||||
@@ -88,4 +88,45 @@ export class SubscriptionService {
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
private getAgentChatChannel({
|
||||
workspaceId,
|
||||
threadId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
threadId: string;
|
||||
}) {
|
||||
return `${SubscriptionChannel.AGENT_CHAT_CHANNEL}:${workspaceId}:${threadId}`;
|
||||
}
|
||||
|
||||
async subscribeToAgentChat({
|
||||
workspaceId,
|
||||
threadId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
threadId: string;
|
||||
}) {
|
||||
const client = this.redisClient.getPubSubClient();
|
||||
|
||||
return client.asyncIterator(
|
||||
this.getAgentChatChannel({ workspaceId, threadId }),
|
||||
);
|
||||
}
|
||||
|
||||
async publishToAgentChat<T>({
|
||||
workspaceId,
|
||||
threadId,
|
||||
payload,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
threadId: string;
|
||||
payload: T;
|
||||
}): Promise<void> {
|
||||
const client = this.redisClient.getPubSubClient();
|
||||
|
||||
await client.publish(
|
||||
this.getAgentChatChannel({ workspaceId, threadId }),
|
||||
payload,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { MetadataEventPublisher } from 'src/engine/subscriptions/metadata-event/
|
||||
import { MetadataEventsToDbListener } from 'src/engine/subscriptions/metadata-event/metadata-events-to-db.listener';
|
||||
import { ObjectRecordEventPublisher } from 'src/engine/subscriptions/object-record-event/object-record-event-publisher';
|
||||
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
|
||||
import { WorkspaceEventBroadcaster } from 'src/engine/subscriptions/workspace-event-broadcaster/workspace-event-broadcaster.service';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
|
||||
@Global()
|
||||
@@ -40,6 +41,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
MetadataEventPublisher,
|
||||
MetadataEventEmitter,
|
||||
MetadataEventsToDbListener,
|
||||
WorkspaceEventBroadcaster,
|
||||
ProcessNestedRelationsHelper,
|
||||
ProcessNestedRelationsV2Helper,
|
||||
CommonSelectFieldsHelper,
|
||||
@@ -48,6 +50,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
SubscriptionService,
|
||||
ObjectRecordEventPublisher,
|
||||
MetadataEventEmitter,
|
||||
WorkspaceEventBroadcaster,
|
||||
],
|
||||
})
|
||||
export class SubscriptionsModule {}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
export type EventStreamMetadataEvent = {
|
||||
metadataName: string;
|
||||
type: 'created' | 'updated' | 'deleted';
|
||||
recordId: string;
|
||||
properties: {
|
||||
updatedFields?: string[];
|
||||
before?: Record<string, unknown>;
|
||||
after?: Record<string, unknown>;
|
||||
diff?: Record<string, unknown>;
|
||||
};
|
||||
updatedCollectionHash?: string;
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { type EventStreamMetadataEvent } from 'src/engine/subscriptions/types/event-stream-metadata-event.type';
|
||||
import { type ObjectRecordSubscriptionEvent } from 'src/engine/subscriptions/types/object-record-subscription-event.type';
|
||||
import { type MetadataEvent } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
|
||||
|
||||
export type EventStreamPayload = {
|
||||
objectRecordEventsWithQueryIds: {
|
||||
queryIds: string[];
|
||||
objectRecordEvent: ObjectRecordSubscriptionEvent;
|
||||
}[];
|
||||
metadataEvents: MetadataEvent[];
|
||||
metadataEvents: EventStreamMetadataEvent[];
|
||||
};
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
export type WorkspaceBroadcastEvent = {
|
||||
type: 'created' | 'updated' | 'deleted';
|
||||
entityName: string;
|
||||
recordId: string;
|
||||
properties: {
|
||||
updatedFields?: string[];
|
||||
before?: Record<string, unknown>;
|
||||
after?: Record<string, unknown>;
|
||||
diff?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
|
||||
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
|
||||
import { type EventStreamPayload } from 'src/engine/subscriptions/types/event-stream-payload.type';
|
||||
import { type WorkspaceBroadcastEvent } from 'src/engine/subscriptions/workspace-event-broadcaster/types/workspace-broadcast-event.type';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceEventBroadcaster {
|
||||
constructor(
|
||||
private readonly eventStreamService: EventStreamService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
) {}
|
||||
|
||||
async broadcast({
|
||||
workspaceId,
|
||||
events,
|
||||
updatedCollectionHash,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
events: WorkspaceBroadcastEvent[];
|
||||
updatedCollectionHash?: string;
|
||||
}): Promise<void> {
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeStreamIds =
|
||||
await this.eventStreamService.getActiveStreamIds(workspaceId);
|
||||
|
||||
if (activeStreamIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const streamsData = await this.eventStreamService.getStreamsData(
|
||||
workspaceId,
|
||||
activeStreamIds,
|
||||
);
|
||||
|
||||
const streamIdsToRemove: string[] = [];
|
||||
|
||||
const payload: EventStreamPayload = {
|
||||
objectRecordEventsWithQueryIds: [],
|
||||
metadataEvents: events.map((event) => ({
|
||||
metadataName: event.entityName,
|
||||
type: event.type,
|
||||
recordId: event.recordId,
|
||||
properties: event.properties,
|
||||
updatedCollectionHash,
|
||||
})),
|
||||
};
|
||||
|
||||
for (const [streamChannelId, streamData] of streamsData) {
|
||||
if (!isDefined(streamData)) {
|
||||
streamIdsToRemove.push(streamChannelId);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.subscriptionService.publishToEventStream({
|
||||
workspaceId,
|
||||
eventStreamChannelId: streamChannelId,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
await this.eventStreamService.removeFromActiveStreams(
|
||||
workspaceId,
|
||||
streamIdsToRemove,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user