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
@@ -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,
);
}
}