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
@@ -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',
}
@@ -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 {}
@@ -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[];
};
@@ -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>;
};
};
@@ -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,
);
}
}