From 749eed0d60ac996fcee42331d5799f924318cd87 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 21 Aug 2026 19:51:11 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=9F=20feat:=20Unify=20Subagent=20Activ?= =?UTF-8?q?ity=20Panel=20(#15106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: unify subagent activity panel * fix: fence durable activity to selected task * fix: preserve exact panel activity semantics * fix: scope panel identity to parent turn * fix: keep detached readiness status neutral * fix: harden subagent activity invariants * test: support backend TypeScript target * fix: preserve subagent invocation identity * fix: bound subagent activity correlation * fix: drain exact-parent subagent updates --- .../Chat/Messages/Content/ApprovalContext.tsx | 2 +- .../Messages/Content/Parts/SubagentCall.tsx | 546 ++--------- .../Parts/__tests__/SubagentCall.test.tsx | 914 ++++-------------- .../src/components/Chat/Presentation.test.tsx | 10 +- .../Chat/Subagents/SubagentActivity.test.tsx | 229 +++++ .../Chat/Subagents/SubagentActivity.tsx | 357 +++++++ .../Subagents/SubagentThreadPanel.test.tsx | 198 +++- .../Chat/Subagents/SubagentThreadPanel.tsx | 201 ++-- .../Chat/Subagents/adapters.test.ts | 173 ++++ .../src/components/Chat/Subagents/adapters.ts | 177 ++++ .../data-provider/Subagents/queries.test.ts | 26 +- client/src/data-provider/Subagents/queries.ts | 23 +- .../SSE/__tests__/useStepHandler.spec.ts | 165 +++- client/src/hooks/SSE/useStepHandler.ts | 130 ++- client/src/locales/en/translation.json | 5 - client/src/store/subagents.ts | 37 +- packages/api/src/agents/activity.spec.ts | 262 +++++ packages/api/src/agents/activity.ts | 317 ++++++ packages/api/src/agents/view.spec.ts | 145 ++- packages/api/src/agents/view.ts | 48 +- packages/data-provider/src/api-endpoints.ts | 6 +- packages/data-provider/src/data-service.ts | 3 +- packages/data-provider/src/types/subagents.ts | 28 + .../data-schemas/src/methods/message.spec.ts | 38 + packages/data-schemas/src/methods/message.ts | 21 + 25 files changed, 2668 insertions(+), 1393 deletions(-) create mode 100644 client/src/components/Chat/Subagents/SubagentActivity.test.tsx create mode 100644 client/src/components/Chat/Subagents/SubagentActivity.tsx create mode 100644 client/src/components/Chat/Subagents/adapters.test.ts create mode 100644 client/src/components/Chat/Subagents/adapters.ts create mode 100644 packages/api/src/agents/activity.spec.ts create mode 100644 packages/api/src/agents/activity.ts diff --git a/client/src/components/Chat/Messages/Content/ApprovalContext.tsx b/client/src/components/Chat/Messages/Content/ApprovalContext.tsx index bb61c75a18..6c0e2ee8ed 100644 --- a/client/src/components/Chat/Messages/Content/ApprovalContext.tsx +++ b/client/src/components/Chat/Messages/Content/ApprovalContext.tsx @@ -303,7 +303,7 @@ export default function ApprovalProvider({ children }: { children: React.ReactNo * * Reads `ChatContext` / the agent store / React Query. The cards render it from * live chat views but ALSO from contexts without a `ChatContext.Provider` (e.g. a - * subagent tool paused inside a portaled dialog, or a search/citation render that + * subagent tool paused inside an isolated activity surface, or a search/citation render that * passes chat context as a prop), so it reads the context non-throwingly: with no * conversation, `buildResumeFields` returns null and the controls are inert rather * than crashing. diff --git a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx index b399e89ff4..d5a05cf38c 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx @@ -1,39 +1,19 @@ -import { - useCallback, - useContext, - useEffect, - useId, - useMemo, - useReducer, - useRef, - useState, -} from 'react'; -import { ContentTypes, EModelEndpoint } from 'librechat-data-provider'; +import { useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react'; +import { ChevronRight, Users } from 'lucide-react'; +import { EModelEndpoint } from 'librechat-data-provider'; import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil'; -import { ArrowDown, ChevronRight, Maximize2, Minimize2, Users } from 'lucide-react'; -import { - Button, - OGDialog, - OGDialogTitle, - OGDialogContent, - OGDialogDescription, -} from '@librechat/client'; import type { - Agents, + PartMetadata, TAttachment, TMessage, TMessageContentParts, - PartMetadata, } from 'librechat-data-provider'; -import type { PartWithIndex } from '~/components/Chat/Messages/Content/ParallelContent'; import type { SubagentTickerLine } from '~/utils/subagentContent'; -import store, { activeSubagentPanel, subagentProgressByToolCallId } from '~/store'; -import ToolCallGroup from '~/components/Chat/Messages/Content/ToolCallGroup'; -import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite'; -import ToolApproval from '~/components/Chat/Messages/Content/ToolApproval'; -import { cn, groupSequentialToolCalls, parseToolName } from '~/utils'; -import Container from '~/components/Chat/Messages/Content/Container'; -import ToolCall from '~/components/Chat/Messages/Content/ToolCall'; +import store, { + activeSubagentPanel, + subagentProgressByToolCallId, + subagentProgressKey, +} from '~/store'; import { MessageContext } from '~/Providers/MessageContext'; import MessageIcon from '~/components/Share/MessageIcon'; import { parseSubagentBackgroundHandle } from './handle'; @@ -41,9 +21,8 @@ import { useAgentsMapContext } from '~/Providers'; import { useMCPServerNames } from '~/hooks/MCP'; import { AttachmentGroup } from './Attachment'; import { useToolCallIntent } from './intent'; +import { cn, parseToolName } from '~/utils'; import { useLocalize } from '~/hooks'; -import Reasoning from './Reasoning'; -import Text from './Text'; interface SubagentCallProps { toolCallId: string; @@ -78,11 +57,6 @@ export const SUBAGENT_TICKER_THROTTLE_MS = 400; * tokens appear right away, and throttling only kicks in once the * preview is long enough to "fill the container". */ const TICKER_PASSTHROUGH_CHARS = 120; -/** Distance from the dialog scroller's bottom that still counts as - * "following along". Inside this window new content auto-scrolls; past - * it we pause so the user can read. Slightly looser than the main - * messages view since the dialog is a smaller scroller. */ -const DIALOG_AT_BOTTOM_THRESHOLD_PX = 120; /** * Trailing-edge throttle. Forwards `value` at most once per `intervalMs` @@ -172,13 +146,13 @@ function useThrottledValue(value: T, intervalMs: number, enabled: boolean): T * doing right now" ticker. The collapsed view shows short, user-readable * status lines — streaming text/reasoning previews plus tool-call lifecycle * markers — built from the `SubagentUpdateEvent` stream. Clicking opens a - * dialog that renders the child's aggregated content parts through the same - * `` pipeline the main conversation uses, so tool calls, reasoning - * blocks, and the final response all look like a regular assistant message. + * artifacts-style panel that renders the child's aggregated activity through + * the shared child-activity module, so every subagent mode uses one deep view. * * Progress is sourced from the `subagentProgressByToolCallId` Recoil atom * family, populated by `useStepHandler` as `ON_SUBAGENT_UPDATE` SSE - * envelopes arrive. The atom is keyed by the parent's `tool_call_id`. + * envelopes arrive. The atom is keyed by the parent message and + * `tool_call_id`, since providers may reuse tool IDs across turns. */ export default function SubagentCall({ toolCallId, @@ -193,13 +167,15 @@ export default function SubagentCall({ }: SubagentCallProps) { const localize = useLocalize(); const parentMessageContext = useContext(MessageContext); - const progress = useRecoilValue(subagentProgressByToolCallId(toolCallId)); + const parentMessageId = parentMessageContext.messageId?.trim() ?? ''; + const partIndex = parentMessageContext.partIndex ?? 0; + const progress = useRecoilValue( + subagentProgressByToolCallId(subagentProgressKey(parentMessageId, toolCallId, partIndex)), + ); const setSelectedSubagent = useSetRecoilState(activeSubagentPanel); const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility); const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId); const agentsMap = useAgentsMapContext(); - const [open, setOpen] = useState(false); - const [promptExpanded, setPromptExpanded] = useState(false); const backgroundHandle = useMemo( () => parseSubagentBackgroundHandle(output, args), [output, args], @@ -250,29 +226,6 @@ export default function SubagentCall({ const running = !finished && !cancelled; const detachedStatusUnknown = backgroundHandle != null && progress == null && !isSubmitting; - /** - * Content parts for the dialog. Preference order: - * - * 1. **Persisted** `subagent_content` on the parent `tool_call` - * when available. Written by the backend at message-save time - * and refreshed on sync / reconnect — the canonical record of - * the run. After a disconnect the client's live atom may have - * missed events, so trusting `persistedContent` prevents the - * dialog from showing a stale/partial view of a completed - * subagent. - * 2. **Live atom** incrementally built by `foldSubagentEvent` as - * each `ON_SUBAGENT_UPDATE` arrives. Used while the subagent - * is mid-run (before the parent message saves, the persisted - * snapshot is empty) and as a fallback for older runs recorded - * before the persistence path landed. - */ - const liveParts = progress?.contentParts as TMessageContentParts[] | undefined; - const contentParts = useMemo(() => { - if (persistedContent && persistedContent.length > 0) return persistedContent; - if (liveParts && liveParts.length > 0) return liveParts; - return []; - }, [liveParts, persistedContent]); - /** Last `TICKER_MAX_LINES` lines from the atom's incrementally-built * ticker state, so history isn't lost to any event trimming. */ const tickerLines = useMemo(() => { @@ -324,215 +277,60 @@ export default function SubagentCall({ * the name isn't resolvable (agent map miss). */ const subagentNameLabel = !isSelfSpawn && subagentAgent?.name ? subagentAgent.name : ''; - /** - * Minimal `MessageContext` for the dialog's `` tree. Subagent - * content rendering needs the same context the main conversation uses - * (reasoning expand state, latest-message cursor, etc.) — synthesizing - * a scoped context lets us reuse the real part renderers without - * pulling the full `ChatView` / `MessagesView` tree into the dialog. - */ - const dialogMessageContext = useMemo( + const panelSelection = useMemo( () => ({ - messageId: `subagent-${toolCallId}`, - isExpanded: true, - isSubmitting: running, - isLatestMessage: running, - conversationId: null, + parentConversationId, + parentMessageId, + toolCallId, + partIndex, + subagentType, + ...(prompt == null ? {} : { prompt }), + ...(backgroundHandle == null ? { legacyOutput: output } : {}), + ...(persistedContent == null ? {} : { persistedContent }), + initialProgress, + isSubmitting, + ...(runStepStatus == null ? {} : { runStepStatus }), + ...(backgroundHandle != null && canOpenDurablePanel + ? { + durable: { + threadId: backgroundHandle.subagent_thread_id, + taskId: backgroundHandle.background_task_id, + }, + } + : {}), }), - [toolCallId, running], + [ + backgroundHandle, + canOpenDurablePanel, + initialProgress, + isSubmitting, + output, + parentConversationId, + parentMessageId, + partIndex, + persistedContent, + prompt, + runStepStatus, + subagentType, + toolCallId, + ], ); - const lastPartIndex = contentParts.length - 1; - - /** - * Dialog renderer used by {@link ToolCallGroup} (for grouped tool_call - * batches) and by the per-part map (for single parts). Mirrors the - * main `` dispatch table but stays scoped to the three types - * a subagent run emits — avoiding the import cycle that would come - * from routing through `Parts/index`. - */ - const renderDialogPart = useCallback( - ( - part: TMessageContentParts, - idx: number, - isLastPart: boolean, - onToolExpand?: () => void, - ): JSX.Element | null => { - return ( - - ); - }, - [toolCallId, running], - ); - - /** - * Apply the same consecutive-tool-call batching the main `ContentParts` - * uses so the dialog renders with visual parity: grouped tools collapse - * into a single `Used N tools` header, single parts wrap in `Container` - * for the same `gap-3` flex column spacing the main conversation has. - */ - const groupedParts = useMemo(() => { - const withIdx: PartWithIndex[] = contentParts.map((part, idx) => ({ part, idx })); - return groupSequentialToolCalls(withIdx); - }, [contentParts]); - - /** - * Auto-scroll the dialog's content area as new parts / delta chunks - * stream in. Same pattern as `MessagesView` but with a dialog-tuned - * threshold — the user can scroll up to read back without auto-scroll - * snatching control. Explicit "jump to bottom" button lets them resume - * following along without having to scroll all the way down. - */ - const scrollRef = useRef(null); - const contentRef = useRef(null); - const [isAtBottom, setIsAtBottom] = useState(true); - - /** React `onScroll` prop instead of manual `addEventListener` so the - * handler attaches as part of DOM commit — no race with Radix's - * portal-mount timing that would leave `scrollRef.current` null when - * the effect runs and silently skip the listener. */ - const handleScroll = useCallback((event: React.UIEvent) => { - const el = event.currentTarget; - const distance = el.scrollHeight - el.scrollTop - el.clientHeight; - setIsAtBottom(distance <= DIALOG_AT_BOTTOM_THRESHOLD_PX); - }, []); - - /** Reset to the compact prompt preview every time the dialog closes. */ useEffect(() => { - if (open) return; - setPromptExpanded(false); - }, [open]); - - /** Start at the top every time the dialog opens so the prompt reads as the - * first item in the scrollable trace instead of a fixed header. */ - useEffect(() => { - if (!open) return; - const el = scrollRef.current; - if (!el) return; - el.scrollTop = 0; - const distance = el.scrollHeight - el.clientHeight; - setIsAtBottom(distance <= DIALOG_AT_BOTTOM_THRESHOLD_PX); - }, [open]); - - /** Keep the view pinned to the bottom while the user is at/near it — - * including during delta streams that grow the last TEXT/THINK part - * without changing `contentParts.length`. A `ResizeObserver` on the - * inner content div catches every height change, whether structural - * (new tool call) or incremental (writing text grows in-place), so - * auto-scroll doesn't desync just because tokens are piling into an - * existing part. */ - useEffect(() => { - if (!open) return; - const scrollEl = scrollRef.current; - const contentEl = contentRef.current; - if (!scrollEl || !contentEl) return; - if (typeof ResizeObserver === 'undefined') return; - const observer = new ResizeObserver(() => { - if (!isAtBottom) return; - scrollEl.scrollTop = scrollEl.scrollHeight; - }); - observer.observe(contentEl); - return () => observer.disconnect(); - }, [open, isAtBottom]); - - const scrollDialogToBottom = useCallback(() => { - const el = scrollRef.current; - if (!el) return; - el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); - setIsAtBottom(true); - }, []); + setSelectedSubagent((current) => + current?.parentMessageId === parentMessageId && + current.toolCallId === toolCallId && + current.partIndex === partIndex + ? panelSelection + : current, + ); + }, [panelSelection, parentMessageId, partIndex, setSelectedSubagent, toolCallId]); const openDetails = useCallback(() => { - if (backgroundHandle != null && canOpenDurablePanel) { - resetCurrentArtifactId(); - setArtifactsVisible(false); - setSelectedSubagent({ - parentConversationId, - threadId: backgroundHandle.subagent_thread_id, - taskId: backgroundHandle.background_task_id, - toolCallId, - subagentType: backgroundHandle.subagent_type, - }); - return; - } - setOpen(true); - }, [ - backgroundHandle, - canOpenDurablePanel, - parentConversationId, - resetCurrentArtifactId, - setArtifactsVisible, - setSelectedSubagent, - toolCallId, - ]); - - const renderDialogBody = () => { - if (contentParts.length > 0) { - return ( - - {groupedParts.map((group) => { - if (group.type === 'single') { - const { part, idx } = group.part; - /** Per-type dispatch handles wrapping: TEXT goes - * through `Container`, THINK/TOOL_CALL render - * directly so their own wrappers set the width - * and spacing. */ - return renderDialogPart(part, idx, idx === lastPartIndex); - } - /** Consecutive tool_calls (2+) collapse into a - * `Used N tools` group — same behavior as the main - * message view. */ - return ( - p.idx === lastPartIndex)} - renderPart={renderDialogPart} - lastContentIdx={lastPartIndex} - /> - ); - })} - - ); - } - if (output && backgroundHandle == null) { - /** Fallback: no aggregated content parts but the backend - * wrote a final tool_call output. Happens for older - * subagent runs recorded before the event forwarder - * existed. Route through the same leaf renderer so - * markdown renders properly. */ - return ( - - - - ); - } - return ( -
- {running - ? localize('com_ui_subagent_no_result_yet') - : localize('com_ui_subagent_empty_result')} -
- ); - }; + resetCurrentArtifactId(); + setArtifactsVisible(false); + setSelectedSubagent(panelSelection); + }, [panelSelection, resetCurrentArtifactId, setArtifactsVisible, setSelectedSubagent]); return ( <> @@ -542,7 +340,9 @@ export default function SubagentCall({ data-subagent-thread={ canOpenDurablePanel ? backgroundHandle?.subagent_thread_id : undefined } - data-subagent-tool-call={canOpenDurablePanel ? toolCallId : undefined} + data-subagent-tool-call={toolCallId} + data-subagent-parent-message={parentMessageId} + data-subagent-part-index={partIndex} className={cn( 'group my-1.5 flex w-full flex-col gap-1 rounded-lg border border-border-light bg-surface-secondary px-3 py-2 text-left transition hover:bg-surface-tertiary', running && !detachedStatusUnknown && 'animate-pulse-slow', @@ -601,73 +401,6 @@ export default function SubagentCall({ - {!canOpenDurablePanel && ( - - -
-
- - {isSelfSpawn - ? localize('com_ui_subagent_dialog_title_self') - : localize('com_ui_subagent_dialog_title', { 0: subagentType })} - -
- - {localize('com_ui_subagent_dialog_description')} - -
- -
- {!isAtBottom && ( - - )} -
-
- {prompt ? ( - setPromptExpanded((expanded) => !expanded)} - /> - ) : null} - {renderDialogBody()} -
-
-
-
-
- )} - {!hideAttachments && attachments && attachments.length > 0 && ( )} @@ -705,68 +438,6 @@ function tryPrompt(args: string): string | undefined { } } -function SubagentPrompt({ - prompt, - expanded, - onToggle, -}: { - prompt: string; - expanded: boolean; - onToggle: () => void; -}): JSX.Element { - const localize = useLocalize(); - const headingId = useId(); - const contentId = useId(); - const toggleLabel = expanded ? localize('com_ui_collapse') : localize('com_ui_expand'); - - return ( -
-
-

- {localize('com_ui_prompt')} -

- -
-
-
- -
- {!expanded && ( - -
- ); -} - /** Stable key for a ticker line — helps React reuse the DOM node across * in-place updates to the same live `writing` / `reasoning` line, and * gives tool-call lines a stable identity by tool name. */ @@ -902,82 +573,3 @@ function TickerLineView({ line }: { line: SubagentTickerLine }): JSX.Element { ); } - -/** - * Per-part renderer for the dialog. Mirrors the wrapper choices `` - * makes in regular messages so subagent content matches the visual width - * and spacing the user already knows: TEXT wraps in `Container` (which - * provides `gap-3` column spacing and the `mt-5` sibling margin), while - * THINK and TOOL_CALL render bare — their own wrappers (`Reasoning`'s - * `mb-2 pb-2 pt-2` box, `ToolCall`'s own margins) control their layout - * and full-column width. Staying inline (vs. calling ``) avoids - * the `Parts/index.ts → SubagentCall → Part` import cycle and keeps us - * from accidentally rendering a nested subagent dialog. - */ -function SubagentDialogPart({ - part, - isSubmitting, - showCursor, - isLast, - onToolExpand, -}: { - part: TMessageContentParts; - isSubmitting: boolean; - showCursor: boolean; - isLast: boolean; - onToolExpand?: () => void; -}): JSX.Element | null { - if (part.type === ContentTypes.TEXT) { - const text = (part as { text: string }).text; - return ( - - - - ); - } - if (part.type === ContentTypes.THINK) { - const think = (part as { think: string }).think; - return ; - } - if (part.type === ContentTypes.TOOL_CALL) { - const tc = ( - part as { - [ContentTypes.TOOL_CALL]?: { - id?: string; - args?: string | Record; - output?: string; - name?: string; - progress?: number; - approval?: Agents.ToolCall['approval']; - }; - } - )[ContentTypes.TOOL_CALL]; - if (!tc) return null; - const toolCall = ( - - ); - // Surface approve/reject/edit controls for a tool paused INSIDE this subagent — - // its tool_call lives in subagent_content, not as a top-level message part, so the - // top-level Part.tsx render never sees it. Only while unresolved (no output yet). - // The dialog portals but React context still flows, so ToolApproval resolves here. - if (tc.approval != null && (tc.output?.length ?? 0) === 0) { - return ( - <> - {toolCall} - - - ); - } - return toolCall; - } - return null; -} diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx index 6d21e11899..e4263f9f42 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { RecoilRoot, useRecoilCallback, useRecoilValue } from 'recoil'; -import { render, screen, act, fireEvent, waitFor, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import type { SubagentUpdateEvent } from 'librechat-data-provider'; import type { + SubagentAggregatorState, SubagentContentPart, SubagentTickerState, - SubagentAggregatorState, } from '~/utils/subagentContent'; import type { ActiveSubagentPanel, SubagentProgress } from '~/store/subagents'; import { @@ -15,22 +15,18 @@ import { initSubagentAggregatorState, initSubagentTickerState, } from '~/utils/subagentContent'; -import { activeSubagentPanel, subagentProgressByToolCallId } from '~/store/subagents'; +import { + activeSubagentPanel, + subagentProgressByToolCallId, + subagentProgressKey, +} from '~/store/subagents'; import SubagentCall, { SUBAGENT_TICKER_THROTTLE_MS } from '../SubagentCall'; import { MessageContext } from '~/Providers/MessageContext'; -const mockNavigateToConvo = jest.fn(); - -jest.mock('librechat-data-provider/react-query', () => ({ - useGetConversationByIdQuery: (id: string) => ({ data: { conversationId: id } }), -})); - jest.mock('~/hooks', () => ({ useLocalize: () => - (key: string, values?: Record): string => { - const arg0 = (values?.[0] as string | undefined) ?? ''; - const arg1 = (values?.[1] as string | undefined) ?? ''; + (key: string): string => { const translations: Record = { com_ui_subagent_running: 'Running agent', com_ui_subagent_activity: 'Agent activity', @@ -38,53 +34,14 @@ jest.mock('~/hooks', () => ({ com_ui_subagent_cancelled: 'Cancelled agent', com_ui_subagent_errored: 'Agent errored', com_ui_subagent_waiting: 'Waiting for first update…', - com_ui_subagent_dialog_title: `"${arg0}" agent`, - com_ui_subagent_dialog_title_self: 'Agent', - com_ui_subagent_dialog_description: 'Isolated child run.', - com_ui_subagent_no_result_yet: 'No result yet.', - com_ui_subagent_empty_result: 'No text.', - com_ui_subagent_open_thread: 'Open child chat', - com_ui_collapse: 'Collapse', - com_ui_expand: 'Expand', com_ui_subagent_ticker_writing: 'Writing', com_ui_subagent_ticker_reasoning: 'Reasoning', com_ui_subagent_ticker_error: 'Error', com_ui_subagent_ticker_using: 'Using', com_ui_subagent_ticker_tool_done: 'done', - com_ui_subagent_ticker_tool_output: `${arg0} → ${arg1}`, - com_ui_prompt: 'Prompt', }; return translations[key] ?? key; }, - useNavigateToConvo: () => ({ navigateToConvo: mockNavigateToConvo }), -})); - -/** Stub the leaf content-part renderers — the tests only need to confirm - * that the right TMessageContentParts flow through to them. */ -jest.mock('../Text', () => ({ - __esModule: true, - default: ({ text }: { text: string }) =>
{text}
, -})); - -jest.mock('../Reasoning', () => ({ - __esModule: true, - default: ({ reasoning }: { reasoning: string }) => ( -
{reasoning}
- ), -})); - -jest.mock('~/components/Chat/Messages/Content/ToolCall', () => ({ - __esModule: true, - default: ({ name, output }: { name: string; output: string }) => ( -
- {output} -
- ), -})); - -jest.mock('~/components/Chat/Messages/Content/MarkdownLite', () => ({ - __esModule: true, - default: ({ content }: { content: string }) =>
{content}
, })); jest.mock('../Attachment', () => ({ @@ -93,104 +50,24 @@ jest.mock('../Attachment', () => ({ ), })); -jest.mock('@librechat/client', () => ({ - __esModule: true, - Button: ({ - children, - variant: _variant, - size: _size, - ...props - }: React.ComponentProps<'button'> & { variant?: string; size?: string }) => ( - - ), - OGDialog: ({ children }: { children: React.ReactNode }) => <>{children}, - OGDialogContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - OGDialogTitle: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - OGDialogDescription: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})); - jest.mock('lucide-react', () => ({ - // eslint-disable-next-line i18next/no-literal-string - ArrowDown: () => arrow-down, // eslint-disable-next-line i18next/no-literal-string ChevronRight: () => chevron, // eslint-disable-next-line i18next/no-literal-string - Maximize2: () => maximize, - // eslint-disable-next-line i18next/no-literal-string - Minimize2: () => minimize, - // eslint-disable-next-line i18next/no-literal-string Users: () => users, })); -/** Stub out the agents-map provider so the header doesn't look up real - * agent data. Tests don't exercise the avatar lookup path; the default - * (no agent) renders the `Users` SVG fallback. */ -jest.mock('~/Providers', () => ({ - useAgentsMapContext: () => ({}), -})); - -/** Stub `MessageIcon` — only relevant when `useAgentsMapContext` returns - * a matching agent; with the stub above it never renders. */ -jest.mock('~/components/Share/MessageIcon', () => ({ - __esModule: true, - default: ({ agent }: { agent?: { name?: string } }) => ( - {agent?.name ?? ''} - ), -})); - -jest.mock('~/hooks/MCP', () => { - const mcpServerNames: string[] = []; - return { useMCPServerNames: () => mcpServerNames }; -}); - +jest.mock('~/Providers', () => ({ useAgentsMapContext: () => ({}) })); +jest.mock('~/components/Share/MessageIcon', () => ({ __esModule: true, default: () => null })); +jest.mock('~/hooks/MCP', () => ({ useMCPServerNames: () => [] })); jest.mock('~/utils', () => ({ - ...jest.requireActual('~/utils/groupToolCalls'), ...jest.requireActual('~/utils/toolLabels'), cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), logger: { log: jest.fn() }, })); -afterEach(() => { - jest.useRealTimers(); -}); +afterEach(() => jest.useRealTimers()); -/** The dialog wraps single parts in `Container` and grouped tool_calls in - * `ToolCallGroup`. Stub both as transparent wrappers so the tests still - * assert on the leaf renderers (Text/Reasoning/ToolCall) without pulling - * Recoil-backed tool-call batching state into the component tree. */ -jest.mock('~/components/Chat/Messages/Content/Container', () => ({ - __esModule: true, - default: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})); - -jest.mock('~/components/Chat/Messages/Content/ToolCallGroup', () => ({ - __esModule: true, - default: ({ - parts, - renderPart, - lastContentIdx, - }: { - parts: Array<{ part: unknown; idx: number }>; - renderPart: (part: unknown, idx: number, isLast: boolean) => React.ReactNode; - lastContentIdx: number; - }) => ( -
- {parts.map(({ part, idx }) => renderPart(part, idx, idx === lastContentIdx))} -
- ), -})); - -/** Helper: fold an event sequence through the real incremental - * aggregators so each test seeds the atom with the same shape - * `useStepHandler` produces in live state. */ function foldEvents(events: SubagentUpdateEvent[]): { contentParts: SubagentContentPart[]; aggregatorState: SubagentAggregatorState; @@ -199,20 +76,17 @@ function foldEvents(events: SubagentUpdateEvent[]): { let contentParts: SubagentContentPart[] = []; let aggregatorState = initSubagentAggregatorState(); let tickerState = initSubagentTickerState(); - for (const event of events) { + for (const update of events) { ({ parts: contentParts, state: aggregatorState } = foldSubagentEvent( contentParts, aggregatorState, - event, + update, )); - tickerState = foldSubagentEventIntoTicker(tickerState, event); + tickerState = foldSubagentEventIntoTicker(tickerState, update); } return { contentParts, aggregatorState, tickerState }; } -/** Thin wrapper: tests pass `{status, subagentRunId, subagentType, events}` - * and get back a full-shape `SubagentProgress` with all three aggregator - * outputs filled. */ function progressFromEvents( base: { events: SubagentUpdateEvent[] } & Omit< SubagentProgress, @@ -220,436 +94,213 @@ function progressFromEvents( >, ): SubagentProgress { const { events, ...rest } = base; - const aggregates = foldEvents(events); - return { ...rest, ...aggregates }; + return { ...rest, ...foldEvents(events) }; } -/** - * Mount the component inside a RecoilRoot and expose a setter so each test - * can seed the `subagentProgressByToolCallId` atom with the state under test. - * Real Recoil, no mocks of the store — matches the hook-integration test - * style in useStepHandler.spec.ts. - */ function renderWithState(args: { toolCallId: string; initialProgress: number; isSubmitting?: boolean; progress?: SubagentProgress | null; + output?: string; + toolArgs?: Record; }) { const setter = { current: null as null | ((next: SubagentProgress | null) => void) }; + let selection: ActiveSubagentPanel | null = null; const SeedHelper = () => { setter.current = useRecoilCallback( ({ set }) => - (next: SubagentProgress | null) => { - set(subagentProgressByToolCallId(args.toolCallId), next); - }, + (next: SubagentProgress | null) => + set( + subagentProgressByToolCallId(subagentProgressKey('parent-message', args.toolCallId, 0)), + next, + ), [], ); return null; }; - const rendered = render( - - - - , - ); - const setProgress = (next: SubagentProgress | null) => { - act(() => { - setter.current?.(next); - }); + const SelectionObserver = () => { + selection = useRecoilValue(activeSubagentPanel); + return null; }; - setProgress(args.progress ?? null); - return { ...rendered, setProgress }; -} - -/** Open the subagent dialog. Required when another test file in the same Jest - * worker has already loaded the real `@librechat/client` module; Radix only - * mounts dialog content while `open` is true. */ -function openSubagentDialog(headerLabel = 'Ran agent') { - fireEvent.click(screen.getByRole('button', { name: headerLabel })); -} - -describe('SubagentCall — status resolution', () => { - it('renders "Running agent" while streaming and no terminal envelope has arrived', () => { - renderWithState({ - toolCallId: 'call_running', - initialProgress: 0.3, - isSubmitting: true, - progress: progressFromEvents({ - subagentRunId: 'run_a', - subagentType: 'self', - events: [], - status: 'run_step', - }), - }); - expect(screen.getByText('Running agent')).toBeInTheDocument(); - }); - - it('renders "Ran agent" when the subagent emits a `stop` phase', () => { - renderWithState({ - toolCallId: 'call_stopped', - initialProgress: 1, - isSubmitting: true, - progress: progressFromEvents({ - subagentRunId: 'run_a', - subagentType: 'self', - events: [], - status: 'stop', - }), - }); - expect(screen.getByText('Ran agent')).toBeInTheDocument(); - }); - - it('renders "Ran agent" when the tool call progress reaches 1', () => { - renderWithState({ - toolCallId: 'call_done', - initialProgress: 1, - isSubmitting: false, - progress: null, - }); - expect(screen.getByText('Ran agent')).toBeInTheDocument(); - }); - - it('renders "Cancelled agent" when the stream stops before a terminal envelope (Codex P2 regression)', () => { - /** - * Codex P2 on #12725: the old `running` computation ignored whether the - * parent run was still streaming, so a user stop or dropped connection - * would leave the ticker permanently "working…". Mirror the behavior - * of `ToolCall.tsx` — `!isSubmitting && !finished` → cancelled. - */ - renderWithState({ - toolCallId: 'call_cancelled', - initialProgress: 0.4, - isSubmitting: false, - progress: progressFromEvents({ - subagentRunId: 'run_a', - subagentType: 'self', - events: [], - status: 'run_step', - }), - }); - expect(screen.getByText('Cancelled agent')).toBeInTheDocument(); - }); - - it('renders "Agent errored" when the subagent emits an `error` phase', () => { - renderWithState({ - toolCallId: 'call_error', - initialProgress: 0.4, - isSubmitting: true, - progress: progressFromEvents({ - subagentRunId: 'run_a', - subagentType: 'self', - events: [], - status: 'error', - }), - }); - expect(screen.getByText('Agent errored')).toBeInTheDocument(); - }); - - it('uses the base "Running agent" label for non-self subagent types (name shown as sub-label elsewhere)', () => { - renderWithState({ - toolCallId: 'call_named', - initialProgress: 0.3, - isSubmitting: true, - progress: progressFromEvents({ - subagentRunId: 'run_b', - subagentType: 'researcher', - events: [], - status: 'run_step', - }), - }); - /** Header base label is constant ("Running agent"). The agent - * display name is rendered as a muted sub-label, which this test - * doesn't exercise (no agents-map context is seeded). */ - expect(screen.getByText('Running agent')).toBeInTheDocument(); - }); -}); - -describe('SubagentCall — ticker', () => { - it('renders semantic text lines instead of raw event names', async () => { - renderWithState({ - toolCallId: 'call_ticker', - initialProgress: 0.3, - isSubmitting: true, - progress: progressFromEvents({ - subagentRunId: 'run_a', - subagentType: 'self', - status: 'run_step', - events: [ - { - runId: 'p', - subagentRunId: 'run_a', - subagentType: 'self', - subagentAgentId: 'child', - phase: 'message_delta', - data: { delta: { content: [{ type: 'text', text: 'Computing result…' }] } }, - timestamp: '', - }, - { - runId: 'p', - subagentRunId: 'run_a', - subagentType: 'self', - subagentAgentId: 'child', - phase: 'run_step', - data: { - stepDetails: { - type: 'tool_calls', - tool_calls: [{ id: 'c1', name: 'calculator', args: '{"expression":"42*58"}' }], - }, - }, - timestamp: '', - }, - { - runId: 'p', - subagentRunId: 'run_a', - subagentType: 'self', - subagentAgentId: 'child', - phase: 'run_step_completed', - data: { - result: { - type: 'tool_call', - tool_call: { - id: 'c1', - name: 'calculator', - output: '42*58 = 2436', - progress: 1, - }, - }, - }, - timestamp: '', - }, - ], - }), - }); - - /** Ticker now renders a structured `using_tool` line — prefix span - * ("Using"), a code-style badge for the tool name, and a muted - * args snippet. Check the pieces individually rather than a - * combined text match. */ - await waitFor( - () => { - expect(screen.getByText('Using')).toBeInTheDocument(); - }, - { timeout: 2500 }, - ); - /** Raw event names never appear in the ticker. */ - expect(screen.queryByText(/on_run_step/)).not.toBeInTheDocument(); - expect(screen.queryByText(/on_message_delta/)).not.toBeInTheDocument(); - /** Tool name renders as a `` badge, args snippet in parens. */ - const calcBadges = screen.getAllByText('calculator'); - expect(calcBadges.some((el) => el.tagName === 'CODE')).toBe(true); - expect(screen.getByText('(expression=42*58)')).toBeInTheDocument(); - /** Completion line renders the output snippet in the body span. */ - expect(screen.getAllByText('42*58 = 2436').length).toBeGreaterThan(0); - }); - - it('collapses a streak of message_delta events into one live Writing line', async () => { - renderWithState({ - toolCallId: 'call_writing', - initialProgress: 0.3, - isSubmitting: true, - progress: progressFromEvents({ - subagentRunId: 'run_a', - subagentType: 'self', - status: 'message_delta', - events: ['Hello ', 'world', '!'].map((text) => ({ - runId: 'p', - subagentRunId: 'run_a', - subagentType: 'self', - subagentAgentId: 'child', - phase: 'message_delta' as const, - data: { delta: { content: [{ type: 'text', text }] } }, - timestamp: '', - })), - }), - }); - /** Ticker renders the "Writing:" label and the body in separate spans - * (prefix is `shrink-0`, body is a tail-truncatable sibling) so the - * label never gets clipped when the body overflows. "Hello world!" - * also appears in the dialog body (mocked OGDialog renders - * children), so `getAllByText` is needed for the body. */ - await waitFor( - () => { - expect(screen.getAllByText('Hello world!').length).toBeGreaterThan(0); - }, - { timeout: 2500 }, - ); - /** Only one "Writing:" label, not three — deltas collapse into one live line. */ - expect(screen.getAllByText('Writing:')).toHaveLength(1); - }); - - it('refreshes long live previews after the subagent ticker throttle window', () => { - jest.useFakeTimers(); - const firstPreview = 'First live preview '.repeat(8).trim(); - const secondPreview = 'Second live preview '.repeat(8).trim(); - const eventForText = (text: string): SubagentUpdateEvent => ({ - runId: 'p', - subagentRunId: 'run_a', - subagentType: 'self', - subagentAgentId: 'child', - phase: 'message_delta', - data: { delta: { content: [{ type: 'text', text }] } }, - timestamp: '', - }); - const progressForText = (text: string): SubagentProgress => - progressFromEvents({ - subagentRunId: 'run_a', - subagentType: 'self', - status: 'message_delta', - events: [eventForText(text)], - }); - - const { setProgress } = renderWithState({ - toolCallId: 'call_throttled_writing', - initialProgress: 0.3, - isSubmitting: true, - progress: progressForText(firstPreview), - }); - const ticker = within(screen.getByRole('button', { name: 'Running agent' })); - - expect(ticker.getByText(firstPreview)).toBeInTheDocument(); - setProgress(progressForText(secondPreview)); - expect(ticker.getByText(firstPreview)).toBeInTheDocument(); - expect(ticker.queryByText(secondPreview)).not.toBeInTheDocument(); - - act(() => { - jest.advanceTimersByTime(SUBAGENT_TICKER_THROTTLE_MS - 1); - }); - expect(ticker.getByText(firstPreview)).toBeInTheDocument(); - expect(ticker.queryByText(secondPreview)).not.toBeInTheDocument(); - - act(() => { - jest.advanceTimersByTime(1); - }); - expect(ticker.getByText(secondPreview)).toBeInTheDocument(); - }); -}); - -describe('SubagentCall — dialog content', () => { - it('renders the prompt through MarkdownLite and expands it inline with the activity area', () => { - render( + const rendered = render( + - + + - , - ); + > + + + + , + ); + act(() => setter.current?.(args.progress ?? null)); + return { + ...rendered, + getSelection: () => selection, + setProgress: (next: SubagentProgress | null) => act(() => setter.current?.(next)), + }; +} - openSubagentDialog(); - expect(screen.getByTestId('prompt-markdown')).toHaveTextContent('# Review prompt'); - expect(screen.getByText('final answer')).toBeInTheDocument(); +const event = ( + phase: SubagentUpdateEvent['phase'], + data: SubagentUpdateEvent['data'], +): SubagentUpdateEvent => ({ + runId: 'parent-run', + subagentRunId: 'child-run', + subagentType: 'self', + subagentAgentId: 'child', + phase, + data, + timestamp: '', +}); - const expandButton = screen.getByRole('button', { name: 'Expand' }); - expect(expandButton).toHaveAttribute('aria-expanded', 'false'); - - fireEvent.click(expandButton); - - expect(screen.getByRole('button', { name: 'Collapse' })).toHaveAttribute( - 'aria-expanded', - 'true', - ); - expect(screen.getByText('final answer')).toBeInTheDocument(); +describe('SubagentCall', () => { + it.each([ + ['Running agent', 0.3, true, 'run_step'], + ['Ran agent', 1, false, undefined], + ['Cancelled agent', 0.3, false, 'run_step'], + ['Agent errored', 0.3, true, 'error'], + ] as const)('renders the %s lifecycle label', (label, initialProgress, isSubmitting, phase) => { + renderWithState({ + toolCallId: `call-${label}`, + initialProgress, + isSubmitting, + progress: + phase == null + ? null + : progressFromEvents({ + subagentRunId: 'child-run', + subagentType: 'self', + status: phase, + events: [], + }), + }); + expect(screen.getByText(label)).toBeInTheDocument(); }); - it('renders aggregated text, reasoning, and tool_call parts through leaf renderers', () => { - renderWithState({ - toolCallId: 'call_dialog', - initialProgress: 0.4, + it('keeps the compact semantic ticker while selecting the shared panel', async () => { + const progress = progressFromEvents({ + subagentRunId: 'child-run', + subagentType: 'self', + status: 'run_step', + events: [ + event('message_delta', { delta: { content: [{ type: 'text', text: 'Working…' }] } }), + event('run_step', { + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'inner-1', name: 'calculator', args: '{"value":4}' }], + }, + }), + ], + }); + const rendered = renderWithState({ + toolCallId: 'call-live', + initialProgress: 0.3, isSubmitting: true, - progress: progressFromEvents({ - subagentRunId: 'run_a', - subagentType: 'self', - status: 'stop', - events: [ - { - runId: 'p', - subagentRunId: 'run_a', - subagentType: 'self', - subagentAgentId: 'child', - phase: 'reasoning_delta', - data: { delta: { content: [{ type: 'think', think: 'Let me compute.' }] } }, - timestamp: '', - }, - { - runId: 'p', - subagentRunId: 'run_a', - subagentType: 'self', - subagentAgentId: 'child', - phase: 'run_step', - data: { - stepDetails: { - type: 'tool_calls', - tool_calls: [{ id: 'c1', name: 'calculator', args: '{}' }], - }, - }, - timestamp: '', - }, - { - runId: 'p', - subagentRunId: 'run_a', - subagentType: 'self', - subagentAgentId: 'child', - phase: 'run_step_completed', - data: { - result: { - type: 'tool_call', - tool_call: { id: 'c1', name: 'calculator', output: '4', progress: 1 }, - }, - }, - timestamp: '', - }, - { - runId: 'p', - subagentRunId: 'run_a', - subagentType: 'self', - subagentAgentId: 'child', - phase: 'message_delta', - data: { delta: { content: [{ type: 'text', text: 'The answer is 4.' }] } }, - timestamp: '', - }, - ], - }), + progress, }); - openSubagentDialog(); - expect(screen.getByTestId('reasoning-part')).toHaveTextContent('Let me compute.'); - expect(screen.getByTestId('tool-call-part')).toHaveAttribute('data-name', 'calculator'); - expect(screen.getByTestId('tool-call-part')).toHaveTextContent('4'); - expect(screen.getByTestId('text-part')).toHaveTextContent('The answer is 4.'); - }); + await waitFor(() => expect(screen.getByText('Using')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: 'Running agent' })); - it('falls back to the raw tool output when no content parts were recorded', () => { - /** No events → no aggregated parts. The SubagentCall should still - * render the raw final `output` that came back in the parent's - * tool_call. */ - const { rerender } = render( - - - , + expect(rendered.getSelection()).toEqual( + expect.objectContaining({ + parentConversationId: 'parent-conversation', + toolCallId: 'call-live', + subagentType: 'self', + initialProgress: 0.3, + isSubmitting: true, + }), ); - openSubagentDialog(); - expect(screen.getByText('raw final text')).toBeInTheDocument(); - rerender({null}); + expect(rendered.getSelection()?.durable).toBeUndefined(); }); - it('opens only an exact host-issued detached result in the parent activity panel', () => { + it('refreshes a long ticker preview only after the throttle window', () => { + jest.useFakeTimers(); + const progressFor = (text: string) => + progressFromEvents({ + subagentRunId: 'child-run', + subagentType: 'self', + status: 'message_delta', + events: [event('message_delta', { delta: { content: [{ type: 'text', text }] } })], + }); + const first = 'First live preview '.repeat(8).trim(); + const second = 'Second live preview '.repeat(8).trim(); + const rendered = renderWithState({ + toolCallId: 'call-throttle', + initialProgress: 0.3, + isSubmitting: true, + progress: progressFor(first), + }); + const card = within(screen.getByRole('button', { name: 'Running agent' })); + rendered.setProgress(progressFor(second)); + expect(card.getByText(first)).toBeInTheDocument(); + act(() => jest.advanceTimersByTime(SUBAGENT_TICKER_THROTTLE_MS)); + expect(card.getByText(second)).toBeInTheDocument(); + }); + + it('opens a foreground legacy invocation in the shared panel with persisted activity', () => { + let selection: ActiveSubagentPanel | null = null; + const Observer = () => { + selection = useRecoilValue(activeSubagentPanel); + return null; + }; + const persistedContent = [ + { type: 'think', think: 'Visible reasoning.' }, + { + type: 'tool_call', + tool_call: { id: 'inner-1', name: 'calculator', args: '{}', output: '4', progress: 1 }, + }, + { type: 'text', text: 'The answer is 4.' }, + ] as Parameters[0]['persistedContent']; + render( + + + + + + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Ran agent' })); + expect(selection).toEqual( + expect.objectContaining({ + prompt: 'Compute the answer.', + legacyOutput: 'legacy fallback', + persistedContent, + }), + ); + expect((selection as ActiveSubagentPanel | null)?.durable).toBeUndefined(); + }); + + it('opens an exact host-issued detached invocation in the same panel', () => { const output = JSON.stringify({ background_task_id: 'task-1', subagent_thread_id: 'child-thread-1', @@ -659,48 +310,24 @@ describe('SubagentCall — dialog content', () => { message: 'Started subagent "self" background task. Poll the host background-task tool with background_task_id "task-1".', }); - let selectedPanel: ActiveSubagentPanel | null = null; - const SelectionObserver = () => { - selectedPanel = useRecoilValue(activeSubagentPanel); - return null; - }; - render( - - - - - - - - , - ); + const rendered = renderWithState({ + toolCallId: 'detached-call', + initialProgress: 1, + output, + toolArgs: { subagent_type: 'self', run_in_background: true }, + }); fireEvent.click(screen.getByRole('button', { name: 'Agent activity' })); - expect(selectedPanel).toEqual({ - parentConversationId: 'parent-conversation', - threadId: 'child-thread-1', - taskId: 'task-1', - toolCallId: 'call_detached', - subagentType: 'self', - }); - expect(mockNavigateToConvo).not.toHaveBeenCalled(); - expect(screen.queryByTestId('dialog-content')).not.toBeInTheDocument(); - expect(screen.queryByText(output)).not.toBeInTheDocument(); + expect(rendered.getSelection()).toEqual( + expect.objectContaining({ + toolCallId: 'detached-call', + durable: { threadId: 'child-thread-1', taskId: 'task-1' }, + }), + ); + expect(rendered.getSelection()?.legacyOutput).toBeUndefined(); }); - it('does not turn model-authored foreground output into a child-chat link', () => { + it('keeps model-authored lookalike output on the foreground adapter', () => { const output = JSON.stringify({ background_task_id: 'task-1', subagent_thread_id: 'child-thread-1', @@ -709,126 +336,15 @@ describe('SubagentCall — dialog content', () => { status: 'running', message: 'background_task_id task-1', }); - render( - - - , - ); - - openSubagentDialog(); - expect(screen.queryByRole('link', { name: 'Open child chat' })).not.toBeInTheDocument(); - expect(screen.getByText(output)).toBeInTheDocument(); - }); - - it('renders persistedContent parts when no live events are available (page-refresh flow)', () => { - /** - * After a refresh the Recoil atom is empty — the child's history has - * to come from the `subagent_content` array the backend attached to - * the tool_call at message-save time. Verifies that a - * `persistedContent` prop routes through the same leaf renderers - * (Text / Reasoning / ToolCall) as live aggregation so a reopened - * dialog looks identical to how the run streamed. - */ - const persistedContent = [ - { type: 'think', think: 'Prior thinking.' }, - { - type: 'tool_call', - tool_call: { - id: 'inner-1', - name: 'calculator', - args: '{"expression":"42*58"}', - output: '2436', - progress: 1, - }, - }, - { type: 'text', text: 'Final persisted answer.' }, - ] as unknown as Parameters[0]['persistedContent']; - - render( - - - , - ); - - openSubagentDialog(); - expect(screen.getByTestId('reasoning-part')).toHaveTextContent('Prior thinking.'); - expect(screen.getByTestId('tool-call-part')).toHaveAttribute('data-name', 'calculator'); - expect(screen.getByTestId('tool-call-part')).toHaveTextContent('2436'); - expect(screen.getByTestId('text-part')).toHaveTextContent('Final persisted answer.'); - }); - - it('prefers persistedContent when both are populated (sync/reconnect canonical)', () => { - /** - * Codex P2 regression: after a disconnect/reconnect the live - * Recoil bucket can be stale or partial — it missed events - * while the socket was down. The server-written - * `persistedContent` on the `tool_call` is the canonical trace - * of the completed run, so when it's present the dialog should - * show it, not the (possibly lossy) live aggregation. - * - * This also covers the post-stream case where persistence has - * landed and both snapshots carry the same content — preferring - * persisted is still correct because it's the authoritative copy. - */ - render( - - [0]['persistedContent'] - } - /> - , - ); - openSubagentDialog(); - expect(screen.getByText('Persisted answer.')).toBeInTheDocument(); - }); - - it('falls back to live aggregated events when persistedContent is empty (mid-stream)', () => { - /** - * Before the parent message saves, `persistedContent` is - * undefined/empty — the live atom is the only source of truth. - * Verify we render the live aggregation in that case. - */ - renderWithState({ - toolCallId: 'call_live_fallback', - initialProgress: 0.4, - isSubmitting: true, - progress: progressFromEvents({ - subagentRunId: 'run_live', - subagentType: 'self', - status: 'message_delta', - events: [ - { - runId: 'p', - subagentRunId: 'run_live', - subagentType: 'self', - subagentAgentId: 'child', - phase: 'message_delta', - data: { delta: { content: [{ type: 'text', text: 'Live answer.' }] } }, - timestamp: '', - }, - ], - }), + const rendered = renderWithState({ + toolCallId: 'spoofed-call', + initialProgress: 1, + output, + toolArgs: { subagent_type: 'self' }, }); - /** Live content renders — both in the ticker preview (collapsed - * card) and inside the dialog body (mocked OGDialog renders - * children). */ - expect(screen.getAllByText('Live answer.').length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole('button', { name: 'Ran agent' })); + expect(rendered.getSelection()?.durable).toBeUndefined(); + expect(rendered.getSelection()?.legacyOutput).toBe(output); }); }); diff --git a/client/src/components/Chat/Presentation.test.tsx b/client/src/components/Chat/Presentation.test.tsx index 7787f0b7d8..756fe4a8f9 100644 --- a/client/src/components/Chat/Presentation.test.tsx +++ b/client/src/components/Chat/Presentation.test.tsx @@ -94,10 +94,16 @@ const OpenSubagentPanel = () => { setConversation({ conversationId: 'parent-conversation' } as TConversation); setSelection({ parentConversationId: 'parent-conversation', - threadId: 'child-thread', - taskId: 'background-task', + parentMessageId: 'parent-message', toolCallId: 'tool-call', + partIndex: 0, subagentType: 'researcher', + initialProgress: 1, + isSubmitting: false, + durable: { + threadId: 'child-thread', + taskId: 'background-task', + }, }); }; return ( diff --git a/client/src/components/Chat/Subagents/SubagentActivity.test.tsx b/client/src/components/Chat/Subagents/SubagentActivity.test.tsx new file mode 100644 index 0000000000..dc9def6470 --- /dev/null +++ b/client/src/components/Chat/Subagents/SubagentActivity.test.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { Agents } from 'librechat-data-provider'; +import type { ChildActivity } from './adapters'; +import SubagentActivity from './SubagentActivity'; + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +jest.mock('~/components/Chat/Messages/Content/Parts/Text', () => ({ + __esModule: true, + default: ({ text }: { text: string }) =>
{text}
, +})); + +jest.mock('~/components/Chat/Messages/Content/Parts/Reasoning', () => ({ + __esModule: true, + default: ({ reasoning }: { reasoning: string }) =>
{reasoning}
, +})); + +jest.mock('~/components/Chat/Messages/Content/MarkdownLite', () => ({ + __esModule: true, + default: ({ content }: { content: string }) =>
{content}
, +})); + +jest.mock('~/components/Chat/Messages/Content/Container', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +jest.mock('~/components/Chat/Messages/Content/ToolCall', () => ({ + __esModule: true, + default: function MockToolCall({ + name, + args, + output, + runStepStatus, + }: { + name: string; + args: unknown; + output: string; + runStepStatus?: string; + }) { + const { useState } = jest.requireActual('react'); + const [open, setOpen] = useState(false); + return ( +
+ + {open && ( +
+ {JSON.stringify(args)} {output} +
+ )} +
+ ); + }, +})); + +jest.mock('~/components/Chat/Messages/Content/ToolCallGroup', () => ({ + __esModule: true, + default: ({ + parts, + renderPart, + lastContentIdx, + }: { + parts: Array<{ part: unknown; idx: number }>; + renderPart: (part: unknown, idx: number, isLast: boolean) => React.ReactNode; + lastContentIdx: number; + }) => ( +
+ {/* eslint-disable-next-line i18next/no-literal-string */} +
Used {parts.length} tools
+ {parts.map(({ part, idx }) => renderPart(part, idx, idx === lastContentIdx))} +
+ ), +})); + +jest.mock('~/components/Chat/Messages/Content/ToolApproval', () => ({ + __esModule: true, + // eslint-disable-next-line i18next/no-literal-string + default: () =>
approval
, +})); + +jest.mock('@librechat/client', () => ({ + Button: ({ children, ...props }: React.ComponentProps<'button'>) => ( + + ), +})); + +jest.mock('lucide-react', () => ({ + AlertCircle: () => null, + ArrowDown: () => null, + CheckCircle2: () => null, + Clock3: () => null, + Maximize2: () => null, + Minimize2: () => null, + XCircle: () => null, +})); + +const base: ChildActivity = { + title: 'Research child', + prompt: 'Investigate.', + status: 'completed', + items: [ + { type: 'reasoning', text: 'Visible reasoning.' }, + { + type: 'tool', + toolCallId: 'tool-1', + name: 'search', + input: '{"query":"release"}', + output: 'Found it.', + status: 'completed', + }, + { + type: 'tool', + toolCallId: 'tool-2', + name: 'calculator', + input: '{"value":4}', + output: '4', + status: 'completed', + }, + { type: 'writing', text: 'Final answer.' }, + ], +}; + +describe('SubagentActivity', () => { + it.each(['running', 'completed', 'failed', 'cancelled'] as const)( + 'renders the %s lifecycle through the shared view', + (status) => { + render(); + expect(screen.getByText(`com_ui_subagent_thread_status_${status}`)).toBeInTheDocument(); + }, + ); + + it('renders approval controls when the provider persists an empty output', () => { + render( + , + ); + + expect(screen.getByTestId('tool-approval')).toBeInTheDocument(); + }); + + it('marks bounded tool details as shortened without expanding them', () => { + render( + , + ); + + expect(screen.queryByText('bounded input')).not.toBeInTheDocument(); + expect(screen.getByText('com_ui_subagent_thread_message_truncated')).toBeInTheDocument(); + }); + + it('renders writing, reasoning, grouped tools, and collapsed details', () => { + render(); + + expect(screen.getByText('com_ui_subagent_ticker_writing')).toBeInTheDocument(); + expect(screen.getByText('Visible reasoning.')).toBeInTheDocument(); + expect(screen.getByText('Used 2 tools')).toBeInTheDocument(); + expect(screen.queryByText(/Found it/)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'search' })); + expect(screen.getByText(/Found it/)).toBeInTheDocument(); + }); + + it.each(['running', 'completed', 'failed', 'cancelled'] as const)( + 'renders a %s tool lifecycle through the shared view', + (status) => { + render( + , + ); + + const tool = screen.getByRole('button', { name: 'search' }); + if (status === 'running') { + expect(tool).not.toHaveAttribute('data-run-step-status'); + } else { + expect(tool).toHaveAttribute('data-run-step-status', status); + } + }, + ); + + it.each([ + ['loading', 'com_ui_subagent_waiting'], + ['error', 'com_ui_subagent_thread_load_error'], + ['ready', 'com_ui_subagent_empty_result'], + ] as const)('renders the %s state', (state, label) => { + render(); + expect(screen.getByText(label)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Chat/Subagents/SubagentActivity.tsx b/client/src/components/Chat/Subagents/SubagentActivity.tsx new file mode 100644 index 0000000000..18cbc65b0a --- /dev/null +++ b/client/src/components/Chat/Subagents/SubagentActivity.tsx @@ -0,0 +1,357 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { Button } from '@librechat/client'; +import { ContentTypes } from 'librechat-data-provider'; +import { + AlertCircle, + ArrowDown, + CheckCircle2, + Clock3, + Maximize2, + Minimize2, + XCircle, +} from 'lucide-react'; +import type { TMessageContentParts } from 'librechat-data-provider'; +import type { PartWithIndex } from '~/components/Chat/Messages/Content/ParallelContent'; +import type { ChildActivity, ChildActivityItem } from './adapters'; +import ToolCallGroup from '~/components/Chat/Messages/Content/ToolCallGroup'; +import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite'; +import ToolApproval from '~/components/Chat/Messages/Content/ToolApproval'; +import Reasoning from '~/components/Chat/Messages/Content/Parts/Reasoning'; +import Container from '~/components/Chat/Messages/Content/Container'; +import ToolCall from '~/components/Chat/Messages/Content/ToolCall'; +import Text from '~/components/Chat/Messages/Content/Parts/Text'; +import { MessageContext } from '~/Providers/MessageContext'; +import { cn, groupSequentialToolCalls } from '~/utils'; +import { useLocalize } from '~/hooks'; + +const AT_BOTTOM_THRESHOLD_PX = 120; + +const statusIcon = (status: ChildActivity['status']) => { + if (status === 'completed') return CheckCircle2; + if (status === 'failed' || status === 'interrupted') return AlertCircle; + if (status === 'cancelled') return XCircle; + return Clock3; +}; + +const statusLabels = { + dispatched: 'com_ui_subagent_thread_status_dispatched', + running: 'com_ui_subagent_thread_status_running', + completed: 'com_ui_subagent_thread_status_completed', + failed: 'com_ui_subagent_thread_status_failed', + interrupted: 'com_ui_subagent_thread_status_interrupted', + cancelled: 'com_ui_subagent_thread_status_cancelled', +} as const; + +const toContentPart = (item: ChildActivityItem): TMessageContentParts => { + if (item.type === 'writing') { + return { type: ContentTypes.TEXT, text: item.text } as TMessageContentParts; + } + if (item.type === 'reasoning') { + return { type: ContentTypes.THINK, think: item.text ?? '' } as TMessageContentParts; + } + return { + type: ContentTypes.TOOL_CALL, + [ContentTypes.TOOL_CALL]: { + id: item.toolCallId, + name: item.name, + args: item.input ?? '', + output: item.output ?? '', + progress: item.status === 'running' ? 0.1 : 1, + ...(item.approval == null ? {} : { approval: item.approval }), + }, + } as TMessageContentParts; +}; + +function ActivityPart({ + item, + part, + isSubmitting, + showCursor, + isLast, + onToolExpand, +}: { + item: ChildActivityItem; + part: TMessageContentParts; + isSubmitting: boolean; + showCursor: boolean; + isLast: boolean; + onToolExpand?: () => void; +}) { + const localize = useLocalize(); + if (item.type === 'writing') { + return ( + +
+ {localize('com_ui_subagent_ticker_writing')} +
+ + {item.textTruncated === true && ( +
+ {localize('com_ui_subagent_thread_message_truncated')} +
+ )} +
+ ); + } + if (item.type === 'reasoning') { + if (item.text == null || item.text === '') { + return ( +
+ {localize('com_ui_subagent_ticker_reasoning')} +
+ ); + } + return ; + } + const tool = ( + part as { + [ContentTypes.TOOL_CALL]: { + id: string; + args: string | Record; + output: string; + name: string; + progress: number; + }; + } + )[ContentTypes.TOOL_CALL]; + const toolCall = ( + + ); + const truncationNotice = + item.inputTruncated === true || item.outputTruncated === true ? ( +
+ {localize('com_ui_subagent_thread_message_truncated')} +
+ ) : null; + if (item.approval != null && (item.output?.length ?? 0) === 0) { + return ( + <> + {toolCall} + {truncationNotice} + + + ); + } + return ( + <> + {toolCall} + {truncationNotice} + + ); +} + +function SubagentPrompt({ prompt }: { prompt: string }) { + const localize = useLocalize(); + const [expanded, setExpanded] = useState(false); + const headingId = useId(); + const contentId = useId(); + const toggleLabel = expanded ? localize('com_ui_collapse') : localize('com_ui_expand'); + return ( +
+
+

+ {localize('com_ui_prompt')} +

+ +
+
+
+ +
+ {!expanded && ( +
+ )} +
+
+ ); +} + +export default function SubagentActivity({ + activity, + state = 'ready', +}: { + activity: ChildActivity; + state?: 'ready' | 'loading' | 'error'; +}) { + const localize = useLocalize(); + const scrollRef = useRef(null); + const contentRef = useRef(null); + const [isAtBottom, setIsAtBottom] = useState(true); + const isSubmitting = activity.status === 'running' || activity.status === 'dispatched'; + const StatusIcon = statusIcon(activity.status); + const parts = useMemo(() => activity.items.map(toContentPart), [activity.items]); + const groupedParts = useMemo(() => { + const indexed: PartWithIndex[] = parts.map((part, idx) => ({ part, idx })); + return groupSequentialToolCalls(indexed); + }, [parts]); + const context = useMemo( + () => ({ + messageId: 'subagent-activity-panel', + isExpanded: true, + isSubmitting, + isLatestMessage: isSubmitting, + conversationId: null, + }), + [isSubmitting], + ); + const renderPart = useCallback( + (part: TMessageContentParts, idx: number, isLast: boolean, onToolExpand?: () => void) => ( + + ), + [activity.items, isSubmitting], + ); + + useEffect(() => { + const scroll = scrollRef.current; + const content = contentRef.current; + if (scroll == null || content == null || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => { + if (isAtBottom) scroll.scrollTop = scroll.scrollHeight; + }); + observer.observe(content); + return () => observer.disconnect(); + }, [isAtBottom]); + + const handleScroll = useCallback((event: React.UIEvent) => { + const element = event.currentTarget; + setIsAtBottom( + element.scrollHeight - element.scrollTop - element.clientHeight <= AT_BOTTOM_THRESHOLD_PX, + ); + }, []); + + let body: React.ReactNode; + if (state === 'loading') { + body = ( +
+ {localize('com_ui_subagent_waiting')} +
+ ); + } else if (state === 'error') { + body = ( +
+ {localize('com_ui_subagent_thread_load_error')} +
+ ); + } else if (activity.items.length === 0) { + body = ( +
+ {isSubmitting + ? localize('com_ui_subagent_no_result_yet') + : localize('com_ui_subagent_empty_result')} +
+ ); + } else { + const last = parts.length - 1; + body = ( + + {groupedParts.map((group) => + group.type === 'single' ? ( + renderPart(group.part.part, group.part.idx, group.part.idx === last) + ) : ( + part.idx === last)} + renderPart={renderPart} + lastContentIdx={last} + /> + ), + )} + + ); + } + + return ( +
+
+
+ + {localize(statusLabels[activity.status])} +
+
+
+ {!isAtBottom && ( + + )} +
+ {activity.prompt != null && } + {activity.activityTruncated === true && ( +
+ {localize('com_ui_subagent_thread_history_truncated')} +
+ )} + {body} +
+
+
+ ); +} diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx index ce24533f82..ede8983cfa 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx @@ -1,13 +1,20 @@ import React from 'react'; import { RecoilRoot, useRecoilValue } from 'recoil'; -import { fireEvent, render, screen } from '@testing-library/react'; -import type { SubagentThreadView } from 'librechat-data-provider'; +import { ContentTypes } from 'librechat-data-provider'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { SubagentThreadView, TMessageContentParts } from 'librechat-data-provider'; import type { ActiveSubagentPanel } from '~/store/subagents'; -import { activeSubagentPanel } from '~/store/subagents'; +import { + activeSubagentPanel, + subagentProgressByToolCallId, + subagentProgressKey, +} from '~/store/subagents'; +import { initSubagentAggregatorState, initSubagentTickerState } from '~/utils/subagentContent'; import SubagentThreadPanel from './SubagentThreadPanel'; const mockUseSubagentThreadQuery = jest.fn(); -const mockSpinnerLabel = 'spinner'; +const mockApprovalProviderMounted = jest.fn(); +const mockApprovalProviderUnmounted = jest.fn(); let mockIsMobile = false; jest.mock('~/data-provider', () => ({ @@ -19,16 +26,45 @@ jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key, })); +jest.mock('~/components/Chat/Messages/Content/ApprovalContext', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => { + const mockReact = jest.requireActual('react'); + mockReact.useEffect(() => { + mockApprovalProviderMounted(); + return () => mockApprovalProviderUnmounted(); + }, []); + return children; + }, +})); + jest.mock('~/components/Chat/Messages/Content/MarkdownLite', () => ({ __esModule: true, default: ({ content }: { content: string }) =>
{content}
, })); +jest.mock('./SubagentActivity', () => ({ + __esModule: true, + default: ({ + activity, + state, + }: { + activity: { status: string; prompt?: string; items: Array<{ type: string; text?: string }> }; + state: string; + }) => ( +
+ {activity.prompt} + {activity.items.map((item, index) => ( + {item.text ?? item.type} + ))} +
+ ), +})); + jest.mock('@librechat/client', () => ({ Button: ({ children, ...props }: React.ComponentProps<'button'>) => ( ), - Spinner: () => {mockSpinnerLabel}, useMediaQuery: () => mockIsMobile, })); @@ -43,10 +79,13 @@ jest.mock('lucide-react', () => ({ const selection: ActiveSubagentPanel = { parentConversationId: 'parent-conversation', - threadId: 'child-thread', - taskId: 'task', + parentMessageId: 'parent-message', toolCallId: 'tool-call', + partIndex: 2, subagentType: 'researcher', + initialProgress: 1, + isSubmitting: false, + durable: { threadId: 'child-thread', taskId: 'task' }, }; const completedView: SubagentThreadView = { @@ -58,6 +97,8 @@ const completedView: SubagentThreadView = { subagentKind: 'agent', title: 'Research child', status: 'completed', + activity: [{ type: 'writing', text: 'The release is ready.' }], + activityTruncated: false, historyTruncated: true, messages: [ { @@ -79,9 +120,11 @@ const completedView: SubagentThreadView = { describe('SubagentThreadPanel', () => { beforeEach(() => { mockIsMobile = false; + mockApprovalProviderMounted.mockClear(); + mockApprovalProviderUnmounted.mockClear(); }); - it('renders a bounded read-only activity timeline and closes its selection', () => { + it('renders a bounded read-only activity timeline and closes its selection', async () => { mockUseSubagentThreadQuery.mockReturnValue({ data: completedView, isLoading: false, @@ -94,8 +137,14 @@ describe('SubagentThreadPanel', () => { return null; }; - render( + const { container } = render( set(activeSubagentPanel, selection)}> +