From 061780553a4813dec3a93cbb56d4f4accb816234 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 1 Oct 2026 11:56:35 +0200
Subject: [PATCH 1/7] feat: Resume Streams and Send AI SDK Messages Through the
Chat Facade
useChat and useChatActions gain resumeStream, which asks the pane's resume-on-load path
to re-check the stream status and reattach through the host transport, and sendMessage
now also takes an AI SDK user message of text parts, sent as the ask call it describes.
The facade spec covers the new members and the refused-send and failed-stop paths; the
transport spec resumes a running generation against a fake transport, stays detached
when nothing runs, and reports a reattached stream that fails.
---
.../src/hooks/Chat/__tests__/facade.spec.tsx | 70 ++++++++-
.../hooks/Chat/__tests__/transport.spec.tsx | 142 +++++++++++++++++-
client/src/hooks/Chat/facade.ts | 86 +++++++++--
client/src/hooks/SSE/useResumeOnLoad.ts | 49 +++++-
client/src/store/resume.ts | 9 ++
5 files changed, 343 insertions(+), 13 deletions(-)
create mode 100644 client/src/store/resume.ts
diff --git a/client/src/hooks/Chat/__tests__/facade.spec.tsx b/client/src/hooks/Chat/__tests__/facade.spec.tsx
index e6bf42298b7..2183bccd6d2 100644
--- a/client/src/hooks/Chat/__tests__/facade.spec.tsx
+++ b/client/src/hooks/Chat/__tests__/facade.spec.tsx
@@ -1,10 +1,12 @@
import React from 'react';
+import { getDefaultStore } from 'jotai';
import { act, render, renderHook } from '@testing-library/react';
import { QueryKeys, ContentTypes } from 'librechat-data-provider';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import type { TConversation, TMessage, TMessageContentParts } from 'librechat-data-provider';
import type { ChatContract } from '../contract';
import { ChatContext } from '~/Providers/ChatContext';
+import { resumeRequestFamily } from '~/store/resume';
import { useChat, useChatActions } from '../facade';
const userMessage: TMessage = {
@@ -637,6 +639,71 @@ describe('useChat', () => {
expect(contract.stopGenerating).toHaveBeenCalledTimes(1);
});
+ it('sends an AI SDK user message as the ask call it describes', () => {
+ const contract = createContract();
+ const { result } = renderChat(contract);
+
+ result.current.sendMessage(
+ {
+ parts: [
+ { type: 'text', text: 'Hel' },
+ { type: 'text', text: 'lo' },
+ ],
+ metadata: { parentMessageId: 'user-1' },
+ },
+ { isRegenerate: false },
+ );
+
+ expect(contract.ask).toHaveBeenCalledWith(
+ { text: 'Hello', parentMessageId: 'user-1' },
+ { isRegenerate: false },
+ );
+ });
+
+ it('reports a refused send to the caller', () => {
+ const contract = createContract({ ask: jest.fn(() => false as const) });
+ const { result } = renderChat(contract);
+
+ expect(result.current.sendMessage({ parts: [{ type: 'text', text: 'Hi' }] })).toBe(false);
+ });
+
+ it('rejects stop when the stop request fails', async () => {
+ const failure = new Error('abort failed');
+ const contract = createContract({
+ isSubmitting: true,
+ stopGenerating: jest.fn(() => Promise.reject(failure)),
+ });
+ const { result } = renderChat(contract);
+
+ await expect(result.current.stop()).rejects.toBe(failure);
+ });
+
+ it('requests a resume of the chat it reads', async () => {
+ const store = getDefaultStore();
+ const before = store.get(resumeRequestFamily('convo-1'));
+ const { result } = renderChat(createContract());
+
+ await result.current.resumeStream();
+ await result.current.resumeStream();
+
+ expect(store.get(resumeRequestFamily('convo-1'))).toBe(before + 2);
+ });
+
+ it('requests no resume for a chat that has no conversation yet', async () => {
+ const store = getDefaultStore();
+ const before = store.get(resumeRequestFamily('new'));
+ const { result } = renderChat(
+ createContract({
+ messagesKey: 'new',
+ conversation: { conversationId: 'new' } as TConversation,
+ }),
+ );
+
+ await expect(result.current.resumeStream()).resolves.toBeUndefined();
+ expect(result.current.id).toBe('new');
+ expect(store.get(resumeRequestFamily('new'))).toBe(before);
+ });
+
it('writes UI messages back onto the stored messages', () => {
const answered = response({
text: 'Hello',
@@ -716,7 +783,8 @@ describe('useChatActions', () => {
it('forwards its actions to the contract', () => {
const { result, contract } = renderActions([userMessage, response()]);
- expect(result.current.sendMessage).toBe(contract.ask);
+ result.current.sendMessage({ text: 'Hello' });
+ expect(contract.ask).toHaveBeenCalledWith({ text: 'Hello' }, undefined);
expect(result.current.stop).toBe(contract.stopGenerating);
result.current.regenerate();
expect(contract.regenerate).toHaveBeenCalledWith({
diff --git a/client/src/hooks/Chat/__tests__/transport.spec.tsx b/client/src/hooks/Chat/__tests__/transport.spec.tsx
index 8be4e480fa6..d660ff0b5c6 100644
--- a/client/src/hooks/Chat/__tests__/transport.spec.tsx
+++ b/client/src/hooks/Chat/__tests__/transport.spec.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { useAtomValue } from 'jotai';
import { MemoryRouter } from 'react-router-dom';
import { RecoilRoot, useRecoilValue } from 'recoil';
-import { QueryKeys } from 'librechat-data-provider';
+import { QueryKeys, request } from 'librechat-data-provider';
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type {
@@ -13,15 +13,21 @@ import type {
ChatTransportRequest,
} from 'librechat-data-provider';
import type { MutableSnapshot } from 'recoil';
+import type { StreamStatusResponse } from '~/data-provider';
+import type { ChatContract } from '~/hooks/Chat/contract';
import type { Transport } from '~/hooks/Chat/contract';
import type { PendingSteer } from '~/store/families';
import { queuedMessagesByConvoId, resetQueueFamilies } from '~/hooks/Chat/queue';
import { ChatTransportContext } from '~/Providers/ChatTransportContext';
+import { ChatContext, useChatContext } from '~/Providers/ChatContext';
+import { startupConfigKey } from '~/data-provider/Endpoints/queries';
import { useSteerReclaim } from '~/hooks/Chat/useSteerCancel';
import useSteerEscalate from '~/hooks/Chat/useSteerEscalate';
import useResumableSSE from '~/hooks/SSE/useResumableSSE';
+import useResumeOnLoad from '~/hooks/SSE/useResumeOnLoad';
import useChatHelpers from '~/hooks/Chat/useChatHelpers';
import useSteering from '~/hooks/Chat/useSteering';
+import { useChat } from '~/hooks/Chat/facade';
import useSSE from '~/hooks/SSE/useSSE';
import store from '~/store';
@@ -492,4 +498,138 @@ describe('chat transport boundary', () => {
expect(result.current.queue).toEqual([expect.objectContaining({ text: 'hold me here' })]);
});
});
+
+ describe('resume', () => {
+ /** The host builds the pane's chat contract, as the chat view does. */
+ function ChatHost({ children }: { children: React.ReactNode }) {
+ const helpers: ChatContract = useChatHelpers(0, 'convo-1');
+ return {children};
+ }
+
+ /** The resume-on-load path and the stream hook a chat view mounts, read through `useChat`. */
+ const useResumablePane = () => {
+ const helpers = useChatContext();
+ useResumeOnLoad('convo-1', helpers.getMessages, 0, true);
+ const submission = useRecoilValue(store.submissionByIndex(0));
+ useResumableSSE(submission, helpers, false, 0);
+ return useChat();
+ };
+
+ const seedConversation = ({ set }: MutableSnapshot) =>
+ set(store.conversationByIndex(0), {
+ conversationId: 'convo-1',
+ endpoint: 'agents',
+ } as TConversation);
+
+ let status: StreamStatusResponse;
+ /** A running job as the status route reports it, with the turn it answers. */
+ const runningStatus = (): StreamStatusResponse => ({
+ active: true,
+ streamId: 'convo-1',
+ status: 'running',
+ createdAt: 2000,
+ generationProtocolVersion: 2,
+ resumeState: {
+ runSteps: [],
+ aggregatedContent: [],
+ userMessage: {
+ messageId: 'msg-1',
+ parentMessageId: '00000000-0000-0000-0000-000000000000',
+ conversationId: 'convo-1',
+ text: 'Hello',
+ },
+ responseMessageId: 'resp-1',
+ conversationId: 'convo-1',
+ } as StreamStatusResponse['resumeState'],
+ });
+ const statusReads = () =>
+ (request.get as jest.Mock).mock.calls.filter(([url]) => String(url).includes('/status/'))
+ .length;
+
+ beforeEach(() => {
+ status = { active: false };
+ /** The status read is the server's answer to "is anything running"; the stream itself
+ * comes from the fake transport. */
+ jest
+ .spyOn(request, 'get')
+ .mockImplementation(async (url: string) =>
+ url.includes('/api/agents/chat/status/') ? status : [],
+ );
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ /** Resume waits for the startup config, which decides a rebuilt turn's retention. */
+ const seedStartupConfig = (queryClient: QueryClient) => {
+ queryClient.setQueryData(startupConfigKey(false), {});
+ /** The turn the running generation answers, as history holds it on reload. */
+ queryClient.setQueryData([QueryKeys.messages, 'convo-1'], [buildSubmission().userMessage]);
+ };
+
+ const renderPane = (transport: Transport) => {
+ const Wrapper = createWrapper(transport, seedConversation, seedStartupConfig);
+ return renderHook(useResumablePane, {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+ };
+
+ it('reattaches to a running generation through the host transport', async () => {
+ const fake = createFakeTransport();
+ const { result } = renderPane(fake.transport);
+ await waitFor(() => expect(statusReads()).toBe(1));
+ expect(fake.streams).toHaveLength(0);
+
+ status = runningStatus();
+ await act(async () => {
+ await result.current.resumeStream();
+ });
+
+ await waitFor(() => expect(fake.streams).toHaveLength(1));
+ expect(fake.transport.stream).toHaveBeenCalledWith({ token: 'test-token' });
+ expect(fake.streams[0].url).toContain('/api/agents/chat/stream/convo-1');
+ expect(fake.streams[0].url).toContain('resume=true');
+ expect(fake.streams[0].url).toContain('generationCreatedAt=2000');
+ expect(fake.transport.start).not.toHaveBeenCalled();
+ });
+
+ it('re-reads the status and stays detached when nothing is running', async () => {
+ const fake = createFakeTransport();
+ const { result } = renderPane(fake.transport);
+ await waitFor(() => expect(statusReads()).toBe(1));
+
+ await act(async () => {
+ await result.current.resumeStream();
+ });
+
+ await waitFor(() => expect(statusReads()).toBe(2));
+ expect(fake.streams).toHaveLength(0);
+ expect(result.current.status).toBe('ready');
+ });
+
+ it('reports a reattached stream that fails as an error', async () => {
+ const fake = createFakeTransport();
+ const { result } = renderPane(fake.transport);
+ await waitFor(() => expect(statusReads()).toBe(1));
+ status = runningStatus();
+ await act(async () => {
+ await result.current.resumeStream();
+ });
+ await waitFor(() => expect(fake.streams).toHaveLength(1));
+
+ /** The run ends with the error, and the teardown re-reads the status to confirm it. */
+ status = { active: false, status: 'error', createdAt: 2000, generationProtocolVersion: 2 };
+ act(() =>
+ fake.streams[0].options.onEvent({ type: 'error', data: { message: 'Generation failed' } }),
+ );
+
+ await waitFor(() => expect(result.current.status).toBe('error'));
+ expect(result.current.error?.message).toContain('Generation failed');
+ });
+ });
});
diff --git a/client/src/hooks/Chat/facade.ts b/client/src/hooks/Chat/facade.ts
index 6db5ac2857b..8d281f6ca10 100644
--- a/client/src/hooks/Chat/facade.ts
+++ b/client/src/hooks/Chat/facade.ts
@@ -1,4 +1,5 @@
import { useRef, useMemo, useCallback, useSyncExternalStore } from 'react';
+import { useStore } from 'jotai';
import { hashQueryKey, useQueryClient } from '@tanstack/react-query';
import {
QueryKeys,
@@ -7,23 +8,49 @@ import {
fromUIMessage,
toUIMessage,
} from 'librechat-data-provider';
-import type { TMessage, UIMessage, TAttachment, UIMappingOptions } from 'librechat-data-provider';
+import type {
+ TMessage,
+ UIMessage,
+ UITextPart,
+ TAttachment,
+ UIMappingOptions,
+ UIMessageMetadata,
+} from 'librechat-data-provider';
import type { QueryCacheNotifyEvent } from '@tanstack/react-query';
+import type { TAskProps, TOptions } from '~/common';
import type { ChatContract } from './contract';
-import type { TAskFunction } from '~/common';
import { isMemoryFailureOutput } from '~/components/Chat/Messages/Content/Parts/MemoryCall';
import { getToolMeta } from '~/components/Chat/Messages/Content/outcome';
import { useChatContext } from '~/Providers/ChatContext';
import { isEmptyContentPart } from '~/utils/messages';
+import { resumeRequestFamily } from '~/store/resume';
import { mapAttachments } from '~/utils/map';
/** AI SDK `ChatStatus`. */
export type ChatStatus = 'submitted' | 'streaming' | 'ready' | 'error';
+/**
+ * A user turn in the AI SDK's `sendMessage` shape: its text parts, plus where it attaches.
+ * File parts are left out because the turn takes its files from the composer, not the message.
+ */
+export type SendMessageInput = {
+ parts: UITextPart[];
+ metadata?: Partial>;
+};
+
+/**
+ * Submits a turn. Takes the contract's `ask` arguments, or a user message in the AI SDK shape,
+ * which is sent as the `ask` call it describes; `{ text }` is already both.
+ */
+export type SendMessage = (
+ message: TAskProps | SendMessageInput,
+ options?: TOptions,
+) => false | void;
+
/**
* The `useChat` surface of `@ai-sdk/react@4.0.117` (`UseChatHelpers`), over LibreChat's chat
- * contract. Members the contract cannot back (`resumeStream`, `addToolOutput`, `clearError`)
- * are left out rather than stubbed.
+ * contract. Members the contract cannot back (`addToolOutput`, `clearError`) are left out rather
+ * than stubbed.
*/
export type UseChatHelpers = {
/** The conversation the messages are read from (the contract's `messagesKey`); AI SDK's chat id. */
@@ -33,11 +60,18 @@ export type UseChatHelpers = {
status: ChatStatus;
/** Set when the latest message is an error; LibreChat reports errors as messages. */
error: Error | undefined;
- /** Submits a turn: the contract's `ask`, called with the same arguments. */
- sendMessage: TAskFunction;
+ /** Submits a turn through the contract's `ask`; see {@link SendMessage}. */
+ sendMessage: SendMessage;
/** Regenerates the response to `messageId`, or the latest message of the branch. */
regenerate: (options?: { messageId?: string }) => void;
stop: () => Promise;
+ /**
+ * Reattaches this chat to its running generation, if the server has one. The request is
+ * answered by the resume-on-load path the chat view mounts for its pane, which checks the
+ * stream status and attaches through the host transport. It resolves once requested, and does
+ * nothing for a chat that is new or already attached, or where no chat view is mounted.
+ */
+ resumeStream: () => Promise;
/**
* Writes messages back to the cache, keeping the stored fields the UI view omits. A message
* with no stored counterpart joins the active conversation under the message before it, or
@@ -312,9 +346,38 @@ const findLatest = (stored: TMessage[] | undefined, latestMessageId: string | un
/** The `useChat` members that need no message list. */
export type ChatActions = Pick<
UseChatHelpers,
- 'id' | 'status' | 'sendMessage' | 'regenerate' | 'stop'
+ 'id' | 'status' | 'sendMessage' | 'regenerate' | 'stop' | 'resumeStream'
>;
+/** `ask` arguments for a `sendMessage` call, which may carry an AI SDK user message. */
+const toAskProps = (message: TAskProps | SendMessageInput): TAskProps => {
+ if (!('parts' in message)) {
+ return message;
+ }
+ const { conversationId, parentMessageId } = message.metadata ?? {};
+ return {
+ text: message.parts.map((part) => part.text).join(''),
+ ...(conversationId !== undefined && { conversationId }),
+ ...(parentMessageId !== undefined && { parentMessageId }),
+ };
+};
+
+/** `sendMessage` and `resumeStream` for the chat `id`, shared by `useChat` and `useChatActions`. */
+function useTurnActions(ask: ChatContract['ask'], id?: string) {
+ const jotaiStore = useStore();
+ const sendMessage = useCallback(
+ (message, options) => ask(toAskProps(message), options),
+ [ask],
+ );
+ const resumeStream = useCallback(async () => {
+ if (!id || id === Constants.NEW_CONVO) {
+ return;
+ }
+ jotaiStore.set(resumeRequestFamily(id), (request) => request + 1);
+ }, [id, jotaiStore]);
+ return { sendMessage, resumeStream };
+}
+
/**
* `useChat` without `messages`, for controls that submit or read status: it re-renders when the
* status changes, not on every stream frame, and never maps the conversation.
@@ -338,10 +401,11 @@ export function useChatActions(): ChatActions {
const status = useSyncExternalStore(subscribe, readStatus, readStatus);
const regenerate = useRegenerate(getMessages, latestMessageId, regenerateTarget);
const id = messagesKey || conversation?.conversationId || undefined;
+ const { sendMessage, resumeStream } = useTurnActions(ask, id);
return useMemo(
- () => ({ id, status, sendMessage: ask, regenerate, stop: stopGenerating }),
- [id, status, ask, regenerate, stopGenerating],
+ () => ({ id, status, sendMessage, regenerate, stop: stopGenerating, resumeStream }),
+ [id, status, sendMessage, regenerate, stopGenerating, resumeStream],
);
}
@@ -404,6 +468,7 @@ export function useChat(): UseChatHelpers {
);
const regenerate = useRegenerate(getMessages, latestMessageId, regenerateTarget);
+ const { sendMessage, resumeStream } = useTurnActions(ask, chatId);
const setMessages = useCallback(
(update: UIMessage[] | ((messages: UIMessage[]) => UIMessage[])) => {
@@ -438,9 +503,10 @@ export function useChat(): UseChatHelpers {
messages,
status,
error,
- sendMessage: ask,
+ sendMessage,
regenerate,
stop: stopGenerating,
+ resumeStream,
setMessages,
};
}
diff --git a/client/src/hooks/SSE/useResumeOnLoad.ts b/client/src/hooks/SSE/useResumeOnLoad.ts
index 8ac28e09cb8..7f989af8254 100644
--- a/client/src/hooks/SSE/useResumeOnLoad.ts
+++ b/client/src/hooks/SSE/useResumeOnLoad.ts
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { useStore } from 'jotai';
+import { useStore, useAtomValue } from 'jotai';
import { useQueryClient } from '@tanstack/react-query';
import { useSetRecoilState, useRecoilValue, useRecoilCallback } from 'recoil';
import {
@@ -43,6 +43,7 @@ import { pendingApprovalActionFamily } from '~/components/Chat/approval/state';
import { agentQueuedTurnsQueryKey } from '~/data-provider/SSE/queuedTurns';
import useSteerConvert from '~/hooks/Chat/useSteerConvert';
import { revealedQueuedTurnFamily } from '~/store/steer';
+import { resumeRequestFamily } from '~/store/resume';
import { useFileMapContext } from '~/Providers';
import store from '~/store';
@@ -1230,4 +1231,50 @@ export default function useResumeOnLoad(
setSubmission,
queryClient,
]);
+
+ /**
+ * An explicit `resumeStream` request takes the announcement's path: the
+ * status read decides whether anything is running, and the effect above
+ * builds the resume submission that `useResumableSSE` attaches through the
+ * host transport. A request made while this pane is already attached is
+ * answered by that attachment and changes nothing.
+ */
+ const resumeRequest = useAtomValue(resumeRequestFamily(conversationId ?? ''));
+ const answeredResumeRequestRef = useRef<{ conversationId?: string; request: number }>({
+ conversationId,
+ request: resumeRequest,
+ });
+ useEffect(() => {
+ const answered = answeredResumeRequestRef.current;
+ if (answered.conversationId !== conversationId) {
+ answeredResumeRequestRef.current = { conversationId, request: resumeRequest };
+ return;
+ }
+ if (resumeRequest === answered.request) {
+ return;
+ }
+ answeredResumeRequestRef.current = { conversationId, request: resumeRequest };
+ if (!resumableEnabled || !conversationId || conversationId === Constants.NEW_CONVO) {
+ return;
+ }
+ if (hasLiveSubmissionForThisConvo) {
+ return;
+ }
+ /** A finished submission still installed reads as attached to the check above. */
+ if (hasActiveSubmissionForThisConvo) {
+ setSubmission(null);
+ }
+ queryClient.invalidateQueries({ queryKey: streamStatusQueryKey(conversationId) });
+ queryClient.invalidateQueries({ queryKey: [QueryKeys.messages, conversationId] });
+ processedConvoRef.current = null;
+ setExternalRunArm((arm) => arm + 1);
+ }, [
+ conversationId,
+ resumeRequest,
+ resumableEnabled,
+ hasActiveSubmissionForThisConvo,
+ hasLiveSubmissionForThisConvo,
+ setSubmission,
+ queryClient,
+ ]);
}
diff --git a/client/src/store/resume.ts b/client/src/store/resume.ts
new file mode 100644
index 00000000000..ee992399a8a
--- /dev/null
+++ b/client/src/store/resume.ts
@@ -0,0 +1,9 @@
+import { atom } from 'jotai';
+import { atomFamily } from 'jotai/utils';
+
+/**
+ * Counts explicit requests to reattach a conversation to its running generation (the facade's
+ * `resumeStream`). `useResumeOnLoad` answers each increase with the same status re-check it runs
+ * when a job is announced, so a request never builds a second resume path.
+ */
+export const resumeRequestFamily = atomFamily((_conversationId: string) => atom(0));
From 3dd6d3abfa233f73f0a2a25104707e99805f2a38 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 1 Oct 2026 12:12:32 +0200
Subject: [PATCH 2/7] refactor: Keep the Resume Request With the Chat Feature
The resume-request atom is written by the facade and read by the chat's resume path,
so it moves from the app store to hooks/Chat.
---
client/src/hooks/Chat/__tests__/facade.spec.tsx | 2 +-
client/src/hooks/Chat/facade.ts | 2 +-
client/src/{store => hooks/Chat}/resume.ts | 3 ++-
client/src/hooks/SSE/useResumeOnLoad.ts | 2 +-
4 files changed, 5 insertions(+), 4 deletions(-)
rename client/src/{store => hooks/Chat}/resume.ts (76%)
diff --git a/client/src/hooks/Chat/__tests__/facade.spec.tsx b/client/src/hooks/Chat/__tests__/facade.spec.tsx
index 2183bccd6d2..3a3a8173d9c 100644
--- a/client/src/hooks/Chat/__tests__/facade.spec.tsx
+++ b/client/src/hooks/Chat/__tests__/facade.spec.tsx
@@ -6,8 +6,8 @@ import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-quer
import type { TConversation, TMessage, TMessageContentParts } from 'librechat-data-provider';
import type { ChatContract } from '../contract';
import { ChatContext } from '~/Providers/ChatContext';
-import { resumeRequestFamily } from '~/store/resume';
import { useChat, useChatActions } from '../facade';
+import { resumeRequestFamily } from '../resume';
const userMessage: TMessage = {
messageId: 'user-1',
diff --git a/client/src/hooks/Chat/facade.ts b/client/src/hooks/Chat/facade.ts
index 8d281f6ca10..f9827734602 100644
--- a/client/src/hooks/Chat/facade.ts
+++ b/client/src/hooks/Chat/facade.ts
@@ -23,7 +23,7 @@ import { isMemoryFailureOutput } from '~/components/Chat/Messages/Content/Parts/
import { getToolMeta } from '~/components/Chat/Messages/Content/outcome';
import { useChatContext } from '~/Providers/ChatContext';
import { isEmptyContentPart } from '~/utils/messages';
-import { resumeRequestFamily } from '~/store/resume';
+import { resumeRequestFamily } from './resume';
import { mapAttachments } from '~/utils/map';
/** AI SDK `ChatStatus`. */
diff --git a/client/src/store/resume.ts b/client/src/hooks/Chat/resume.ts
similarity index 76%
rename from client/src/store/resume.ts
rename to client/src/hooks/Chat/resume.ts
index ee992399a8a..4499a98a09b 100644
--- a/client/src/store/resume.ts
+++ b/client/src/hooks/Chat/resume.ts
@@ -4,6 +4,7 @@ import { atomFamily } from 'jotai/utils';
/**
* Counts explicit requests to reattach a conversation to its running generation (the facade's
* `resumeStream`). `useResumeOnLoad` answers each increase with the same status re-check it runs
- * when a job is announced, so a request never builds a second resume path.
+ * when a job is announced, so a request never builds a second resume path. Chat-owned: the facade
+ * writes it and the chat's resume path reads it, so it lives with the chat, not the app store.
*/
export const resumeRequestFamily = atomFamily((_conversationId: string) => atom(0));
diff --git a/client/src/hooks/SSE/useResumeOnLoad.ts b/client/src/hooks/SSE/useResumeOnLoad.ts
index 7f989af8254..98983d49b89 100644
--- a/client/src/hooks/SSE/useResumeOnLoad.ts
+++ b/client/src/hooks/SSE/useResumeOnLoad.ts
@@ -42,8 +42,8 @@ import { siblingIdxFamily, siblingKey } from '~/components/Chat/Messages/Thread/
import { pendingApprovalActionFamily } from '~/components/Chat/approval/state';
import { agentQueuedTurnsQueryKey } from '~/data-provider/SSE/queuedTurns';
import useSteerConvert from '~/hooks/Chat/useSteerConvert';
+import { resumeRequestFamily } from '~/hooks/Chat/resume';
import { revealedQueuedTurnFamily } from '~/store/steer';
-import { resumeRequestFamily } from '~/store/resume';
import { useFileMapContext } from '~/Providers';
import store from '~/store';
From 32520f7d27a3076ef3c8b41bdb21e63b2de5c9ab Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 1 Oct 2026 12:34:06 +0200
Subject: [PATCH 3/7] perf: Hold One Resume Request Instead of One per
Conversation
A request only matters to the pane showing its conversation, so a single atom with the
conversation id and a count replaces the per-conversation family, which kept an atom for
every conversation a session ever asked to resume.
---
client/src/hooks/Chat/__tests__/facade.spec.tsx | 10 +++++-----
client/src/hooks/Chat/facade.ts | 4 ++--
client/src/hooks/Chat/resume.ts | 15 +++++++++------
client/src/hooks/SSE/useResumeOnLoad.ts | 17 ++++++++++-------
4 files changed, 26 insertions(+), 20 deletions(-)
diff --git a/client/src/hooks/Chat/__tests__/facade.spec.tsx b/client/src/hooks/Chat/__tests__/facade.spec.tsx
index 3a3a8173d9c..412a4f0b4d7 100644
--- a/client/src/hooks/Chat/__tests__/facade.spec.tsx
+++ b/client/src/hooks/Chat/__tests__/facade.spec.tsx
@@ -7,7 +7,7 @@ import type { TConversation, TMessage, TMessageContentParts } from 'librechat-da
import type { ChatContract } from '../contract';
import { ChatContext } from '~/Providers/ChatContext';
import { useChat, useChatActions } from '../facade';
-import { resumeRequestFamily } from '../resume';
+import { resumeRequestAtom } from '../resume';
const userMessage: TMessage = {
messageId: 'user-1',
@@ -680,18 +680,18 @@ describe('useChat', () => {
it('requests a resume of the chat it reads', async () => {
const store = getDefaultStore();
- const before = store.get(resumeRequestFamily('convo-1'));
+ const before = store.get(resumeRequestAtom).count;
const { result } = renderChat(createContract());
await result.current.resumeStream();
await result.current.resumeStream();
- expect(store.get(resumeRequestFamily('convo-1'))).toBe(before + 2);
+ expect(store.get(resumeRequestAtom)).toEqual({ conversationId: 'convo-1', count: before + 2 });
});
it('requests no resume for a chat that has no conversation yet', async () => {
const store = getDefaultStore();
- const before = store.get(resumeRequestFamily('new'));
+ const before = store.get(resumeRequestAtom);
const { result } = renderChat(
createContract({
messagesKey: 'new',
@@ -701,7 +701,7 @@ describe('useChat', () => {
await expect(result.current.resumeStream()).resolves.toBeUndefined();
expect(result.current.id).toBe('new');
- expect(store.get(resumeRequestFamily('new'))).toBe(before);
+ expect(store.get(resumeRequestAtom)).toBe(before);
});
it('writes UI messages back onto the stored messages', () => {
diff --git a/client/src/hooks/Chat/facade.ts b/client/src/hooks/Chat/facade.ts
index f9827734602..2704f5f2134 100644
--- a/client/src/hooks/Chat/facade.ts
+++ b/client/src/hooks/Chat/facade.ts
@@ -23,7 +23,7 @@ import { isMemoryFailureOutput } from '~/components/Chat/Messages/Content/Parts/
import { getToolMeta } from '~/components/Chat/Messages/Content/outcome';
import { useChatContext } from '~/Providers/ChatContext';
import { isEmptyContentPart } from '~/utils/messages';
-import { resumeRequestFamily } from './resume';
+import { resumeRequestAtom } from './resume';
import { mapAttachments } from '~/utils/map';
/** AI SDK `ChatStatus`. */
@@ -373,7 +373,7 @@ function useTurnActions(ask: ChatContract['ask'], id?: string) {
if (!id || id === Constants.NEW_CONVO) {
return;
}
- jotaiStore.set(resumeRequestFamily(id), (request) => request + 1);
+ jotaiStore.set(resumeRequestAtom, ({ count }) => ({ conversationId: id, count: count + 1 }));
}, [id, jotaiStore]);
return { sendMessage, resumeStream };
}
diff --git a/client/src/hooks/Chat/resume.ts b/client/src/hooks/Chat/resume.ts
index 4499a98a09b..34b0d5cb699 100644
--- a/client/src/hooks/Chat/resume.ts
+++ b/client/src/hooks/Chat/resume.ts
@@ -1,10 +1,13 @@
import { atom } from 'jotai';
-import { atomFamily } from 'jotai/utils';
+
+/** The latest explicit request to reattach a conversation to its running generation. */
+export type ResumeRequest = { conversationId: string; count: number };
/**
- * Counts explicit requests to reattach a conversation to its running generation (the facade's
- * `resumeStream`). `useResumeOnLoad` answers each increase with the same status re-check it runs
- * when a job is announced, so a request never builds a second resume path. Chat-owned: the facade
- * writes it and the chat's resume path reads it, so it lives with the chat, not the app store.
+ * Explicit requests to reattach a conversation to its running generation (the facade's
+ * `resumeStream`). `useResumeOnLoad` answers each new count for the conversation it shows with
+ * the same status re-check it runs when a job is announced, so a request never builds a second
+ * resume path. One value, not one per conversation: a request only matters to the pane showing
+ * that conversation. Chat-owned, so it lives with the chat rather than in the app store.
*/
-export const resumeRequestFamily = atomFamily((_conversationId: string) => atom(0));
+export const resumeRequestAtom = atom({ conversationId: '', count: 0 });
diff --git a/client/src/hooks/SSE/useResumeOnLoad.ts b/client/src/hooks/SSE/useResumeOnLoad.ts
index 98983d49b89..9fd9c7d923c 100644
--- a/client/src/hooks/SSE/useResumeOnLoad.ts
+++ b/client/src/hooks/SSE/useResumeOnLoad.ts
@@ -42,8 +42,8 @@ import { siblingIdxFamily, siblingKey } from '~/components/Chat/Messages/Thread/
import { pendingApprovalActionFamily } from '~/components/Chat/approval/state';
import { agentQueuedTurnsQueryKey } from '~/data-provider/SSE/queuedTurns';
import useSteerConvert from '~/hooks/Chat/useSteerConvert';
-import { resumeRequestFamily } from '~/hooks/Chat/resume';
import { revealedQueuedTurnFamily } from '~/store/steer';
+import { resumeRequestAtom } from '~/hooks/Chat/resume';
import { useFileMapContext } from '~/Providers';
import store from '~/store';
@@ -1239,21 +1239,24 @@ export default function useResumeOnLoad(
* host transport. A request made while this pane is already attached is
* answered by that attachment and changes nothing.
*/
- const resumeRequest = useAtomValue(resumeRequestFamily(conversationId ?? ''));
- const answeredResumeRequestRef = useRef<{ conversationId?: string; request: number }>({
+ const resumeRequest = useAtomValue(resumeRequestAtom);
+ const answeredResumeRequestRef = useRef<{ conversationId?: string; count: number }>({
conversationId,
- request: resumeRequest,
+ count: resumeRequest.count,
});
useEffect(() => {
const answered = answeredResumeRequestRef.current;
if (answered.conversationId !== conversationId) {
- answeredResumeRequestRef.current = { conversationId, request: resumeRequest };
+ answeredResumeRequestRef.current = { conversationId, count: resumeRequest.count };
return;
}
- if (resumeRequest === answered.request) {
+ if (resumeRequest.count === answered.count) {
+ return;
+ }
+ answeredResumeRequestRef.current = { conversationId, count: resumeRequest.count };
+ if (resumeRequest.conversationId !== conversationId) {
return;
}
- answeredResumeRequestRef.current = { conversationId, request: resumeRequest };
if (!resumableEnabled || !conversationId || conversationId === Constants.NEW_CONVO) {
return;
}
From 5934f63244d1368e16e465acd6b9372da146ee70 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 1 Oct 2026 13:15:17 +0200
Subject: [PATCH 4/7] fix: Keep Each Conversation's Resume Request Until Its
Pane Answers
A single request slot let a second pane's resumeStream overwrite the first before
either pane's effect ran, so the first request was dropped. Requests are now a set of
conversation ids: the facade adds its chat, and the pane showing that conversation
consumes the entry and answers it.
---
.../src/hooks/Chat/__tests__/facade.spec.tsx | 10 +++---
.../hooks/Chat/__tests__/transport.spec.tsx | 20 ++++++++++-
client/src/hooks/Chat/facade.ts | 11 +++---
client/src/hooks/Chat/resume.ts | 15 ++++----
client/src/hooks/SSE/useResumeOnLoad.ts | 36 ++++++++-----------
5 files changed, 52 insertions(+), 40 deletions(-)
diff --git a/client/src/hooks/Chat/__tests__/facade.spec.tsx b/client/src/hooks/Chat/__tests__/facade.spec.tsx
index 412a4f0b4d7..0484175f33a 100644
--- a/client/src/hooks/Chat/__tests__/facade.spec.tsx
+++ b/client/src/hooks/Chat/__tests__/facade.spec.tsx
@@ -7,7 +7,7 @@ import type { TConversation, TMessage, TMessageContentParts } from 'librechat-da
import type { ChatContract } from '../contract';
import { ChatContext } from '~/Providers/ChatContext';
import { useChat, useChatActions } from '../facade';
-import { resumeRequestAtom } from '../resume';
+import { resumeRequestsAtom } from '../resume';
const userMessage: TMessage = {
messageId: 'user-1',
@@ -680,18 +680,18 @@ describe('useChat', () => {
it('requests a resume of the chat it reads', async () => {
const store = getDefaultStore();
- const before = store.get(resumeRequestAtom).count;
+ store.set(resumeRequestsAtom, new Set(['convo-2']));
const { result } = renderChat(createContract());
await result.current.resumeStream();
await result.current.resumeStream();
- expect(store.get(resumeRequestAtom)).toEqual({ conversationId: 'convo-1', count: before + 2 });
+ expect([...store.get(resumeRequestsAtom)]).toEqual(['convo-2', 'convo-1']);
});
it('requests no resume for a chat that has no conversation yet', async () => {
const store = getDefaultStore();
- const before = store.get(resumeRequestAtom);
+ const before = store.get(resumeRequestsAtom);
const { result } = renderChat(
createContract({
messagesKey: 'new',
@@ -701,7 +701,7 @@ describe('useChat', () => {
await expect(result.current.resumeStream()).resolves.toBeUndefined();
expect(result.current.id).toBe('new');
- expect(store.get(resumeRequestAtom)).toBe(before);
+ expect(store.get(resumeRequestsAtom)).toBe(before);
});
it('writes UI messages back onto the stored messages', () => {
diff --git a/client/src/hooks/Chat/__tests__/transport.spec.tsx b/client/src/hooks/Chat/__tests__/transport.spec.tsx
index d660ff0b5c6..a4121812200 100644
--- a/client/src/hooks/Chat/__tests__/transport.spec.tsx
+++ b/client/src/hooks/Chat/__tests__/transport.spec.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { useAtomValue } from 'jotai';
+import { useAtomValue, getDefaultStore } from 'jotai';
import { MemoryRouter } from 'react-router-dom';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { QueryKeys, request } from 'librechat-data-provider';
@@ -26,6 +26,7 @@ import useSteerEscalate from '~/hooks/Chat/useSteerEscalate';
import useResumableSSE from '~/hooks/SSE/useResumableSSE';
import useResumeOnLoad from '~/hooks/SSE/useResumeOnLoad';
import useChatHelpers from '~/hooks/Chat/useChatHelpers';
+import { resumeRequestsAtom } from '~/hooks/Chat/resume';
import useSteering from '~/hooks/Chat/useSteering';
import { useChat } from '~/hooks/Chat/facade';
import useSSE from '~/hooks/SSE/useSSE';
@@ -612,6 +613,23 @@ describe('chat transport boundary', () => {
expect(result.current.status).toBe('ready');
});
+ it("answers its own conversation's request and leaves another pane's pending", async () => {
+ const store = getDefaultStore();
+ const fake = createFakeTransport();
+ const { result } = renderPane(fake.transport);
+ await waitFor(() => expect(statusReads()).toBe(1));
+
+ await act(async () => {
+ /** Another pane asks in the same tick, before any effect runs. */
+ store.set(resumeRequestsAtom, (pending) => new Set(pending).add('convo-2'));
+ await result.current.resumeStream();
+ });
+
+ await waitFor(() => expect(statusReads()).toBe(2));
+ expect([...store.get(resumeRequestsAtom)]).toEqual(['convo-2']);
+ store.set(resumeRequestsAtom, new Set());
+ });
+
it('reports a reattached stream that fails as an error', async () => {
const fake = createFakeTransport();
const { result } = renderPane(fake.transport);
diff --git a/client/src/hooks/Chat/facade.ts b/client/src/hooks/Chat/facade.ts
index 2704f5f2134..007c270246b 100644
--- a/client/src/hooks/Chat/facade.ts
+++ b/client/src/hooks/Chat/facade.ts
@@ -23,7 +23,7 @@ import { isMemoryFailureOutput } from '~/components/Chat/Messages/Content/Parts/
import { getToolMeta } from '~/components/Chat/Messages/Content/outcome';
import { useChatContext } from '~/Providers/ChatContext';
import { isEmptyContentPart } from '~/utils/messages';
-import { resumeRequestAtom } from './resume';
+import { resumeRequestsAtom } from './resume';
import { mapAttachments } from '~/utils/map';
/** AI SDK `ChatStatus`. */
@@ -68,8 +68,9 @@ export type UseChatHelpers = {
/**
* Reattaches this chat to its running generation, if the server has one. The request is
* answered by the resume-on-load path the chat view mounts for its pane, which checks the
- * stream status and attaches through the host transport. It resolves once requested, and does
- * nothing for a chat that is new or already attached, or where no chat view is mounted.
+ * stream status and attaches through the host transport. It resolves once requested. It does
+ * nothing for a new chat or one already attached, and a request for a conversation no chat view
+ * shows waits until one does.
*/
resumeStream: () => Promise;
/**
@@ -373,7 +374,9 @@ function useTurnActions(ask: ChatContract['ask'], id?: string) {
if (!id || id === Constants.NEW_CONVO) {
return;
}
- jotaiStore.set(resumeRequestAtom, ({ count }) => ({ conversationId: id, count: count + 1 }));
+ jotaiStore.set(resumeRequestsAtom, (pending) =>
+ pending.has(id) ? pending : new Set(pending).add(id),
+ );
}, [id, jotaiStore]);
return { sendMessage, resumeStream };
}
diff --git a/client/src/hooks/Chat/resume.ts b/client/src/hooks/Chat/resume.ts
index 34b0d5cb699..ca50ad3d17d 100644
--- a/client/src/hooks/Chat/resume.ts
+++ b/client/src/hooks/Chat/resume.ts
@@ -1,13 +1,10 @@
import { atom } from 'jotai';
-/** The latest explicit request to reattach a conversation to its running generation. */
-export type ResumeRequest = { conversationId: string; count: number };
-
/**
- * Explicit requests to reattach a conversation to its running generation (the facade's
- * `resumeStream`). `useResumeOnLoad` answers each new count for the conversation it shows with
- * the same status re-check it runs when a job is announced, so a request never builds a second
- * resume path. One value, not one per conversation: a request only matters to the pane showing
- * that conversation. Chat-owned, so it lives with the chat rather than in the app store.
+ * Conversations with an unanswered request to reattach to their running generation (the
+ * facade's `resumeStream`). The pane showing a conversation consumes its entry and answers it
+ * with the same status re-check `useResumeOnLoad` runs when a job is announced, so a request
+ * never builds a second resume path. Entries are consumed, so the set stays as small as the
+ * requests in flight. Chat-owned, so it lives with the chat rather than in the app store.
*/
-export const resumeRequestAtom = atom({ conversationId: '', count: 0 });
+export const resumeRequestsAtom = atom>(new Set());
diff --git a/client/src/hooks/SSE/useResumeOnLoad.ts b/client/src/hooks/SSE/useResumeOnLoad.ts
index 9fd9c7d923c..256dd77bc77 100644
--- a/client/src/hooks/SSE/useResumeOnLoad.ts
+++ b/client/src/hooks/SSE/useResumeOnLoad.ts
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { useStore, useAtomValue } from 'jotai';
+import { useStore, useAtom } from 'jotai';
import { useQueryClient } from '@tanstack/react-query';
import { useSetRecoilState, useRecoilValue, useRecoilCallback } from 'recoil';
import {
@@ -43,7 +43,7 @@ import { pendingApprovalActionFamily } from '~/components/Chat/approval/state';
import { agentQueuedTurnsQueryKey } from '~/data-provider/SSE/queuedTurns';
import useSteerConvert from '~/hooks/Chat/useSteerConvert';
import { revealedQueuedTurnFamily } from '~/store/steer';
-import { resumeRequestAtom } from '~/hooks/Chat/resume';
+import { resumeRequestsAtom } from '~/hooks/Chat/resume';
import { useFileMapContext } from '~/Providers';
import store from '~/store';
@@ -1236,28 +1236,21 @@ export default function useResumeOnLoad(
* An explicit `resumeStream` request takes the announcement's path: the
* status read decides whether anything is running, and the effect above
* builds the resume submission that `useResumableSSE` attaches through the
- * host transport. A request made while this pane is already attached is
- * answered by that attachment and changes nothing.
+ * host transport. The request is consumed either way: one made while this
+ * pane is already attached is answered by that attachment.
*/
- const resumeRequest = useAtomValue(resumeRequestAtom);
- const answeredResumeRequestRef = useRef<{ conversationId?: string; count: number }>({
- conversationId,
- count: resumeRequest.count,
- });
+ const [resumeRequests, setResumeRequests] = useAtom(resumeRequestsAtom);
+ const resumeRequested = !!conversationId && resumeRequests.has(conversationId);
useEffect(() => {
- const answered = answeredResumeRequestRef.current;
- if (answered.conversationId !== conversationId) {
- answeredResumeRequestRef.current = { conversationId, count: resumeRequest.count };
- return;
- }
- if (resumeRequest.count === answered.count) {
- return;
- }
- answeredResumeRequestRef.current = { conversationId, count: resumeRequest.count };
- if (resumeRequest.conversationId !== conversationId) {
+ if (!resumeRequested || !conversationId) {
return;
}
- if (!resumableEnabled || !conversationId || conversationId === Constants.NEW_CONVO) {
+ setResumeRequests((pending) => {
+ const next = new Set(pending);
+ next.delete(conversationId);
+ return next;
+ });
+ if (!resumableEnabled || conversationId === Constants.NEW_CONVO) {
return;
}
if (hasLiveSubmissionForThisConvo) {
@@ -1273,7 +1266,8 @@ export default function useResumeOnLoad(
setExternalRunArm((arm) => arm + 1);
}, [
conversationId,
- resumeRequest,
+ resumeRequested,
+ setResumeRequests,
resumableEnabled,
hasActiveSubmissionForThisConvo,
hasLiveSubmissionForThisConvo,
From 243fe4f3525c31271f87edf6ba2edbce727aa858 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 1 Oct 2026 14:10:58 +0200
Subject: [PATCH 5/7] fix: Attach an AI SDK Message With a Null Parent at the
Root
A root message's view carries a null parentMessageId, which ask reads as unspecified
and replaces with the branch tail. sendMessage now passes the no-parent sentinel for an
explicit null, so the turn attaches where its metadata says.
---
client/src/hooks/Chat/__tests__/facade.spec.tsx | 17 ++++++++++++++++-
client/src/hooks/Chat/facade.ts | 6 +++++-
2 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/client/src/hooks/Chat/__tests__/facade.spec.tsx b/client/src/hooks/Chat/__tests__/facade.spec.tsx
index 0484175f33a..04223be6795 100644
--- a/client/src/hooks/Chat/__tests__/facade.spec.tsx
+++ b/client/src/hooks/Chat/__tests__/facade.spec.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { getDefaultStore } from 'jotai';
import { act, render, renderHook } from '@testing-library/react';
-import { QueryKeys, ContentTypes } from 'librechat-data-provider';
+import { QueryKeys, Constants, ContentTypes } from 'librechat-data-provider';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import type { TConversation, TMessage, TMessageContentParts } from 'librechat-data-provider';
import type { ChatContract } from '../contract';
@@ -660,6 +660,21 @@ describe('useChat', () => {
);
});
+ it('attaches an AI SDK message with a null parent at the root', () => {
+ const contract = createContract();
+ const { result } = renderChat(contract);
+
+ result.current.sendMessage({
+ parts: [{ type: 'text', text: 'From the top' }],
+ metadata: { parentMessageId: null },
+ });
+
+ expect(contract.ask).toHaveBeenCalledWith(
+ { text: 'From the top', parentMessageId: Constants.NO_PARENT },
+ undefined,
+ );
+ });
+
it('reports a refused send to the caller', () => {
const contract = createContract({ ask: jest.fn(() => false as const) });
const { result } = renderChat(contract);
diff --git a/client/src/hooks/Chat/facade.ts b/client/src/hooks/Chat/facade.ts
index 007c270246b..fa3a152e331 100644
--- a/client/src/hooks/Chat/facade.ts
+++ b/client/src/hooks/Chat/facade.ts
@@ -359,7 +359,11 @@ const toAskProps = (message: TAskProps | SendMessageInput): TAskProps => {
return {
text: message.parts.map((part) => part.text).join(''),
...(conversationId !== undefined && { conversationId }),
- ...(parentMessageId !== undefined && { parentMessageId }),
+ /** A root message's view carries a `null` parent, which `ask` would read as "append to the
+ * branch tail"; the turn asked to attach at the root. */
+ ...(parentMessageId !== undefined && {
+ parentMessageId: parentMessageId ?? Constants.NO_PARENT,
+ }),
};
};
From 8da163841759bbc725d2918ec818ccd3ba56bd18 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 1 Oct 2026 16:38:04 +0200
Subject: [PATCH 6/7] fix: Hold a Resume Request Until Its Conversation Loads,
and Send to the Facade's Chat
A pane navigating away from an Assistants conversation still reads that endpoint while
the route already names the next one, so it consumed a resume request it could not answer.
The request now waits until the pane has loaded the route's conversation. sendMessage's AI
SDK input also stops accepting a conversationId: the turn goes to the facade's own chat.
---
.../hooks/Chat/__tests__/transport.spec.tsx | 44 ++++++++++++++++---
client/src/hooks/Chat/facade.ts | 8 ++--
client/src/hooks/SSE/useResumeOnLoad.ts | 6 ++-
3 files changed, 48 insertions(+), 10 deletions(-)
diff --git a/client/src/hooks/Chat/__tests__/transport.spec.tsx b/client/src/hooks/Chat/__tests__/transport.spec.tsx
index a4121812200..0484f8c2e2d 100644
--- a/client/src/hooks/Chat/__tests__/transport.spec.tsx
+++ b/client/src/hooks/Chat/__tests__/transport.spec.tsx
@@ -1,9 +1,9 @@
import React from 'react';
-import { useAtomValue, getDefaultStore } from 'jotai';
import { MemoryRouter } from 'react-router-dom';
-import { RecoilRoot, useRecoilValue } from 'recoil';
+import { useAtomValue, getDefaultStore } from 'jotai';
import { QueryKeys, request } from 'librechat-data-provider';
import { act, renderHook, waitFor } from '@testing-library/react';
+import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type {
TEnqueueAgentQueuedTurnRequest,
@@ -513,7 +513,8 @@ describe('chat transport boundary', () => {
useResumeOnLoad('convo-1', helpers.getMessages, 0, true);
const submission = useRecoilValue(store.submissionByIndex(0));
useResumableSSE(submission, helpers, false, 0);
- return useChat();
+ const showConversation = useSetRecoilState(store.conversationByIndex(0));
+ return { ...useChat(), showConversation };
};
const seedConversation = ({ set }: MutableSnapshot) =>
@@ -569,8 +570,11 @@ describe('chat transport boundary', () => {
queryClient.setQueryData([QueryKeys.messages, 'convo-1'], [buildSubmission().userMessage]);
};
- const renderPane = (transport: Transport) => {
- const Wrapper = createWrapper(transport, seedConversation, seedStartupConfig);
+ const renderPane = (
+ transport: Transport,
+ seed: (snapshot: MutableSnapshot) => void = seedConversation,
+ ) => {
+ const Wrapper = createWrapper(transport, seed, seedStartupConfig);
return renderHook(useResumablePane, {
wrapper: ({ children }) => (
@@ -613,6 +617,36 @@ describe('chat transport boundary', () => {
expect(result.current.status).toBe('ready');
});
+ it('holds a request until the pane has loaded the conversation the route names', async () => {
+ const store$ = getDefaultStore();
+ const fake = createFakeTransport();
+ /** The pane still shows the Assistants conversation it is navigating away from. */
+ const { result } = renderPane(fake.transport, ({ set }) =>
+ set(store.conversationByIndex(0), {
+ conversationId: 'assistants-convo',
+ endpoint: 'assistants',
+ } as TConversation),
+ );
+ status = runningStatus();
+
+ await act(async () => {
+ await result.current.resumeStream();
+ });
+ expect([...store$.get(resumeRequestsAtom)]).toEqual(['convo-1']);
+ expect(fake.streams).toHaveLength(0);
+
+ act(() =>
+ result.current.showConversation({
+ conversationId: 'convo-1',
+ endpoint: 'agents',
+ } as TConversation),
+ );
+
+ await waitFor(() => expect(fake.streams).toHaveLength(1));
+ expect(fake.streams[0].url).toContain('resume=true');
+ expect([...store$.get(resumeRequestsAtom)]).toEqual([]);
+ });
+
it("answers its own conversation's request and leaves another pane's pending", async () => {
const store = getDefaultStore();
const fake = createFakeTransport();
diff --git a/client/src/hooks/Chat/facade.ts b/client/src/hooks/Chat/facade.ts
index fa3a152e331..328a7cb3603 100644
--- a/client/src/hooks/Chat/facade.ts
+++ b/client/src/hooks/Chat/facade.ts
@@ -30,12 +30,13 @@ import { mapAttachments } from '~/utils/map';
export type ChatStatus = 'submitted' | 'streaming' | 'ready' | 'error';
/**
- * A user turn in the AI SDK's `sendMessage` shape: its text parts, plus where it attaches.
+ * A user turn in the AI SDK's `sendMessage` shape: its text parts, plus the message it attaches
+ * under. It goes to the facade's own chat, as AI SDK `useChat` does, so it names no conversation.
* File parts are left out because the turn takes its files from the composer, not the message.
*/
export type SendMessageInput = {
parts: UITextPart[];
- metadata?: Partial>;
+ metadata?: Partial>;
};
/**
@@ -355,10 +356,9 @@ const toAskProps = (message: TAskProps | SendMessageInput): TAskProps => {
if (!('parts' in message)) {
return message;
}
- const { conversationId, parentMessageId } = message.metadata ?? {};
+ const { parentMessageId } = message.metadata ?? {};
return {
text: message.parts.map((part) => part.text).join(''),
- ...(conversationId !== undefined && { conversationId }),
/** A root message's view carries a `null` parent, which `ask` would read as "append to the
* branch tail"; the turn asked to attach at the root. */
...(parentMessageId !== undefined && {
diff --git a/client/src/hooks/SSE/useResumeOnLoad.ts b/client/src/hooks/SSE/useResumeOnLoad.ts
index 256dd77bc77..b6ee8e6be37 100644
--- a/client/src/hooks/SSE/useResumeOnLoad.ts
+++ b/client/src/hooks/SSE/useResumeOnLoad.ts
@@ -1241,8 +1241,11 @@ export default function useResumeOnLoad(
*/
const [resumeRequests, setResumeRequests] = useAtom(resumeRequestsAtom);
const resumeRequested = !!conversationId && resumeRequests.has(conversationId);
+ /** The route can name a conversation before this pane has loaded it; until then the endpoint
+ * that decides resumability is the previous conversation's, so the request waits. */
+ const routeConversationLoaded = currentConversation?.conversationId === conversationId;
useEffect(() => {
- if (!resumeRequested || !conversationId) {
+ if (!resumeRequested || !conversationId || !routeConversationLoaded) {
return;
}
setResumeRequests((pending) => {
@@ -1267,6 +1270,7 @@ export default function useResumeOnLoad(
}, [
conversationId,
resumeRequested,
+ routeConversationLoaded,
setResumeRequests,
resumableEnabled,
hasActiveSubmissionForThisConvo,
From a1b9dd6ff3134feb9a3633ca115af9077584c525 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 1 Oct 2026 18:37:45 +0200
Subject: [PATCH 7/7] test: Keep the Facade Resume Tests in Their Own Atom
Store
The resume-request tests wrote to jotai's module-global default store and left their
entries for later tests; they now render under IsolatedAtomStore and read that store.
---
.../src/hooks/Chat/__tests__/facade.spec.tsx | 37 ++++++++++++++-----
1 file changed, 28 insertions(+), 9 deletions(-)
diff --git a/client/src/hooks/Chat/__tests__/facade.spec.tsx b/client/src/hooks/Chat/__tests__/facade.spec.tsx
index 04223be6795..6965cdb82dc 100644
--- a/client/src/hooks/Chat/__tests__/facade.spec.tsx
+++ b/client/src/hooks/Chat/__tests__/facade.spec.tsx
@@ -1,12 +1,13 @@
import React from 'react';
-import { getDefaultStore } from 'jotai';
import { act, render, renderHook } from '@testing-library/react';
import { QueryKeys, Constants, ContentTypes } from 'librechat-data-provider';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import type { TConversation, TMessage, TMessageContentParts } from 'librechat-data-provider';
import type { ChatContract } from '../contract';
+import type { JotaiStore } from 'test/harness';
import { ChatContext } from '~/Providers/ChatContext';
import { useChat, useChatActions } from '../facade';
+import { IsolatedAtomStore } from 'test/harness';
import { resumeRequestsAtom } from '../resume';
const userMessage: TMessage = {
@@ -693,21 +694,39 @@ describe('useChat', () => {
await expect(result.current.stop()).rejects.toBe(failure);
});
+ /** Renders `useChat` under its own atom store, seeded with another pane's pending request. */
+ const renderChatWithRequests = (contract: ChatContract) => {
+ let atoms: JotaiStore | undefined;
+ const queryClient = new QueryClient();
+ const view = renderHook(() => useChat(), {
+ wrapper: ({ children }) => (
+
+ {
+ atoms = store;
+ store.set(resumeRequestsAtom, new Set(['convo-2']));
+ }}
+ >
+ {children}
+
+
+ ),
+ });
+ const pending = () => [...(atoms?.get(resumeRequestsAtom) ?? [])];
+ return { ...view, pending };
+ };
+
it('requests a resume of the chat it reads', async () => {
- const store = getDefaultStore();
- store.set(resumeRequestsAtom, new Set(['convo-2']));
- const { result } = renderChat(createContract());
+ const { result, pending } = renderChatWithRequests(createContract());
await result.current.resumeStream();
await result.current.resumeStream();
- expect([...store.get(resumeRequestsAtom)]).toEqual(['convo-2', 'convo-1']);
+ expect(pending()).toEqual(['convo-2', 'convo-1']);
});
it('requests no resume for a chat that has no conversation yet', async () => {
- const store = getDefaultStore();
- const before = store.get(resumeRequestsAtom);
- const { result } = renderChat(
+ const { result, pending } = renderChatWithRequests(
createContract({
messagesKey: 'new',
conversation: { conversationId: 'new' } as TConversation,
@@ -716,7 +735,7 @@ describe('useChat', () => {
await expect(result.current.resumeStream()).resolves.toBeUndefined();
expect(result.current.id).toBe('new');
- expect(store.get(resumeRequestsAtom)).toBe(before);
+ expect(pending()).toEqual(['convo-2']);
});
it('writes UI messages back onto the stored messages', () => {