mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🪴 feat: Fork Completed Subagents Into Continuable Chats (#15133)
* feat: continue completed subagents as chats * chore: sort continuation imports
This commit is contained in:
parent
77cb72e50c
commit
1d9c2fc591
4 changed files with 225 additions and 8 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue