🪴 feat: Fork Completed Subagents Into Continuable Chats (#15133)

* feat: continue completed subagents as chats

* chore: sort continuation imports
This commit is contained in:
Danny Avila 2026-08-23 10:09:14 -04:00 committed by GitHub
parent 77cb72e50c
commit 1d9c2fc591
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 225 additions and 8 deletions

View file

@ -11,9 +11,15 @@ const BaseClient = require('~/app/clients/BaseClient');
* Helper function to clone messages with proper parent-child relationships and timestamps
* @param {TMessage[]} messagesToClone - Original messages to clone
* @param {ImportBatchBuilder} importBatchBuilder - Instance of ImportBatchBuilder
* @param {object} [options] - Clone behavior for the source conversation.
* @param {boolean} [options.detachSubagentRuntime=false] - Remove durable child execution metadata.
* @returns {Map<string, string>} Map of original messageIds to new messageIds
*/
function cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder) {
function cloneMessagesWithTimestamps(
messagesToClone,
importBatchBuilder,
{ detachSubagentRuntime = false } = {},
) {
const idMapping = new Map();
// First pass: create ID mapping and sort messages by parentMessageId
@ -63,6 +69,10 @@ function cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder) {
parentMessageId: parentId,
createdAt,
};
if (detachSubagentRuntime) {
delete clonedMessage.subagentTask;
delete clonedMessage.subagentTranscript;
}
importBatchBuilder.saveMessage(clonedMessage);
}
@ -136,7 +146,12 @@ async function forkConversation({
messagesToClone = getMessagesUpToTargetLevel(originalMessages, targetMessageId);
}
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder, {
/** A human continuation is an ordinary conversation snapshot, not another
* durable child executor. Preserve visible history while dropping the
* task protocol and private serialized model transcript. */
detachSubagentRuntime: originalConvo.subagentThread != null,
});
const result = importBatchBuilder.finishConversation(
newTitle || originalConvo.title,

View file

@ -178,6 +178,68 @@ describe('forkConversation', () => {
});
expect(bulkSaveConvos.mock.calls[0][0][0]).not.toHaveProperty('subagentThread');
expect(
bulkSaveMessages.mock.calls[0][0].every(
(message) => message.subagentTask == null && message.subagentTranscript == null,
),
).toBe(true);
});
test('drops child execution metadata while retaining its visible transcript', async () => {
getConvo.mockResolvedValue({
...mockConversation,
agent_id: 'agent-1',
subagentThread: {
rootConversationId: 'root-conversation',
parentConversationId: 'parent-conversation',
parentToolCallId: 'parent-tool-call',
subagentType: 'researcher',
subagentKind: 'agent',
depth: 1,
},
});
getMessages.mockResolvedValue([
{
messageId: 'task-1:user',
parentMessageId: Constants.NO_PARENT,
isCreatedByUser: true,
text: 'Investigate this.',
subagentTask: { attemptKey: 'attempt-1', status: 'running' },
},
{
messageId: 'task-1:assistant',
parentMessageId: 'task-1:user',
isCreatedByUser: false,
text: 'The investigation is complete.',
subagentTask: { attemptKey: 'attempt-1', status: 'completed' },
subagentTranscript: {
taskId: 'task-1',
mode: 'replace',
messagesJson: '[{"role":"assistant","content":"private"}]',
},
},
]);
await forkConversation({
originalConvoId: 'abc123',
targetMessageId: 'task-1:assistant',
requestUserId: 'user1',
option: ForkOptions.DIRECT_PATH,
});
const savedConversation = bulkSaveConvos.mock.calls[0][0][0];
const savedMessages = bulkSaveMessages.mock.calls[0][0];
expect(savedConversation).toEqual(expect.objectContaining({ agent_id: 'agent-1' }));
expect(savedConversation).not.toHaveProperty('subagentThread');
expect(savedMessages.map((message) => message.text)).toEqual([
'Investigate this.',
'The investigation is complete.',
]);
expect(
savedMessages.every(
(message) => message.subagentTask == null && message.subagentTranscript == null,
),
).toBe(true);
});
test('should fork conversation with branches', async () => {

View file

@ -1,7 +1,7 @@
import React from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { ContentTypes } from 'librechat-data-provider';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ContentTypes, ForkOptions } from 'librechat-data-provider';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { SubagentThreadView, TMessageContentParts } from 'librechat-data-provider';
import type { ActiveSubagentPanel } from '~/store/subagents';
import {
@ -14,6 +14,9 @@ import SubagentThreadPanel from './SubagentThreadPanel';
const mockUseSubagentThreadQuery = jest.fn();
const mockUseSubagentActivityStream = jest.fn();
const mockForkMutate = jest.fn();
const mockNavigateToConvo = jest.fn();
const mockShowToast = jest.fn();
const mockApprovalProviderMounted = jest.fn();
const mockApprovalProviderUnmounted = jest.fn();
let mockIsMobile = false;
@ -25,6 +28,13 @@ jest.mock('~/data-provider', () => ({
(message) =>
message.messageId === `${taskId}:user` || message.messageId === `${taskId}:assistant`,
) === true,
useForkConvoMutation: (options: {
onSuccess: (result: unknown) => void;
onError: () => void;
}) => ({
mutate: (payload: unknown) => mockForkMutate(payload, options),
isLoading: false,
}),
}));
jest.mock('~/data-provider/Subagents/useSubagentActivityStream', () => ({
@ -35,6 +45,7 @@ jest.mock('~/data-provider/Subagents/useSubagentActivityStream', () => ({
jest.mock('~/hooks', () => ({
useFocusTrap: jest.fn(),
useLocalize: () => (key: string) => key,
useNavigateToConvo: () => ({ navigateToConvo: mockNavigateToConvo }),
}));
jest.mock('~/components/Chat/Messages/Content/ApprovalContext', () => ({
@ -77,6 +88,7 @@ jest.mock('@librechat/client', () => ({
<button {...props}>{children}</button>
),
useMediaQuery: () => mockIsMobile,
useToastContext: () => ({ showToast: mockShowToast }),
}));
jest.mock('lucide-react', () => ({
@ -84,6 +96,7 @@ jest.mock('lucide-react', () => ({
Bot: () => null,
CheckCircle2: () => null,
Clock3: () => null,
MessagesSquare: () => null,
X: () => null,
XCircle: () => null,
}));
@ -107,6 +120,7 @@ const completedView: SubagentThreadView = {
parentToolCallId: 'tool-call',
subagentType: 'researcher',
subagentKind: 'agent',
agentId: 'agent-1',
title: 'Research child',
status: 'completed',
activity: [{ type: 'writing', text: 'The release is ready.' }],
@ -134,6 +148,9 @@ describe('SubagentThreadPanel', () => {
mockIsMobile = false;
mockApprovalProviderMounted.mockClear();
mockApprovalProviderUnmounted.mockClear();
mockForkMutate.mockClear();
mockNavigateToConvo.mockClear();
mockShowToast.mockClear();
});
it('renders a bounded read-only activity timeline and closes its selection', async () => {
@ -215,6 +232,81 @@ describe('SubagentThreadPanel', () => {
expect(screen.getByText('Review this change.')).toBeInTheDocument();
expect(screen.getByText('Review complete.')).toBeInTheDocument();
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-state', 'ready');
expect(screen.queryByRole('button', { name: 'com_ui_continue_chat' })).not.toBeInTheDocument();
});
it('continues a completed durable agent task as an ordinary conversation snapshot', () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: completedView,
isLoading: false,
isError: false,
isReadinessPending: false,
});
render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_continue_chat' }));
expect(mockForkMutate).toHaveBeenCalledWith(
{
conversationId: 'child-thread',
messageId: 'task:assistant',
option: ForkOptions.DIRECT_PATH,
},
expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }),
);
const mutationOptions = mockForkMutate.mock.calls[0][1];
const conversation = { conversationId: 'continued-chat', agent_id: 'agent-1' };
act(() => mutationOptions.onSuccess({ conversation, messages: [] }));
expect(mockNavigateToConvo).toHaveBeenCalledWith(conversation);
});
it.each([
['a running task', { ...completedView, status: 'running' as const }],
['a graph child', { ...completedView, subagentKind: 'graph' as const, agentId: undefined }],
['a child without an agent identity', { ...completedView, agentId: undefined }],
])('does not offer human continuation for %s', (_label, view) => {
mockUseSubagentThreadQuery.mockReturnValue({
data: view,
isLoading: false,
isError: false,
isReadinessPending: false,
});
render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
expect(screen.queryByRole('button', { name: 'com_ui_continue_chat' })).not.toBeInTheDocument();
});
it('reports continuation failures without closing the child panel', () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: completedView,
isLoading: false,
isError: false,
isReadinessPending: false,
});
render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_continue_chat' }));
mockForkMutate.mock.calls[0][1].onError();
expect(mockShowToast).toHaveBeenCalledWith({
message: 'com_ui_continue_chat_error',
status: 'error',
});
expect(screen.getByRole('region')).toBeInTheDocument();
});
it('renders newer detached progress instead of a dispatch-time parent snapshot', () => {

View file

@ -1,22 +1,29 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Bot, X } from 'lucide-react';
import { Button, useMediaQuery } from '@librechat/client';
import { Bot, MessagesSquare, X } from 'lucide-react';
import { ForkOptions } from 'librechat-data-provider';
import { useRecoilValue, useResetRecoilState } from 'recoil';
import { Button, useMediaQuery, useToastContext } from '@librechat/client';
import type { ActiveSubagentPanel } from '~/store/subagents';
import {
subagentThreadHasTaskEvidence,
useForkConvoMutation,
useSubagentThreadQuery,
} from '~/data-provider';
import {
activeSubagentPanel,
subagentProgressByToolCallId,
subagentProgressKey,
} from '~/store/subagents';
import useSubagentActivityStream from '~/data-provider/Subagents/useSubagentActivityStream';
import { subagentThreadHasTaskEvidence, useSubagentThreadQuery } from '~/data-provider';
import { adaptDurableThreadActivity, adaptLivePersistedActivity } from './adapters';
import ApprovalProvider from '~/components/Chat/Messages/Content/ApprovalContext';
import { useFocusTrap, useLocalize } from '~/hooks';
import { useFocusTrap, useLocalize, useNavigateToConvo } from '~/hooks';
import SubagentActivity from './SubagentActivity';
export default function SubagentThreadPanel({ selection }: { selection: ActiveSubagentPanel }) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { navigateToConvo } = useNavigateToConvo();
const panelRef = useRef<HTMLDivElement>(null);
const isMobile = useMediaQuery('(max-width: 767px)');
const resetSelection = useResetRecoilState(activeSubagentPanel);
@ -49,6 +56,16 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
progress.status !== 'stop' &&
progress.status !== 'error';
const continueChat = useForkConvoMutation({
onSuccess: (result) => {
resetSelection();
navigateToConvo(result.conversation);
},
onError: () => {
showToast({ message: localize('com_ui_continue_chat_error'), status: 'error' });
},
});
const close = useCallback(() => {
resetSelection();
requestAnimationFrame(() => {
@ -114,6 +131,23 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
items: durable.items.length > 0 ? durable.items : liveActivity.items,
};
}, [data, liveActivity, progress, selection.durable]);
const canContinueAsChat =
selection.host === 'conversation' &&
selection.durable != null &&
data?.subagentKind === 'agent' &&
data.agentId != null &&
data.status === 'completed' &&
subagentThreadHasTaskEvidence(data, taskId) &&
data.messages.some((message) => message.messageId === `${taskId}:assistant`);
const continueAsChat = useCallback(() => {
if (!canContinueAsChat || selection.durable == null) return;
continueChat.mutate({
conversationId: selection.durable.threadId,
messageId: `${selection.durable.taskId}:assistant`,
option: ForkOptions.DIRECT_PATH,
});
}, [canContinueAsChat, continueChat, selection.durable]);
let panelState: 'ready' | 'loading' | 'error' = 'ready';
if (
selection.durable != null &&
@ -142,6 +176,20 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
{activity.title}
</h2>
</div>
{canContinueAsChat && (
<Button
type="button"
variant="outline"
size="sm"
onClick={continueAsChat}
disabled={continueChat.isLoading}
aria-label={localize('com_ui_continue_chat')}
className="h-8 shrink-0 gap-1.5"
>
<MessagesSquare size={15} aria-hidden="true" />
<span className="hidden sm:inline">{localize('com_ui_continue_chat')}</span>
</Button>
)}
<Button
type="button"
variant="ghost"