Files
twenty/packages/twenty-front/src/modules/ai/components/AgentChatAiSdkStreamEffect.tsx
T
Félix Malfait 8fa3962e1c feat: add resumable stream support for agent chat (#19107)
## Overview
Add resumable stream support for agent chat to allow clients to
reconnect and resume streaming responses if the connection is
interrupted (e.g., during page refresh).

## Changes

### Backend (Twenty Server)
- Add `activeStreamId` column to `AgentChatThreadEntity` to track
ongoing streams
- Create `AgentChatResumableStreamService` to manage Redis-backed
resumable streams using the `resumable-stream` library with ioredis
- Extend `AgentChatController` with:
  - `GET /:threadId/stream` endpoint to resume an existing stream
  - `DELETE /:threadId/stream` endpoint to stop an active stream
- Update `AgentChatStreamingService` to store streams in Redis and track
active stream IDs
- Add `resumable-stream@^2.2.12` dependency to package.json

### Frontend (Twenty Front)
- Update `useAgentChat` hook to:
- Use a persistent transport with `prepareReconnectToStreamRequest` for
resumable streams
  - Export `resumeStream` function from useChat
  - Add `handleStop` callback to clear active stream on DELETE endpoint
- Use thread ID as stable message ID instead of including message count
- Add stream resumption logic in `AgentChatAiSdkStreamEffect` component
to automatically call `resumeStream()` when switching threads

## Database Migration
New migration `1774003611071-add-active-stream-id-to-agent-chat-thread`
adds the `activeStreamId` column to store the current resumable stream
identifier.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 18:19:25 +02:00

161 lines
6.5 KiB
TypeScript

import { AGENT_CHAT_ENSURE_THREAD_FOR_DRAFT_EVENT_NAME } from '@/ai/constants/AgentChatEnsureThreadForDraftEventName';
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
import { useAgentChat } from '@/ai/hooks/useAgentChat';
import { useCreateAgentChatThread } from '@/ai/hooks/useCreateAgentChatThread';
import { useEnsureAgentChatThreadExistsForDraft } from '@/ai/hooks/useEnsureAgentChatThreadExistsForDraft';
import { useEnsureAgentChatThreadIdForSend } from '@/ai/hooks/useEnsureAgentChatThreadIdForSend';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
import { agentChatFetchedMessagesComponentFamilyState } from '@/ai/states/agentChatFetchedMessagesComponentFamilyState';
import { agentChatIsInitialScrollPendingOnThreadChangeState } from '@/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState';
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { mergeAgentChatFetchedAndStreamingMessages } from '@/ai/utils/mergeAgentChatFetchedAndStreamingMessages';
import { normalizeAiSdkError } from '@/ai/utils/normalizeAiSdkError';
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentFamilyState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useCallback, useEffect } from 'react';
import { isValidUuid } from 'twenty-shared/utils';
export const AgentChatAiSdkStreamEffect = () => {
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
const agentChatFetchedMessages = useAtomComponentFamilyStateValue(
agentChatFetchedMessagesComponentFamilyState,
{ threadId: currentAIChatThread },
);
const { createChatThread } = useCreateAgentChatThread();
const { ensureThreadExistsForDraft } =
useEnsureAgentChatThreadExistsForDraft(createChatThread);
const { ensureThreadIdForSend } =
useEnsureAgentChatThreadIdForSend(createChatThread);
useListenToBrowserEvent({
eventName: AGENT_CHAT_ENSURE_THREAD_FOR_DRAFT_EVENT_NAME,
onBrowserEvent: ensureThreadExistsForDraft,
});
const onStreamingComplete = useCallback(() => {
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
}, []);
const chatState = useAgentChat(
agentChatFetchedMessages,
ensureThreadIdForSend,
onStreamingComplete,
);
// Attempt to resume an active stream when navigating to an existing
// thread. We call resumeStream() manually instead of using useChat's
// resume:true option so that the stop button can coexist with
// resumption (resume:true is incompatible with abort signals).
// Only resume when the thread already has fetched messages — this
// avoids resuming on newly created threads where the thread ID
// transitions from a placeholder to a real UUID mid-conversation.
useEffect(() => {
if (
currentAIChatThread === null ||
!isValidUuid(currentAIChatThread) ||
agentChatFetchedMessages.length === 0 ||
chatState.status === 'streaming' ||
chatState.status === 'submitted'
) {
return;
}
chatState.resumeStream();
// We intentionally omit chatState.resumeStream and status from deps
// to avoid resume loops. We do include agentChatFetchedMessages.length
// so that resume fires once messages are fetched (they may arrive
// after the thread ID is set).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentAIChatThread, agentChatFetchedMessages.length]);
const setAgentChatMessages = useSetAtomComponentFamilyState(
agentChatMessagesComponentFamilyState,
{ threadId: currentAIChatThread },
);
const agentChatDisplayedThread = useAtomStateValue(
agentChatDisplayedThreadState,
);
const setAgentChatDisplayedThread = useSetAtomState(
agentChatDisplayedThreadState,
);
const setAgentChatIsInitialScrollPendingOnThreadChange = useSetAtomState(
agentChatIsInitialScrollPendingOnThreadChangeState,
);
useEffect(() => {
const mergedMessages = mergeAgentChatFetchedAndStreamingMessages(
agentChatFetchedMessages,
chatState.messages,
);
setAgentChatMessages(mergedMessages);
if (currentAIChatThread !== agentChatDisplayedThread) {
if (mergedMessages.length > 0) {
setAgentChatIsInitialScrollPendingOnThreadChange(true);
}
setAgentChatDisplayedThread(currentAIChatThread);
}
}, [
agentChatFetchedMessages,
chatState.messages,
setAgentChatMessages,
currentAIChatThread,
agentChatDisplayedThread,
setAgentChatDisplayedThread,
setAgentChatIsInitialScrollPendingOnThreadChange,
]);
const setAgentChatIsLoading = useSetAtomState(agentChatIsLoadingState);
const agentChatThreadsLoading = useAtomStateValue(
agentChatThreadsLoadingState,
);
const agentChatMessagesLoading = useAtomStateValue(
agentChatMessagesLoadingState,
);
useEffect(() => {
const combinedIsLoading =
chatState.isLoading ||
agentChatMessagesLoading ||
agentChatThreadsLoading;
setAgentChatIsLoading(combinedIsLoading);
}, [
chatState.isLoading,
agentChatMessagesLoading,
agentChatThreadsLoading,
setAgentChatIsLoading,
]);
const setAgentChatError = useSetAtomState(agentChatErrorState);
useEffect(() => {
setAgentChatError(normalizeAiSdkError(chatState.error));
}, [chatState.error, setAgentChatError]);
const setAgentChatIsStreaming = useSetAtomState(agentChatIsStreamingState);
useEffect(() => {
setAgentChatIsStreaming(chatState.status === 'streaming');
}, [chatState.status, setAgentChatIsStreaming]);
return null;
};