Files
twenty/packages/twenty-server/src/engine/subscriptions/subscription.service.ts
T
fd7387928c 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>
2026-04-02 10:10:13 +02:00

133 lines
2.9 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
@Injectable()
export class SubscriptionService {
constructor(private readonly redisClient: RedisClientService) {}
private getSubscriptionChannel({
channel,
workspaceId,
}: {
channel: SubscriptionChannel;
workspaceId: string;
}) {
return `${channel}:${workspaceId}`;
}
private getEventStreamChannel({
workspaceId,
eventStreamChannelId,
}: {
workspaceId: string;
eventStreamChannelId: string;
}) {
return `${SubscriptionChannel.EVENT_STREAM_CHANNEL}:${workspaceId}:${eventStreamChannelId}`;
}
async subscribe({
channel,
workspaceId,
}: {
channel: SubscriptionChannel;
workspaceId: string;
}) {
const client = this.redisClient.getPubSubClient();
return client.asyncIterator(
this.getSubscriptionChannel({ channel, workspaceId }),
);
}
async subscribeToEventStream({
workspaceId,
eventStreamChannelId,
}: {
workspaceId: string;
eventStreamChannelId: string;
}) {
const client = this.redisClient.getPubSubClient();
return client.asyncIterator(
this.getEventStreamChannel({ workspaceId, eventStreamChannelId }),
);
}
async publish<T>({
channel,
payload,
workspaceId,
}: {
channel: SubscriptionChannel;
payload: T;
workspaceId: string;
}): Promise<void> {
const client = this.redisClient.getPubSubClient();
await client.publish(
this.getSubscriptionChannel({ channel, workspaceId }),
payload,
);
}
async publishToEventStream<T>({
workspaceId,
eventStreamChannelId,
payload,
}: {
workspaceId: string;
eventStreamChannelId: string;
payload: T;
}): Promise<void> {
const client = this.redisClient.getPubSubClient();
await client.publish(
this.getEventStreamChannel({ workspaceId, eventStreamChannelId }),
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,
);
}
}