From 1d9c2fc5912d52e6d00decedb484b0566934417e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 23 Aug 2026 10:09:14 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=B4=20feat:=20Fork=20Completed=20Subag?= =?UTF-8?q?ents=20Into=20Continuable=20Chats=20(#15133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: continue completed subagents as chats * chore: sort continuation imports --- api/server/utils/import/fork.js | 19 +++- api/server/utils/import/fork.spec.js | 62 ++++++++++++ .../Subagents/SubagentThreadPanel.test.tsx | 96 ++++++++++++++++++- .../Chat/Subagents/SubagentThreadPanel.tsx | 56 ++++++++++- 4 files changed, 225 insertions(+), 8 deletions(-) diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js index 4b463bac6f..cd08d51862 100644 --- a/api/server/utils/import/fork.js +++ b/api/server/utils/import/fork.js @@ -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} 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, diff --git a/api/server/utils/import/fork.spec.js b/api/server/utils/import/fork.spec.js index 68c52cbc7d..10c2bfdfbf 100644 --- a/api/server/utils/import/fork.spec.js +++ b/api/server/utils/import/fork.spec.js @@ -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 () => { diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx index 46dc8808aa..d45e0fe22a 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx @@ -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', () => ({ ), 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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', () => { diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx index 4ef8aac190..cf6a5c3679 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx @@ -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(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} + {canContinueAsChat && ( + + )}