From f0eda616389a203534e4366f9764d29d4b577de0 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 27 Aug 2026 06:04:13 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=B5=20feat:=20Unify=20Subagent=20Child?= =?UTF-8?q?=20Threads=20(#15261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: unify subagent child thread rendering * chore: sort subagent UI imports * fix: preserve bounded subagent thread context * fix: render persisted child activity in unified timeline * fix: bound durable subagent activity projections * perf: cap subagent activity source scans * chore: sort subagent thread imports * fix: align completed child messages * fix: bound selected subagent activity reads * fix: bound child activity storage reads * fix: preserve child receipt truncation state * test: preserve projected receipt truncation * test: align child completion e2e * test: isolate Stable Diffusion logger mock --- .../tools/structured/StableDiffusion.spec.js | 10 +- .../SharedSubagentActivityDialog.test.tsx | 27 + .../SharedSubagentActivityDialog.tsx | 28 +- .../Chat/Subagents/SubagentActivity.test.tsx | 5 +- .../Chat/Subagents/SubagentActivity.tsx | 106 ++- .../Subagents/SubagentConversation.test.tsx | 118 +++ .../Chat/Subagents/SubagentConversation.tsx | 195 +++++ .../Subagents/SubagentThreadPanel.test.tsx | 150 +++- .../Chat/Subagents/SubagentThreadPanel.tsx | 158 +++- .../Chat/Subagents/adapters.test.ts | 4 + .../src/components/Chat/Subagents/adapters.ts | 53 +- client/src/data-provider/Subagents/queries.ts | 1 + client/src/locales/en/translation.json | 7 + e2e/specs/mock/subagent-activity.spec.ts | 14 +- packages/api/src/agents/activity.spec.ts | 73 +- packages/api/src/agents/activity.ts | 159 +++- .../api/src/agents/subagentThreads.spec.ts | 8 +- packages/api/src/agents/subagentThreads.ts | 19 + packages/api/src/agents/view.spec.ts | 310 +++++++- packages/api/src/agents/view.ts | 273 +++++-- packages/data-provider/src/types/subagents.ts | 43 ++ .../data-schemas/src/methods/message.spec.ts | 272 ++++++- packages/data-schemas/src/methods/message.ts | 686 +++++++++++++++--- packages/data-schemas/src/schema/message.ts | 11 + packages/data-schemas/src/types/message.ts | 7 + 25 files changed, 2482 insertions(+), 255 deletions(-) create mode 100644 client/src/components/Chat/Subagents/SubagentConversation.test.tsx create mode 100644 client/src/components/Chat/Subagents/SubagentConversation.tsx diff --git a/api/app/clients/tools/structured/StableDiffusion.spec.js b/api/app/clients/tools/structured/StableDiffusion.spec.js index f84d23c2f9..6dd7b0dbdd 100644 --- a/api/app/clients/tools/structured/StableDiffusion.spec.js +++ b/api/app/clients/tools/structured/StableDiffusion.spec.js @@ -6,13 +6,9 @@ jest.mock('axios', () => ({ post: jest.fn() }), { virtual: true }); jest.mock('fs'); jest.mock('sharp', () => jest.fn(), { virtual: true }); jest.mock('uuid', () => ({ v4: jest.fn() }), { virtual: true }); -jest.mock( - '@librechat/data-schemas', - () => ({ - logger: { error: jest.fn() }, - }), - { virtual: true }, -); +jest.mock('@librechat/data-schemas', () => ({ + logger: { error: jest.fn() }, +})); jest.mock( '@librechat/agents/langchain/tools', () => ({ diff --git a/client/src/components/Chat/Subagents/SharedSubagentActivityDialog.test.tsx b/client/src/components/Chat/Subagents/SharedSubagentActivityDialog.test.tsx index 67b158ba64..ddd2e41091 100644 --- a/client/src/components/Chat/Subagents/SharedSubagentActivityDialog.test.tsx +++ b/client/src/components/Chat/Subagents/SharedSubagentActivityDialog.test.tsx @@ -31,6 +31,9 @@ jest.mock('~/hooks/MCP', () => ({ useMCPServerNames: () => [] })); jest.mock('./SubagentActivity', () => ({ __esModule: true, + SubagentActivityScrollSurface: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), default: ({ activity, }: { @@ -45,6 +48,30 @@ jest.mock('./SubagentActivity', () => ({ ), })); +jest.mock('./SubagentConversation', () => ({ + __esModule: true, + default: ({ + turns, + }: { + turns: Array<{ + taskId: string; + trigger: { summary: string }; + activity: { items: Array<{ type: string; text?: string }> }; + }>; + }) => ( +
+ {turns.map((turn) => ( +
+ {turn.trigger.summary} + {turn.activity.items.map((item, index) => ( + {item.text ?? item.type} + ))} +
+ ))} +
+ ), +})); + const persistedContent = (text: string): TMessageContentParts[] => [ { type: ContentTypes.TEXT, text } as TMessageContentParts, ]; diff --git a/client/src/components/Chat/Subagents/SharedSubagentActivityDialog.tsx b/client/src/components/Chat/Subagents/SharedSubagentActivityDialog.tsx index 4d7ec4bf60..45e97441e8 100644 --- a/client/src/components/Chat/Subagents/SharedSubagentActivityDialog.tsx +++ b/client/src/components/Chat/Subagents/SharedSubagentActivityDialog.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useRecoilValue, useResetRecoilState } from 'recoil'; import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle } from '@librechat/client'; +import { SubagentActivityScrollSurface } from './SubagentActivity'; +import SubagentConversation from './SubagentConversation'; import { activeSubagentPanel } from '~/store/subagents'; import { adaptLivePersistedActivity } from './adapters'; -import SubagentActivity from './SubagentActivity'; import { useLocalize } from '~/hooks'; /** Public-share fallback for subagent activity already embedded in the shared message payload. */ @@ -67,14 +68,23 @@ export default function SharedSubagentActivityDialog({ shareId }: { shareId?: st {activity.title} - + + + ); diff --git a/client/src/components/Chat/Subagents/SubagentActivity.test.tsx b/client/src/components/Chat/Subagents/SubagentActivity.test.tsx index bd9bfe078a..98e0af9671 100644 --- a/client/src/components/Chat/Subagents/SubagentActivity.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentActivity.test.tsx @@ -270,7 +270,7 @@ describe('SubagentActivity', () => { expect(screen.getByTestId('tool-approval')).toBeInTheDocument(); }); - it('marks bounded activity as shortened without expanding tool details', () => { + it('marks bounded tool details as shortened without claiming history is missing', () => { render( { ); expect(screen.queryByText('bounded input')).not.toBeInTheDocument(); - expect(screen.getByText('com_ui_subagent_thread_history_truncated')).toBeInTheDocument(); + expect(screen.getByText('com_ui_subagent_activity_details_truncated')).toBeInTheDocument(); + expect(screen.queryByText('com_ui_subagent_thread_history_truncated')).not.toBeInTheDocument(); }); it('renders writing, reasoning, grouped tools, and collapsed details', () => { diff --git a/client/src/components/Chat/Subagents/SubagentActivity.tsx b/client/src/components/Chat/Subagents/SubagentActivity.tsx index b3b552a18a..832565fffd 100644 --- a/client/src/components/Chat/Subagents/SubagentActivity.tsx +++ b/client/src/components/Chat/Subagents/SubagentActivity.tsx @@ -278,34 +278,39 @@ function SubagentPrompt({ prompt }: { prompt: string }) { ); } -export default function SubagentActivity({ +export const hasTruncatedActivityDetails = (activity: ChildActivity): boolean => + activity.items.some( + (item) => + (item.type === 'writing' && item.textTruncated === true) || + (item.type === 'activity_label' && item.labelTruncated === true) || + (item.type === 'tool' && (item.inputTruncated === true || item.outputTruncated === true)), + ); + +export function SubagentActivityContent({ activity, activityId, state = 'ready', - embedded = false, + showPrompt = true, + showDetailTruncationNotice = true, + conversationId = null, onCancelControl, }: { activity: ChildActivity; activityId?: string; state?: 'ready' | 'loading' | 'error'; - embedded?: boolean; + showPrompt?: boolean; + showDetailTruncationNotice?: boolean; + conversationId?: string | null; onCancelControl?: (controlId: string) => void; }) { const localize = useLocalize(); const isSubmitting = activity.status === 'running' || activity.status === 'dispatched'; - const StatusIcon = subagentStatusIcon(activity.status); const reasoningMarkerLabel = localize('com_ui_subagent_ticker_reasoning'); const parts = useMemo( () => activity.items.map((item) => toContentPart(item, reasoningMarkerLabel)), [activity.items, reasoningMarkerLabel], ); - const activityTruncated = - activity.activityTruncated === true || - activity.items.some( - (item) => - (item.type === 'writing' && item.textTruncated === true) || - (item.type === 'tool' && (item.inputTruncated === true || item.outputTruncated === true)), - ); + const activityDetailsTruncated = hasTruncatedActivityDetails(activity); let body: React.ReactNode; if (state === 'loading') { @@ -331,7 +336,7 @@ export default function SubagentActivity({ -
- - {localize(subagentStatusLabelKey(activity.status))} -
- - ); - const content = ( + return (
- {activity.prompt != null && } + {showPrompt && activity.prompt != null && } )} - {activityTruncated && ( + {activity.activityTruncated === true && (
{localize('com_ui_subagent_thread_history_truncated')}
)} + {showDetailTruncationNotice && activityDetailsTruncated && ( +
+ {localize('com_ui_subagent_activity_details_truncated')} +
+ )} {body}
); +} + +export function SubagentStatus({ activity }: { activity: ChildActivity }) { + const localize = useLocalize(); + const StatusIcon = subagentStatusIcon(activity.status); + return ( +
+ + {localize(subagentStatusLabelKey(activity.status))} +
+ ); +} + +export default function SubagentActivity({ + activity, + activityId, + state = 'ready', + embedded = false, + showPrompt = true, + onCancelControl, +}: { + activity: ChildActivity; + activityId?: string; + state?: 'ready' | 'loading' | 'error'; + embedded?: boolean; + showPrompt?: boolean; + onCancelControl?: (controlId: string) => void; +}) { + const statusHeader = ( +
+ +
+ ); + const content = ( + + ); if (embedded) { return ( diff --git a/client/src/components/Chat/Subagents/SubagentConversation.test.tsx b/client/src/components/Chat/Subagents/SubagentConversation.test.tsx new file mode 100644 index 0000000000..c513f91c54 --- /dev/null +++ b/client/src/components/Chat/Subagents/SubagentConversation.test.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { render, screen } from '@testing-library/react'; +import type { ChildConversationTurn } from './adapters'; +import SubagentConversation from './SubagentConversation'; + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +jest.mock('~/Providers', () => ({ + useAgentsMapContext: () => undefined, +})); + +jest.mock('~/components/Chat/Messages/Content/ContentParts', () => ({ + __esModule: true, + default: ({ + content, + messageId, + }: { + content: Array>; + messageId: string; + }) => ( +
+ {content.map((part, index) => ( + + {(part.text as string | undefined) ?? + (part.think as string | undefined) ?? + (part.tool_call as { name?: string } | undefined)?.name ?? + ''} + + ))} +
+ ), +})); + +jest.mock('~/components/Chat/Messages/Content/Container', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +jest.mock('~/components/Chat/Messages/Content/Parts', () => ({ + EmptyText: () =>
, +})); + +jest.mock('lucide-react', () => ({ + AlertCircle: () => null, + Bot: () => null, + CheckCircle2: () => null, + Clock3: () => null, + CornerDownRight: () => null, + Radio: () => null, + XCircle: () => null, +})); + +const turns: ChildConversationTurn[] = [ + { + taskId: 'task-1', + trigger: { + kind: 'parent_dispatch', + summary: 'Investigate the release.', + createdAt: '2026-08-25T12:00:00.000Z', + }, + activity: { + title: 'Research child', + status: 'completed', + items: [ + { type: 'reasoning', text: 'Checked the constraints.' }, + { + type: 'tool', + toolCallId: 'search-1', + name: 'search', + status: 'completed', + outputTruncated: true, + }, + { type: 'writing', text: 'The release is ready.' }, + ], + }, + }, + { + taskId: 'task-2', + trigger: { + kind: 'external_event', + summary: 'A deployment event arrived.', + }, + activity: { + title: 'Research child', + status: 'running', + items: [], + }, + }, +]; + +describe('SubagentConversation', () => { + it('renders host triggers and child activity through the main chat row and content modules', () => { + const { container } = render( + + + , + ); + + expect(screen.getAllByText('com_ui_subagent_trigger_parent_dispatch')).toHaveLength(2); + expect(screen.getAllByText('com_ui_subagent_trigger_external_event')).toHaveLength(2); + expect(screen.getByText('Investigate the release.')).toBeInTheDocument(); + expect(screen.getByText('Checked the constraints.')).toBeInTheDocument(); + expect(screen.getByText('search')).toBeInTheDocument(); + expect(screen.getByText('The release is ready.')).toBeInTheDocument(); + expect(screen.queryByText('com_ui_subagent_thread_status_completed')).not.toBeInTheDocument(); + expect(screen.getByText('com_ui_subagent_thread_status_running')).toBeInTheDocument(); + expect(screen.getByTestId('thinking-cursor')).toBeInTheDocument(); + expect(container.querySelectorAll('.message-render')).toHaveLength(4); + expect(container.querySelectorAll('.user-turn')).toHaveLength(2); + expect(container.querySelectorAll('.agent-turn')).toHaveLength(2); + expect(container.querySelector('[data-subagent-conversation]')).toBeInTheDocument(); + expect(screen.queryByText('com_ui_prompt')).not.toBeInTheDocument(); + expect(screen.getAllByText('com_ui_subagent_activity_details_truncated')).toHaveLength(1); + }); +}); diff --git a/client/src/components/Chat/Subagents/SubagentConversation.tsx b/client/src/components/Chat/Subagents/SubagentConversation.tsx new file mode 100644 index 0000000000..ae2e61914e --- /dev/null +++ b/client/src/components/Chat/Subagents/SubagentConversation.tsx @@ -0,0 +1,195 @@ +import { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; +import { Bot, CornerDownRight, Radio } from 'lucide-react'; +import { ContentTypes, EModelEndpoint } from 'librechat-data-provider'; +import type { TMessageContentParts } from 'librechat-data-provider'; +import type { ChildConversationTurn } from './adapters'; +import type { TranslationKeys } from '~/hooks'; +import { + hasTruncatedActivityDetails, + SubagentActivityContent, + SubagentStatus, +} from './SubagentActivity'; +import ContentParts from '~/components/Chat/Messages/Content/ContentParts'; +import MessageRow from '~/components/Chat/Messages/ui/MessageRow'; +import MessageIcon from '~/components/Chat/Messages/MessageIcon'; +import { useAgentsMapContext } from '~/Providers'; +import { useLocalize } from '~/hooks'; +import store from '~/store'; + +const TRIGGER_LABELS = { + parent_dispatch: 'com_ui_subagent_trigger_parent_dispatch', + parent_continuation: 'com_ui_subagent_trigger_parent_continuation', + external_event: 'com_ui_subagent_trigger_external_event', +} as const satisfies Record; + +function TriggerIcon({ kind }: { kind: ChildConversationTurn['trigger']['kind'] }) { + const Icon = kind === 'external_event' ? Radio : CornerDownRight; + return ( + + + + ); +} + +function TriggerMessage({ turn, fullWidth }: { turn: ChildConversationTurn; fullWidth: boolean }) { + const localize = useLocalize(); + const label = localize(TRIGGER_LABELS[turn.trigger.kind]); + const content = useMemo( + () => + turn.trigger.summary === '' + ? [] + : [ + { + type: ContentTypes.TEXT, + text: turn.trigger.summary, + } as TMessageContentParts, + ], + [turn.trigger.summary], + ); + return ( + } + label={label} + footer={null} + timestamp={turn.trigger.createdAt} + ariaLabel={label} + headerPrefix="" + isCreatedByUser={true} + fullWidth={fullWidth} + > +
+ + {label} +
+ {content.length > 0 && ( + + )} + {turn.trigger.summaryTruncated === true && ( +
+ {localize('com_ui_subagent_trigger_truncated')} +
+ )} +
+ ); +} + +function ChildMessage({ + turn, + state, + agentId, + conversationId, + fullWidth, + onCancelControl, +}: { + turn: ChildConversationTurn; + state: 'ready' | 'loading' | 'error'; + agentId?: string; + conversationId?: string | null; + fullWidth: boolean; + onCancelControl?: (controlId: string) => void; +}) { + const agentsMap = useAgentsMapContext(); + const agent = agentId == null ? undefined : agentsMap?.[agentId]; + const label = agent?.name ?? turn.activity.title; + const iconData = { + endpoint: EModelEndpoint.agents, + modelLabel: label, + isCreatedByUser: false, + }; + return ( + + + + ) : ( + + ) + } + label={label} + footer={ + turn.activity.status === 'completed' ? null : + } + ariaLabel={label} + headerPrefix="" + isCreatedByUser={false} + fullWidth={fullWidth} + > + + + ); +} + +export default function SubagentConversation({ + turns, + agentId, + conversationId, + stateByTask, + controllableTaskId, + onCancelControl, +}: { + turns: ChildConversationTurn[]; + agentId?: string; + conversationId?: string | null; + stateByTask?: ReadonlyMap; + controllableTaskId?: string; + onCancelControl?: (taskId: string, controlId: string) => void; +}) { + const localize = useLocalize(); + const fullWidth = useRecoilValue(store.maximizeChatSpace); + const hasShortenedDetails = turns.some((turn) => hasTruncatedActivityDetails(turn.activity)); + return ( +
+ {hasShortenedDetails && ( +
+ {localize('com_ui_subagent_activity_details_truncated')} +
+ )} + {turns.map((turn) => ( +
+
+ +
+
+ onCancelControl(turn.taskId, controlId) + } + /> +
+
+ ))} +
+ ); +} diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx index 4e429803fc..b2a4459fc3 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx @@ -146,6 +146,35 @@ jest.mock('./SubagentActivity', () => ({ ), })); +jest.mock('./SubagentConversation', () => ({ + __esModule: true, + default: ({ + turns, + stateByTask, + }: { + turns: Array<{ + taskId: string; + trigger: { summary: string }; + activity: { items: Array<{ text?: string }> }; + }>; + stateByTask?: ReadonlyMap; + }) => ( +
+ {turns.map((turn) => ( +
+ {turn.trigger.summary} + {turn.activity.items.map((item, index) => ( + {item.text} + ))} +
+ ))} +
+ ), +})); + jest.mock('@librechat/client', () => { const mockReact = jest.requireActual('react'); const MockSelectContext = mockReact.createContext((_value: string): void => {}); @@ -196,6 +225,7 @@ jest.mock('lucide-react', () => ({ AlertCircle: () => null, Bot: () => null, CornerDownRight: () => null, + CornerUpLeft: () => null, CheckCircle2: () => null, Clock3: () => null, ListEnd: () => null, @@ -225,6 +255,7 @@ const completedView: SubagentThreadView = { parentToolCallId: 'tool-call', subagentType: 'researcher', subagentKind: 'agent', + depth: 1, agentId: 'agent-1', title: 'Research child', status: 'completed', @@ -297,6 +328,7 @@ describe('SubagentThreadPanel', () => { ); expect(mockUseSubagentActivityStream).toHaveBeenCalledWith(selection, false); expect(screen.getByText('Research child')).toBeInTheDocument(); + expect(screen.getByText('com_ui_subagent_depth')).toBeInTheDocument(); expect(screen.getByText('Investigate the release.')).toBeInTheDocument(); expect(screen.getByText('The release is ready.')).toBeInTheDocument(); expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-status', 'completed'); @@ -312,6 +344,111 @@ describe('SubagentThreadPanel', () => { ); }); + it('returns to and restores focus on the originating parent activity', async () => { + mockUseSubagentThreadQuery.mockReturnValue({ + data: completedView, + isLoading: false, + isError: false, + isReadinessPending: false, + }); + let active: ActiveSubagentPanel | null = selection; + const Observer = () => { + active = useRecoilValue(activeSubagentPanel); + return null; + }; + const { container } = render( + set(activeSubagentPanel, selection)}> +
); } + const conversationStateByTask = useMemo( + () => new Map([[taskId || conversationTurns[0]?.taskId || '', panelState] as const]), + [conversationTurns, panelState, taskId], + ); + + let activityPanel: ReactNode; + if (hasConversationProjection) { + activityPanel = ( + + {data?.historyTruncated === true && ( +
+ {localize('com_ui_subagent_thread_history_truncated')} +
+ )} + + submitControl('cancel_message', controlId) + } + /> +
+ ); + } else if (selection.event != null && (eventSummary?.tasks.length ?? 0) > 1) { + activityPanel = ( + +
+ {timelinePrefix} + {visibleEventTasks.map(renderEventTask)} +
+
+ ); + } else { + activityPanel = ( + submitControl('cancel_message', controlId) + : undefined + } + /> + ); + } return (